Files
calcount/backend/services/log.py
T
Craig 87d7eca468 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)
2026-07-26 13:15:46 +01:00

128 lines
4.3 KiB
Python
Raw 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
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