TICKET-003: Daily log write path
- POST/PUT/DELETE /api/log, GET /api/log?date= with embedded food - sort_order auto-appends per day; hard delete for log entries - Soft-deleted foods rejected for new logs (404) but render in history - Full suite green (87 passed)
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user