"""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, selectinload from models import DailyLogEntry, Food, MealComponent from schemas import ( DaySummaryNutrition, DaySummaryResponse, LogEntryCreate, LogEntryRead, LogEntryUpdate, MealComponentRead, ) 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)) ) def _build_log_food_dict(food: Food) -> dict: """Convert a Food ORM to dict for LogFoodRead, including nested components when the food is a meal.""" data = { "id": food.id, "name": food.name, "brand": food.brand, "unit_type": food.unit_type, "calories_per_unit": food.calories_per_unit, "serving_size_g": food.serving_size_g, "serving_name": food.serving_name, "is_meal": food.is_meal, "deleted_at": food.deleted_at, } if food.is_meal and hasattr(food, "components"): comps = [] for mc in food.components: cf = mc.food comps.append(MealComponentRead( food_id=mc.food_id, quantity=mc.quantity, food={ "id": cf.id, "name": cf.name, "brand": cf.brand, "unit_type": cf.unit_type, "calories_per_unit": cf.calories_per_unit, "serving_size_g": cf.serving_size_g, "serving_name": cf.serving_name, "is_meal": cf.is_meal, "deleted_at": cf.deleted_at, }, )) data["components"] = comps else: data["components"] = None return data # ── 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. Meal foods include their nested components so the frontend can render collapsible rows (§3.3). Soft-deleted foods still render (§2.1). """ stmt = ( select(DailyLogEntry) .where(DailyLogEntry.date == lookup_date) .options( joinedload(DailyLogEntry.food) .selectinload(Food.components) .joinedload(MealComponent.food) ) .order_by(DailyLogEntry.sort_order, DailyLogEntry.id) ) entries = db.scalars(stmt).unique().all() result: list[LogEntryRead] = [] for entry in entries: food_data = _build_log_food_dict(entry.food) # Compute nutrition for this entry (handles meals recursively) entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity) result.append(LogEntryRead.model_validate({ "id": entry.id, "date": entry.date, "food_id": entry.food_id, "quantity": entry.quantity, "meal_slot": entry.meal_slot, "sort_order": entry.sort_order, "food": food_data, "computed_nutrition": entry_nut, })) return result 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 loaded = _load_entry_with_food(db, entry.id) food_data = _build_log_food_dict(loaded.food) entry_nut = nutrition.entry_nutrition(loaded.food, loaded.quantity) return LogEntryRead.model_validate({ "id": loaded.id, "date": loaded.date, "food_id": loaded.food_id, "quantity": loaded.quantity, "meal_slot": loaded.meal_slot, "sort_order": loaded.sort_order, "food": food_data, "computed_nutrition": entry_nut, }) 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() # Re-query with eager-loaded food + components for the response loaded = db.scalar( select(DailyLogEntry) .where(DailyLogEntry.id == entry_id) .options( joinedload(DailyLogEntry.food) .selectinload(Food.components) .joinedload(MealComponent.food) ) ) food_data = _build_log_food_dict(loaded.food) entry_nut = nutrition.entry_nutrition(loaded.food, loaded.quantity) return LogEntryRead.model_validate({ "id": loaded.id, "date": loaded.date, "food_id": loaded.food_id, "quantity": loaded.quantity, "meal_slot": loaded.meal_slot, "sort_order": loaded.sort_order, "food": food_data, "computed_nutrition": entry_nut, }) 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 and meal components eagerly joined. 2. For each entry, uses nutrition.entry_nutrition() which now handles meal foods recursively by summing their component nutrition (§2.2). Cycle detection prevents infinite loops on inconsistent data. 3. 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) .selectinload(Food.components) .joinedload(MealComponent.food) ) ).unique().all() # Sum nutrition across all entries for the date. # entry_nutrition() now handles meal foods by recursive component # summation (TICKET-007). 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, )