"""Daily log service layer — business logic + DB access (spec §8.1 rules 2–3). - Log entries reference foods; embedded food data is included in responses. - sort_order for new entries defaults to end of day: max(sort_order) + 1 for that date, starting at 1 if no entries exist yet. - Soft-deleted foods are rejected on create (404 — you can't log what you can't search), but still render in historical GET responses (§2.1). ORM objects never leave this module; functions return Pydantic schemas. """ from datetime import date, datetime, timezone from sqlalchemy import func, select from sqlalchemy.orm import Session, joinedload from models import DailyLogEntry, Food from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate def _now() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) def _load_entry_with_food(db: Session, entry_id: int) -> DailyLogEntry | None: """Eager-load a single entry with its food relationship.""" return db.scalar( select(DailyLogEntry) .where(DailyLogEntry.id == entry_id) .options(joinedload(DailyLogEntry.food)) ) # ── CRUD ───────────────────────────────────────────────────────────────────── def get_log_entries(db: Session, lookup_date: date) -> list[LogEntryRead]: """All entries for a date, ordered by sort_order then id, with embedded food data eagerly loaded. Soft-deleted foods still render (§2.1).""" stmt = ( select(DailyLogEntry) .where(DailyLogEntry.date == lookup_date) .options(joinedload(DailyLogEntry.food)) .order_by(DailyLogEntry.sort_order, DailyLogEntry.id) ) entries = db.scalars(stmt).all() return [LogEntryRead.model_validate(e) for e in entries] def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead: """Create a log entry. Validates that food_id exists and is not soft-deleted. sort_order defaults to end of day for the given date. Raises FoodNotAvailableError if food_id is unknown or the food is soft-deleted (you can't log what you can't search — spec §2.1). """ food = db.get(Food, data.food_id) if food is None or food.deleted_at is not None: raise FoodNotAvailableError(data.food_id) max_sort = db.scalar( select(func.max(DailyLogEntry.sort_order)).where( DailyLogEntry.date == data.date ) ) sort_order = (max_sort or 0) + 1 entry = DailyLogEntry( date=data.date, food_id=data.food_id, quantity=data.quantity, meal_slot=data.meal_slot, sort_order=sort_order, created_at=_now(), ) db.add(entry) db.commit() # Re-query with eager-loaded food for the response return LogEntryRead.model_validate(_load_entry_with_food(db, entry.id)) def update_log_entry( db: Session, entry_id: int, data: LogEntryUpdate ) -> LogEntryRead | None: """Update quantity, meal_slot, and/or sort_order. Uses model_dump(exclude_unset=True) so only explicitly-provided fields are changed. Send {"meal_slot": null} to clear the slot. Returns None if the entry is not found. """ entry = db.get(DailyLogEntry, entry_id) if entry is None: return None update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(entry, field, value) db.commit() return LogEntryRead.model_validate(_load_entry_with_food(db, entry_id)) def delete_log_entry(db: Session, entry_id: int) -> bool: """Hard-delete a log entry (§2.3 has no soft-delete). Returns True if deleted, False if not found. """ entry = db.get(DailyLogEntry, entry_id) if entry is None: return False db.delete(entry) db.commit() return True # ── Errors ─────────────────────────────────────────────────────────────────── class FoodNotAvailableError(Exception): """Raised when a food_id is unknown or the food is soft-deleted.""" def __init__(self, food_id: int): super().__init__(f"Food {food_id} not available for logging") self.food_id = food_id