277 lines
9.4 KiB
Python
277 lines
9.4 KiB
Python
"""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,
|
||
) |