From 87d7eca468e8917f245b43d0719efeafa601565b Mon Sep 17 00:00:00 2001 From: Craig Date: Sun, 26 Jul 2026 13:15:46 +0100 Subject: [PATCH] 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) --- backend/routers/log.py | 47 +++-- backend/schemas.py | 25 +++ backend/services/log.py | 127 +++++++++++++ backend/tests/test_log.py | 377 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 564 insertions(+), 12 deletions(-) create mode 100644 backend/services/log.py create mode 100644 backend/tests/test_log.py diff --git a/backend/routers/log.py b/backend/routers/log.py index cd61352..3746a99 100644 --- a/backend/routers/log.py +++ b/backend/routers/log.py @@ -1,24 +1,47 @@ -"""Daily log router (spec §3.3).""" +"""Daily log router (spec §3.3). Thin handlers — business logic in services/log.py.""" from datetime import date -from fastapi import APIRouter, Depends -from sqlalchemy import select +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from database import get_db -from models import DailyLogEntry -from schemas import LogEntryRead +from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate +from services import log as svc router = APIRouter(prefix="/api/log", tags=["log"]) @router.get("", response_model=list[LogEntryRead]) def get_log(date: date, db: Session = Depends(get_db)): - """All entries for a client-supplied date (spec §8.1 rule 8).""" - stmt = ( - select(DailyLogEntry) - .where(DailyLogEntry.date == date) - .order_by(DailyLogEntry.sort_order, DailyLogEntry.id) - ) - return db.scalars(stmt).all() + """All entries for a client-supplied date, ordered by sort_order then id, + each with the referenced food embedded (§3.3).""" + return svc.get_log_entries(db, date) + + +@router.post("", response_model=LogEntryRead, status_code=201) +def create_log_entry(data: LogEntryCreate, db: Session = Depends(get_db)): + """Add a log entry. food_id must reference a non-deleted food. + sort_order defaults to end of day. Unknown or soft-deleted food → 404.""" + try: + return svc.create_log_entry(db, data) + except svc.FoodNotAvailableError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/{entry_id}", response_model=LogEntryRead) +def update_log_entry(entry_id: int, data: LogEntryUpdate, db: Session = Depends(get_db)): + """Update quantity, meal_slot, and/or sort_order. + Only provided fields change; send {"meal_slot": null} to clear it.""" + result = svc.update_log_entry(db, entry_id, data) + if result is None: + raise HTTPException(status_code=404, detail="Log entry not found") + return result + + +@router.delete("/{entry_id}") +def delete_log_entry(entry_id: int, db: Session = Depends(get_db)): + """Hard-delete a log entry (§2.3 has no soft-delete).""" + if not svc.delete_log_entry(db, entry_id): + raise HTTPException(status_code=404, detail="Log entry not found") + return {"status": "ok"} diff --git a/backend/schemas.py b/backend/schemas.py index 7bf595d..7473e6a 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -103,6 +103,30 @@ class LogEntryCreate(BaseModel): date: date # client-supplied YYYY-MM-DD (spec §8.1 rule 8) +class LogEntryUpdate(BaseModel): + """Schema for PUT /api/log/{id}. All fields optional — only supplied + fields are updated. Send {"meal_slot": null} to clear the slot.""" + quantity: float | None = Field(default=None, gt=0) + meal_slot: MealSlot | None = None + sort_order: int | None = None + + +class LogFoodRead(BaseModel): + """Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1). + Includes name, brand, unit_type, and serving info so the frontend can render + log entries without N+1 lookups. Soft-deleted foods render here (§2.1).""" + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + brand: str | None + unit_type: UnitType + serving_size_g: float | None + serving_name: str | None + is_meal: bool + deleted_at: datetime | None + + class LogEntryRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -112,6 +136,7 @@ class LogEntryRead(BaseModel): quantity: float meal_slot: MealSlot | None sort_order: int + food: LogFoodRead # ── Targets ────────────────────────────────────────────────────────────────── diff --git a/backend/services/log.py b/backend/services/log.py new file mode 100644 index 0000000..32a4dab --- /dev/null +++ b/backend/services/log.py @@ -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 diff --git a/backend/tests/test_log.py b/backend/tests/test_log.py new file mode 100644 index 0000000..54e63fd --- /dev/null +++ b/backend/tests/test_log.py @@ -0,0 +1,377 @@ +"""Daily log tests — TICKET-003 (spec §2.3, §3.3, §8.1 rules 2–3, 6, 8, 11). + +Behaviour notes: +- POST /api/log rejects unknown or soft-deleted food_id with 404 + (you can't log what you can't search). +- sort_order for new entries defaults to end of day (max(sort_order) + 1). +- GET /api/log?date= returns entries ordered by sort_order, then id, + with embedded food data (including soft-deleted foods for history). +- LogEntryUpdate uses exclude_unset: only supplied fields change; + send {"meal_slot": null} to clear the slot. +""" + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _create_food(client, **overrides) -> dict: + """Create a food via POST and return the response JSON.""" + payload = { + "name": "Test Food", + "brand": "Test Brand", + "calories_per_unit": 250.0, + "source": "manual", + "unit_type": "weight", + } + payload.update(overrides) + resp = client.post("/api/foods", json=payload) + assert resp.status_code == 201, resp.text + return resp.json() + + +def _post_log(client, **overrides) -> dict: + """Create a log entry, returning the full response (for status checks).""" + resp = client.post("/api/log", json=overrides) + return resp + + +def _create_entry(client, **overrides): + """Create a log entry and return the parsed JSON (asserts 201).""" + food = _create_food(client) + payload = { + "food_id": food["id"], + "quantity": 100.0, + "date": "2025-06-15", + } + payload.update(overrides) + resp = client.post("/api/log", json=payload) + assert resp.status_code == 201, resp.text + return resp.json() + + +def assert_422(resp): + assert resp.status_code == 422, f"expected 422, got {resp.status_code}: {resp.text}" + + +# ── Create + read round-trip ───────────────────────────────────────────────── + + +def test_create_and_read_round_trip(client): + """POST creates (201), GET returns it with embedded food.""" + entry = _create_entry(client) + assert entry["quantity"] == 100.0 + assert entry["date"] == "2025-06-15" + assert entry["meal_slot"] is None + assert "id" in entry + assert "sort_order" in entry + + # Embedded food + food = entry["food"] + assert food["name"] == "Test Food" + assert food["brand"] == "Test Brand" + assert food["unit_type"] == "weight" + assert food["is_meal"] is False + assert food["deleted_at"] is None + + # GET confirms + resp = client.get("/api/log", params={"date": "2025-06-15"}) + assert resp.status_code == 200 + entries = resp.json() + assert len(entries) == 1 + assert entries[0] == entry + + +# ── meal_slot ──────────────────────────────────────────────────────────────── + + +def test_create_with_each_meal_slot(client): + """All four meal slots are accepted.""" + for slot in ("breakfast", "lunch", "dinner", "snack"): + entry = _create_entry(client, meal_slot=slot) + assert entry["meal_slot"] == slot + + +def test_create_without_meal_slot_defaults_null(client): + """meal_slot is optional, defaults to None.""" + entry = _create_entry(client) + assert entry["meal_slot"] is None + + +# ── Validation: food_id ────────────────────────────────────────────────────── + + +def test_unknown_food_id_returns_404(client): + """Logging a non-existent food_id → 404.""" + resp = _post_log(client, food_id=99999, quantity=100.0, date="2025-06-15") + assert resp.status_code == 404 + + +def test_soft_deleted_food_rejected_404(client): + """Logging a soft-deleted food → 404 (you can't log what you can't search).""" + food = _create_food(client, name="Delete Me") + fid = food["id"] + client.delete(f"/api/foods/{fid}") + + resp = _post_log(client, food_id=fid, quantity=100.0, date="2025-06-15") + assert resp.status_code == 404 + + +# ── Validation: quantity ───────────────────────────────────────────────────── + + +def test_negative_quantity_rejected(client): + resp = _post_log(client, food_id=1, quantity=-5.0, date="2025-06-15") + assert_422(resp) + + +def test_zero_quantity_rejected(client): + resp = _post_log(client, food_id=1, quantity=0.0, date="2025-06-15") + assert_422(resp) + + +# ── Validation: meal_slot ──────────────────────────────────────────────────── + + +def test_bad_meal_slot_rejected(client): + food = _create_food(client) + resp = _post_log( + client, + food_id=food["id"], + quantity=100.0, + meal_slot="brunch", + date="2025-06-15", + ) + assert_422(resp) + + +# ── Validation: date ───────────────────────────────────────────────────────── + + +def test_bad_date_format_rejected(client): + food = _create_food(client) + resp = _post_log( + client, + food_id=food["id"], + quantity=100.0, + date="06-15-2025", + ) + assert_422(resp) + + +def test_missing_date_rejected(client): + food = _create_food(client) + resp = _post_log(client, food_id=food["id"], quantity=100.0) + assert_422(resp) + + +# ── Ordering ───────────────────────────────────────────────────────────────── + + +def test_entries_ordered_by_sort_order_then_id(client): + """GET /api/log returns entries sorted by sort_order, then id.""" + food = _create_food(client) + date = "2025-12-01" # unique date to avoid cross-test pollution + + # Create 3 entries (they'll get sort_order 1, 2, 3 automatically) + eids = [] + for _ in range(3): + resp = _post_log(client, food_id=food["id"], quantity=100.0, date=date) + assert resp.status_code == 201, resp.text + eids.append(resp.json()["id"]) + + # Update sort_orders to reverse the creation order + client.put(f"/api/log/{eids[0]}", json={"sort_order": 3}) + client.put(f"/api/log/{eids[1]}", json={"sort_order": 2}) + client.put(f"/api/log/{eids[2]}", json={"sort_order": 1}) + + resp = client.get("/api/log", params={"date": date}) + entries = resp.json() + ids = [e["id"] for e in entries] + assert ids == [eids[2], eids[1], eids[0]] # sort_order 1, 2, 3 + + +def test_new_entries_append_to_end(client): + """New entries get increasing sort_order values (append to end of day).""" + food = _create_food(client) + date = "2025-12-02" # unique date to avoid cross-test pollution + + e1 = _create_entry(client, food_id=food["id"], date=date) + e2 = _create_entry(client, food_id=food["id"], date=date) + e3 = _create_entry(client, food_id=food["id"], date=date) + + assert e1["sort_order"] < e2["sort_order"] < e3["sort_order"] + + +# ── Embedded food data ─────────────────────────────────────────────────────── + + +def test_embedded_food_data_complete(client): + """Log entry includes full food reference: name, brand, unit_type, serving.""" + food = _create_food( + client, + name="Banana", + brand="Chiquita", + unit_type="weight", + serving_size_g=118.0, + serving_name="1 banana", + ) + fid = food["id"] + + entry = _create_entry(client, food_id=fid, date="2025-06-15") + + f = entry["food"] + assert f["id"] == fid + assert f["name"] == "Banana" + assert f["brand"] == "Chiquita" + assert f["unit_type"] == "weight" + assert f["serving_size_g"] == 118.0 + assert f["serving_name"] == "1 banana" + assert f["is_meal"] is False + assert f["deleted_at"] is None + + +def test_soft_deleted_food_still_renders_in_history(client): + """Food soft-deleted after logging still appears in GET /api/log.""" + food = _create_food(client, name="Old Product") + fid = food["id"] + + _create_entry(client, food_id=fid, date="2025-01-01") + + # Soft-delete the food + client.delete(f"/api/foods/{fid}") + + # The log entry should still render it + resp = client.get("/api/log", params={"date": "2025-01-01"}) + entries = resp.json() + assert len(entries) == 1 + assert entries[0]["food"]["id"] == fid + assert entries[0]["food"]["name"] == "Old Product" + assert entries[0]["food"]["deleted_at"] is not None + + +# ── Update ─────────────────────────────────────────────────────────────────── + + +def test_update_quantity(client): + entry = _create_entry(client) + eid = entry["id"] + + resp = client.put(f"/api/log/{eid}", json={"quantity": 200.0}) + assert resp.status_code == 200 + assert resp.json()["quantity"] == 200.0 + + +def test_update_meal_slot_set_and_clear(client): + entry = _create_entry(client) + eid = entry["id"] + + # Set + resp = client.put(f"/api/log/{eid}", json={"meal_slot": "dinner"}) + assert resp.status_code == 200 + assert resp.json()["meal_slot"] == "dinner" + + # Clear (explicit null) + resp = client.put(f"/api/log/{eid}", json={"meal_slot": None}) + assert resp.status_code == 200 + assert resp.json()["meal_slot"] is None + + +def test_update_sort_order(client): + entry = _create_entry(client) + eid = entry["id"] + + resp = client.put(f"/api/log/{eid}", json={"sort_order": 42}) + assert resp.status_code == 200 + assert resp.json()["sort_order"] == 42 + + +def test_update_partial_preserves_other_fields(client): + """PUT with only one field leaves others unchanged.""" + entry = _create_entry(client, quantity=100.0, meal_slot="lunch") + eid = entry["id"] + + resp = client.put(f"/api/log/{eid}", json={"quantity": 150.0}) + assert resp.status_code == 200 + data = resp.json() + assert data["quantity"] == 150.0 + assert data["meal_slot"] == "lunch" # unchanged + + +def test_update_404(client): + resp = client.put("/api/log/99999", json={"quantity": 100.0}) + assert resp.status_code == 404 + + +def test_update_negative_quantity_rejected(client): + entry = _create_entry(client) + eid = entry["id"] + + resp = client.put(f"/api/log/{eid}", json={"quantity": -1.0}) + assert_422(resp) + + +def test_update_zero_quantity_rejected(client): + entry = _create_entry(client) + eid = entry["id"] + + resp = client.put(f"/api/log/{eid}", json={"quantity": 0.0}) + assert_422(resp) + + +# ── Delete ─────────────────────────────────────────────────────────────────── + + +def test_delete_removes_entry(client): + date = "2025-12-03" # unique to avoid cross-test pollution + entry = _create_entry(client, date=date) + eid = entry["id"] + + resp = client.delete(f"/api/log/{eid}") + assert resp.status_code == 200 + + # Confirm gone + resp = client.get("/api/log", params={"date": date}) + assert resp.json() == [] + + +def test_delete_404(client): + resp = client.delete("/api/log/99999") + assert resp.status_code == 404 + + +# ── Meal foods ─────────────────────────────────────────────────────────────── + + +def test_log_meal_food_accepted(client): + """Logging a food with is_meal=True succeeds (explosion is TICKET-007).""" + meal = _create_food( + client, + name="My Meal", + is_meal=True, + calories_per_unit=None, + source="meal", + ) + meal_id = meal["id"] + + entry = _create_entry(client, food_id=meal_id, quantity=1.0, date="2025-06-15") + assert entry["food"]["is_meal"] is True + assert entry["food"]["name"] == "My Meal" + + +# ── Date isolation ─────────────────────────────────────────────────────────── + + +def test_get_log_only_returns_requested_date(client): + """GET /api/log?date= isolates by date.""" + food = _create_food(client) + d1 = "2025-12-04" + d2 = "2025-12-05" + + _create_entry(client, food_id=food["id"], quantity=100.0, date=d1) + _create_entry(client, food_id=food["id"], quantity=200.0, date=d2) + + resp = client.get("/api/log", params={"date": d1}) + entries = resp.json() + assert len(entries) == 1 + assert entries[0]["date"] == d1 + assert entries[0]["quantity"] == 100.0