Files

277 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Daily log service layer — business logic + DB access (spec §8.1 rules 23).
- 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,
)