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:
Craig
2026-07-26 13:15:46 +01:00
parent 601c0d461e
commit 87d7eca468
4 changed files with 564 additions and 12 deletions
+35 -12
View File
@@ -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"}
+25
View File
@@ -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 ──────────────────────────────────────────────────────────────────
+127
View File
@@ -0,0 +1,127 @@
"""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
+377
View File
@@ -0,0 +1,377 @@
"""Daily log tests — TICKET-003 (spec §2.3, §3.3, §8.1 rules 23, 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