"""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 ( DaySummaryNutrition, DaySummaryResponse, LogEntryCreate, LogEntryRead, LogEntryUpdate, ) from services import nutrition from services import targets as targets_svc 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 # ── Day Summary (TICKET-004) ───────────────────────────────────────────────── def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse: """Compute nutrition totals for a date vs. the applicable target. 1. Loads all log entries for the date with food data eagerly joined. 2. For each entry, scales the food's per-unit nutrition to the logged quantity via nutrition.entry_nutrition() — weight-type foods get (qty/100)× scaling, count-type foods get qty× scaling (§2.1). 3. Meal entries (is_meal=True) currently contribute 0 because their per_unit fields are null per the CHECK constraint. TODO: TICKET-007 — replace with recursive component summation. 4. Looks up the target covering the date via the half-open interval lookup from targets.get_target_for_date(). Returns null if none. Returns a DaySummaryResponse with summed totals and the applicable target (or null). """ entries = db.scalars( select(DailyLogEntry) .where(DailyLogEntry.date == lookup_date) .options(joinedload(DailyLogEntry.food)) ).all() # Sum nutrition across all entries for the date. # TODO: TICKET-007 — meal entries currently contribute 0 because their # per_unit fields are null per the CHECK constraint. Real meal nutrition # will be derived by recursively summing component foods' nutrition. # When that lands, replace the flat entry_nutrition() call with a # meal-aware sum function from services/nutrition.py. totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS} for entry in entries: entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity) for field, value in entry_nut.items(): totals[field] += value target = targets_svc.get_target_for_date(db, lookup_date) return DaySummaryResponse( date=lookup_date, totals=DaySummaryNutrition(**totals), target=target, )