Public Access
911 lines
33 KiB
Python
911 lines
33 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import HTMLResponse
|
|
from html import escape as _html_escape
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import text, func
|
|
from sqlalchemy.orm import Session, joinedload
|
|
from app.database import get_db
|
|
from app.models import (
|
|
MealPlan, MealPlanItem, MealPlanVote, Recipe, Ingredient,
|
|
FamilyProfile, FamilyMember, ApprovalToken, NeverSuggest, NeverSuggestReason,
|
|
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus,
|
|
)
|
|
from app.schemas import (
|
|
MealPlanResponse, MealPlanCreate,
|
|
MealPlanItemResponse, VoteRequest, VoteResponse,
|
|
FillEmptySlotsRequest, FillEmptySlotsResult,
|
|
FilledSlot, FailedSlot,
|
|
)
|
|
from app.security import require_session
|
|
from app.services import approval as approval_service
|
|
from app.services.feedback_analyzer import FeedbackAnalyzer
|
|
from app.services.meal_pairings import components_with_suggested_sides
|
|
from uuid import UUID
|
|
from typing import List, Optional
|
|
from datetime import datetime, timedelta, timezone
|
|
from datetime import date
|
|
from uuid import UUID
|
|
from typing import List, Optional
|
|
import random
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# Sprint 8 — soft-denial decay window. A "Deny this week" creates a row
|
|
# with `denial_expires_at = now() + 90d`; after that the recipe is
|
|
# eligible again. Two denials in this window promote the recipe to a
|
|
# permanent `NeverSuggest` block (user decision: hard filter).
|
|
DENIAL_DECAY_DAYS = 90
|
|
|
|
|
|
def _ensure_never_suggest_recipe(
|
|
db: Session, family_id, recipe_id, reason: NeverSuggestReason = NeverSuggestReason.DISLIKE
|
|
) -> bool:
|
|
"""Insert a NeverSuggest row for (family, recipe) if one doesn't exist.
|
|
|
|
Idempotent: returns True if a new row was inserted, False if one
|
|
already existed. Used by the auto-promotion logic in deny_meal_item
|
|
and submit_vote when a recipe is denied for the 2nd time within the
|
|
decay window.
|
|
"""
|
|
existing = (
|
|
db.query(NeverSuggest)
|
|
.filter(
|
|
NeverSuggest.family_profile_id == family_id,
|
|
NeverSuggest.recipe_id == recipe_id,
|
|
)
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
return False
|
|
db.add(NeverSuggest(
|
|
family_profile_id=family_id,
|
|
recipe_id=recipe_id,
|
|
reason=reason,
|
|
))
|
|
return True
|
|
|
|
|
|
def _has_prior_active_soft_denial(
|
|
db: Session, family_id, recipe_id, current_item_id: Optional[UUID] = None
|
|
) -> bool:
|
|
"""True if there is at least one *still-active* soft denial for this
|
|
recipe for this family. Active = approval_status='denied' AND
|
|
denial_expires_at > now(). Excludes the current row by default so
|
|
the auto-promotion check is correct on the first call."""
|
|
q = (
|
|
db.query(func.count(MealPlanItem.id))
|
|
.join(MealPlan, MealPlanItem.meal_plan_id == MealPlan.id)
|
|
.filter(
|
|
MealPlan.family_profile_id == family_id,
|
|
MealPlanItem.recipe_id == recipe_id,
|
|
MealPlanItem.approval_status == MealPlanItemStatus.denied,
|
|
MealPlanItem.denial_expires_at.isnot(None),
|
|
MealPlanItem.denial_expires_at > func.now(),
|
|
)
|
|
)
|
|
if current_item_id is not None:
|
|
q = q.filter(MealPlanItem.id != current_item_id)
|
|
return (q.scalar() or 0) > 0
|
|
|
|
|
|
def _apply_denial(
|
|
db: Session,
|
|
item: MealPlanItem,
|
|
scope: str,
|
|
) -> dict:
|
|
"""Apply a denial to `item` and (if scope=never_again or this is the
|
|
2nd denial within the decay window) promote the recipe to a permanent
|
|
block. Returns a dict describing what happened — used by both the
|
|
webui and the email vote paths to surface a clear toast / message.
|
|
|
|
scope values:
|
|
- "this_week" (default): set denial_expires_at = now() + 90d.
|
|
If a prior active soft denial exists, promote to permanent.
|
|
- "never_again": write a NeverSuggest row; set denial_expires_at
|
|
to NULL (signals "permanent, no decay").
|
|
"""
|
|
family_id = item.meal_plan.family_profile_id
|
|
recipe_id = item.recipe_id
|
|
promoted = False
|
|
|
|
if scope == "never_again":
|
|
_ensure_never_suggest_recipe(db, family_id, recipe_id, NeverSuggestReason.DISLIKE)
|
|
item.denial_expires_at = None
|
|
promoted = True
|
|
else: # this_week
|
|
if _has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=item.id):
|
|
# 2nd denial in 90d → promote to permanent.
|
|
_ensure_never_suggest_recipe(db, family_id, recipe_id, NeverSuggestReason.DISLIKE)
|
|
item.denial_expires_at = None
|
|
promoted = True
|
|
else:
|
|
item.denial_expires_at = datetime.now(timezone.utc) + timedelta(days=DENIAL_DECAY_DAYS)
|
|
|
|
item.approval_status = MealPlanItemStatus.denied
|
|
db.commit()
|
|
db.refresh(item)
|
|
return {
|
|
"item": item,
|
|
"promoted_to_permanent": promoted,
|
|
"scope": scope,
|
|
}
|
|
|
|
|
|
@router.get("", response_model=Optional[MealPlanResponse])
|
|
def get_planned_meals(
|
|
week_start: Optional[date] = Query(
|
|
None,
|
|
description="ISO date of the week's Monday (YYYY-MM-DD). Omit for the most recent plan.",
|
|
),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
profile = db.query(FamilyProfile).first()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
|
|
|
query = db.query(MealPlan).filter(MealPlan.family_profile_id == profile.id)
|
|
if week_start is not None:
|
|
query = query.filter(MealPlan.week_start_date == week_start)
|
|
meal_plan = query.first()
|
|
else:
|
|
meal_plan = query.order_by(MealPlan.week_start_date.desc()).first()
|
|
|
|
if not meal_plan:
|
|
return None
|
|
|
|
return meal_plan
|
|
|
|
|
|
@router.post("", response_model=MealPlanResponse, dependencies=[Depends(require_session)])
|
|
def create_meal_plan(meal_plan_data: MealPlanCreate, db: Session = Depends(get_db)):
|
|
profile = db.query(FamilyProfile).first()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
|
|
|
existing = db.query(MealPlan).filter(
|
|
MealPlan.family_profile_id == profile.id,
|
|
MealPlan.week_start_date == meal_plan_data.week_start_date
|
|
).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Meal plan for this week already exists")
|
|
|
|
db_meal_plan = MealPlan(
|
|
family_profile_id=profile.id,
|
|
week_start_date=meal_plan_data.week_start_date,
|
|
status=MealPlanStatus[meal_plan_data.status.value.upper()],
|
|
approval_deadline=meal_plan_data.approval_deadline,
|
|
notes=meal_plan_data.notes
|
|
)
|
|
|
|
db.add(db_meal_plan)
|
|
db.flush()
|
|
|
|
for item_data in meal_plan_data.items:
|
|
db_item = MealPlanItem(
|
|
meal_plan_id=db_meal_plan.id,
|
|
recipe_id=item_data.recipe_id,
|
|
day_of_week=item_data.day_of_week,
|
|
meal_type=MealType[item_data.meal_type.value.upper()],
|
|
estimated_cost=item_data.estimated_cost
|
|
)
|
|
db.add(db_item)
|
|
|
|
db.commit()
|
|
db.refresh(db_meal_plan)
|
|
return db_meal_plan
|
|
|
|
|
|
@router.get("/{meal_plan_id}", response_model=MealPlanResponse)
|
|
def get_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)):
|
|
meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
|
|
if not meal_plan:
|
|
raise HTTPException(status_code=404, detail="Meal plan not found")
|
|
return meal_plan
|
|
|
|
|
|
@router.post("/{meal_plan_id}/lock", dependencies=[Depends(require_session)])
|
|
def lock_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)):
|
|
meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
|
|
if not meal_plan:
|
|
raise HTTPException(status_code=404, detail="Meal plan not found")
|
|
|
|
meal_plan.status = MealPlanStatus.LOCKED
|
|
db.commit()
|
|
return {"message": "Meal plan locked", "status": meal_plan.status.value}
|
|
|
|
|
|
_DAY_NAMES = {
|
|
1: "Monday", 2: "Tuesday", 3: "Wednesday", 4: "Thursday",
|
|
5: "Friday", 6: "Saturday", 7: "Sunday",
|
|
}
|
|
|
|
|
|
class VoteSubmission(BaseModel):
|
|
"""Body for POST /vote/{item_id}?token=...
|
|
|
|
Spec body: {"vote": "approve" | "deny" | "never_again"}.
|
|
|
|
Sprint 8 added "never_again" as a separate vote value (vs. plain
|
|
"deny" which is the soft "this week" denial). The vote path is the
|
|
same; the response carries `promoted_to_permanent` and `denial_scope`
|
|
so the email confirmation page can show what was applied.
|
|
"""
|
|
|
|
vote: str = Field(..., pattern="^(approve|deny|never_again)$")
|
|
|
|
|
|
@router.get("/vote/{item_id}", response_class=HTMLResponse)
|
|
def get_vote_page(
|
|
item_id: UUID,
|
|
token: str = Query(..., description="Per-voter signed token"),
|
|
scope: Optional[str] = Query(
|
|
None,
|
|
pattern="^(approve|this_week|never_again)$",
|
|
description=(
|
|
"Sprint 8: when present, the GET is treated as a one-click "
|
|
"direct vote from an email link. The token is consumed, the "
|
|
"vote is recorded via submit_vote(), and a tiny confirmation "
|
|
"page is rendered. When absent, the page is the full vote "
|
|
"form with 3 buttons."
|
|
),
|
|
),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Render the per-voter approval confirmation page.
|
|
|
|
Verifies the signed token (no consume) and returns minimal accessible
|
|
HTML with Approve / Deny buttons that POST to the same URL.
|
|
"""
|
|
payload = approval_service.verify_token(token)
|
|
if str(payload.get("item")) != str(item_id):
|
|
raise HTTPException(status_code=400, detail="Token not valid for this meal")
|
|
|
|
voter = (
|
|
db.query(FamilyMember)
|
|
.filter(FamilyMember.id == UUID(str(payload["voter"])))
|
|
.first()
|
|
)
|
|
if not voter:
|
|
raise HTTPException(status_code=404, detail="Voter not found")
|
|
|
|
item = (
|
|
db.query(MealPlanItem)
|
|
.options(joinedload(MealPlanItem.recipe))
|
|
.filter(MealPlanItem.id == item_id)
|
|
.first()
|
|
)
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
|
|
recipe_name = item.recipe.name if item.recipe else "Unnamed meal"
|
|
day_name = _DAY_NAMES.get(int(item.day_of_week), str(item.day_of_week))
|
|
meal_type = item.meal_type.value if item.meal_type else ""
|
|
|
|
# Token is included only inside the form action (href), never in the
|
|
# visible body. POST is performed by JS so we can submit JSON without
|
|
# leaving the page; non-JS users still get a usable form fallback.
|
|
safe_voter = _html_escape(voter.name)
|
|
safe_recipe = _html_escape(recipe_name)
|
|
safe_day = _html_escape(day_name)
|
|
safe_meal = _html_escape(meal_type)
|
|
|
|
# Sprint 8: one-click direct vote (used by the email's per-button
|
|
# links). Consume the token, record the vote via submit_vote, and
|
|
# render a tiny confirmation page. Single-use enforcement is shared
|
|
# with the JSON path (consume_token).
|
|
if scope is not None:
|
|
# Map email-link scope to the vote-payload "vote" field.
|
|
vote_value = "approve" if scope == "approve" else scope # "this_week" or "never_again"
|
|
result = submit_vote(
|
|
item_id=item_id,
|
|
submission=VoteSubmission(vote=vote_value),
|
|
token=token,
|
|
db=db,
|
|
)
|
|
item_status = result.get("item_status", "?")
|
|
promoted = result.get("promoted_to_permanent", False)
|
|
if scope == "approve":
|
|
msg = f"Approved {safe_recipe} ({safe_day})."
|
|
elif scope == "this_week":
|
|
if promoted:
|
|
msg = (
|
|
f"Denied {safe_recipe} for this week. "
|
|
f"You've denied this recipe recently, so it will not be "
|
|
f"suggested again (permanently blocked)."
|
|
)
|
|
else:
|
|
msg = (
|
|
f"Denied {safe_recipe} for this week. "
|
|
f"It will not be re-suggested for 90 days unless denied again."
|
|
)
|
|
else: # never_again
|
|
msg = (
|
|
f"Denied {safe_recipe} permanently. "
|
|
f"It will never be suggested again."
|
|
)
|
|
confirmation = f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Vote recorded</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
|
|
padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
|
|
.meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
|
|
.status {{ margin-top: 1rem; font-weight: bold; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Hi {safe_voter}, your vote was recorded</h1>
|
|
<div class="meal">
|
|
<div><strong>{safe_recipe}</strong></div>
|
|
<div>{safe_day} · {safe_meal}</div>
|
|
</div>
|
|
<div class="status">{_html_escape(msg)}</div>
|
|
<p style="margin-top:1rem;color:#555;font-size:14px">Meal plan status: {_html_escape(str(item_status))}.</p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return HTMLResponse(content=confirmation, status_code=200)
|
|
|
|
action_url = f"/api/meals/vote/{item_id}?token={_html_escape(token, quote=True)}"
|
|
|
|
existing_vote = db.query(MealPlanVote).filter(
|
|
MealPlanVote.meal_plan_item_id == item_id,
|
|
MealPlanVote.family_member_id == voter.id,
|
|
).first()
|
|
|
|
if existing_vote:
|
|
already_voted_html = f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Already Voted</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
|
|
padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
|
|
h1 {{ font-size: 1.4rem; }}
|
|
.meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
|
|
.already-voted {{ margin-top: 1rem; font-weight: bold; color: #0a6b2b; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Hi {safe_voter}, you already voted on this meal</h1>
|
|
<div class="meal">
|
|
<div><strong>{safe_recipe}</strong></div>
|
|
<div>{safe_day} · {safe_meal}</div>
|
|
</div>
|
|
<div class="already-voted">Your vote has already been recorded. Thank you!</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return HTMLResponse(content=already_voted_html, status_code=200)
|
|
|
|
html = f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Approve meal</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
|
|
padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
|
|
h1 {{ font-size: 1.4rem; }}
|
|
.meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
|
|
.actions {{ display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }}
|
|
button {{ font-size: 1rem; padding: .6rem 1.2rem;
|
|
border: 2px solid #111; border-radius: 4px; cursor: pointer; }}
|
|
.approve {{ background: #0a6b2b; color: #fff; }}
|
|
.deny {{ background: #b00020; color: #fff; }}
|
|
.never {{ background: #5a0000; color: #fff; border-style: dashed; }}
|
|
#status {{ margin-top: 1rem; font-weight: bold; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Hi {safe_voter}, please vote on this meal</h1>
|
|
<div class="meal">
|
|
<div><strong>{safe_recipe}</strong></div>
|
|
<div>{safe_day} · {safe_meal}</div>
|
|
</div>
|
|
<form id="voteForm" method="post" action="{action_url}">
|
|
<div class="actions">
|
|
<button type="submit" name="vote" value="approve" class="approve" aria-label="Approve this meal">Approve</button>
|
|
<button type="submit" name="vote" value="deny" class="deny" aria-label="Deny this meal for this week (will not reappear for 90 days)">Deny this week</button>
|
|
<button type="submit" name="vote" value="never_again" class="never" aria-label="Never suggest this recipe again">Never again</button>
|
|
</div>
|
|
</form>
|
|
<div id="status" role="status" aria-live="polite"></div>
|
|
<script>
|
|
document.getElementById('voteForm').addEventListener('submit', async function(e) {{
|
|
e.preventDefault();
|
|
var btn = e.submitter || document.activeElement;
|
|
var vote = btn && btn.value ? btn.value : 'approve';
|
|
var resp = await fetch(this.action, {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ vote: vote }})
|
|
}});
|
|
var data = {{}};
|
|
try {{ data = await resp.json(); }} catch (_) {{}}
|
|
var s = document.getElementById('status');
|
|
if (resp.ok) {{
|
|
var msg = 'Recorded: ' + (data.item_status || vote);
|
|
if (vote !== 'approve' && data.promoted_to_permanent) {{
|
|
msg += '. This recipe will not be suggested again (permanently blocked).';
|
|
}} else if (vote === 'deny') {{
|
|
msg += '. Will not reappear for 90 days unless denied again.';
|
|
}} else if (vote === 'never_again') {{
|
|
msg += '. Permanently blocked.';
|
|
}}
|
|
s.textContent = msg;
|
|
}} else {{
|
|
s.textContent = 'Error: ' + (data.detail || resp.status);
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return HTMLResponse(content=html, status_code=200)
|
|
|
|
|
|
@router.post("/vote/{item_id}")
|
|
def submit_vote(
|
|
item_id: UUID,
|
|
submission: VoteSubmission,
|
|
token: str = Query(..., description="Per-voter signed token"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Record a per-voter vote and apply the approval rule.
|
|
|
|
- Single-use enforcement lives in `approval_service.consume_token`.
|
|
- Approval rule: any deny (any scope) -> item.denied; all-approve ->
|
|
item.approved; otherwise pending (waiting on remaining voters).
|
|
- Sprint 8: a `deny` (or `never_again`) vote also flows through
|
|
`_apply_denial` which may set `denial_expires_at` or promote the
|
|
recipe to a permanent `NeverSuggest` block (auto-escalation after
|
|
the 2nd denial in 90d).
|
|
"""
|
|
voter = approval_service.consume_token(db, token, item_id)
|
|
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
|
|
vote_bool = submission.vote == "approve"
|
|
denial_scope: Optional[str] = None
|
|
promoted = False
|
|
|
|
if submission.vote == "never_again":
|
|
denial_scope = "never_again"
|
|
elif submission.vote == "deny":
|
|
denial_scope = "this_week"
|
|
|
|
db.add(MealPlanVote(
|
|
meal_plan_item_id=item_id,
|
|
family_member_id=voter.id,
|
|
vote=vote_bool,
|
|
denial_scope=denial_scope,
|
|
))
|
|
|
|
# Apply the denial (Sprint 8). For approve votes, this is a no-op
|
|
# other than the rule's effect on approval_status below.
|
|
if vote_bool is False and item.recipe_id is not None:
|
|
result = _apply_denial(db, item, scope=denial_scope or "this_week")
|
|
promoted = result["promoted_to_permanent"]
|
|
else:
|
|
# Approve path: keep existing approval-rule logic.
|
|
pass
|
|
|
|
# Approval rule: count electorate (all family members on this profile)
|
|
# vs votes recorded so far.
|
|
profile_id = item.meal_plan.family_profile_id
|
|
electorate_ids = {
|
|
m.id for m in db.query(FamilyMember)
|
|
.filter(FamilyMember.family_profile_id == profile_id)
|
|
.all()
|
|
}
|
|
votes = db.query(MealPlanVote).filter(
|
|
MealPlanVote.meal_plan_item_id == item_id,
|
|
).all()
|
|
|
|
if any(v.vote is False for v in votes):
|
|
item.approval_status = MealPlanItemStatus.denied
|
|
elif electorate_ids and {v.family_member_id for v in votes} >= electorate_ids:
|
|
item.approval_status = MealPlanItemStatus.approved
|
|
else:
|
|
item.approval_status = MealPlanItemStatus.pending
|
|
|
|
db.commit()
|
|
return {
|
|
"status": "recorded",
|
|
"item_status": item.approval_status.value,
|
|
"denial_scope": denial_scope,
|
|
"promoted_to_permanent": promoted,
|
|
}
|
|
|
|
|
|
@router.get("/items/{item_id}", response_model=MealPlanItemResponse)
|
|
def get_meal_item(item_id: UUID, db: Session = Depends(get_db)):
|
|
item = db.query(MealPlanItem).options(joinedload(MealPlanItem.recipe)).filter(
|
|
MealPlanItem.id == item_id
|
|
).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
|
|
# Enrich ingredient names from the Ingredient table
|
|
if item.recipe and item.recipe.ingredients:
|
|
ing_ids = [ing.get("ingredient_id") for ing in item.recipe.ingredients if ing.get("ingredient_id")]
|
|
if ing_ids:
|
|
ingredients = db.query(Ingredient).filter(Ingredient.id.in_(ing_ids)).all()
|
|
name_map = {str(i.id): i.name for i in ingredients}
|
|
for ing in item.recipe.ingredients:
|
|
ing_id = str(ing.get("ingredient_id", ""))
|
|
if ing_id in name_map and not ing.get("name"):
|
|
ing["name"] = name_map[ing_id]
|
|
|
|
return item
|
|
|
|
|
|
@router.post("/items/{item_id}/swap", dependencies=[Depends(require_session)])
|
|
def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get_db)):
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
|
|
new_recipe = db.query(Recipe).filter(Recipe.id == new_recipe_id).first()
|
|
if not new_recipe:
|
|
raise HTTPException(status_code=404, detail="New recipe not found")
|
|
|
|
item.recipe_id = new_recipe_id
|
|
item.approval_status = MealPlanItemStatus.pending
|
|
item.denial_reason = None
|
|
item.denial_details = None
|
|
# Sprint 8: swapping to a new recipe clears any prior soft-deny
|
|
# window. The new recipe is a different recipe_id so the prior
|
|
# denial wouldn't apply anyway, but the new row starts fresh.
|
|
item.denial_expires_at = None
|
|
|
|
db.commit()
|
|
return {"message": "Meal swapped", "item": item}
|
|
|
|
|
|
@router.post("/items/{item_id}/approve")
|
|
def approve_meal_item(item_id: UUID, db: Session = Depends(get_db)):
|
|
"""Directly approve a meal plan item from the dashboard."""
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
item.approval_status = MealPlanItemStatus.approved
|
|
db.commit()
|
|
return {"message": "Meal approved", "item": item}
|
|
|
|
|
|
@router.post("/items/{item_id}/deny")
|
|
def deny_meal_item(
|
|
item_id: UUID,
|
|
scope: str = Query(
|
|
"this_week",
|
|
pattern="^(this_week|never_again)$",
|
|
description=(
|
|
"Sprint 8: 'this_week' (default) sets denial_expires_at = now()+90d. "
|
|
"If a prior active soft denial exists for the same recipe, the "
|
|
"recipe is auto-promoted to a permanent NeverSuggest block. "
|
|
"'never_again' always writes a NeverSuggest row and clears "
|
|
"denial_expires_at (no decay)."
|
|
),
|
|
),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Directly deny a meal plan item from the dashboard.
|
|
|
|
Sprint 8: per the user's policy decision, two denials in the past
|
|
90 days (or any explicit "never_again") promote the recipe to a
|
|
permanent `NeverSuggest` block. The function returns the standard
|
|
`MealPlanItemResponse` plus a `promoted_to_permanent` boolean so
|
|
the frontend can show a clear toast ("Denied + won't suggest again").
|
|
"""
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
if item.recipe_id is None:
|
|
# Defensive: an item without a recipe can't be blocked by recipe.
|
|
item.approval_status = MealPlanItemStatus.denied
|
|
item.denial_expires_at = None
|
|
db.commit()
|
|
return {
|
|
"message": "Meal denied",
|
|
"item": item,
|
|
"promoted_to_permanent": False,
|
|
"scope": scope,
|
|
}
|
|
result = _apply_denial(db, item, scope=scope)
|
|
return {
|
|
"message": "Meal denied",
|
|
"item": result["item"],
|
|
"promoted_to_permanent": result["promoted_to_permanent"],
|
|
"scope": result["scope"],
|
|
}
|
|
|
|
|
|
@router.delete("/items/{item_id}")
|
|
def delete_meal_item(item_id: UUID, db: Session = Depends(get_db)):
|
|
"""Delete a meal plan item entirely."""
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
db.delete(item)
|
|
db.commit()
|
|
return {"message": "Meal deleted"}
|
|
|
|
|
|
@router.post("/{meal_plan_id}/generate-item")
|
|
def generate_single_item(
|
|
meal_plan_id: UUID,
|
|
day_of_week: int = Query(..., ge=1, le=7),
|
|
meal_type: str = Query(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Generate a single meal for an empty slot."""
|
|
plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(status_code=404, detail="Meal plan not found")
|
|
|
|
# Verify slot is empty
|
|
existing = (
|
|
db.query(MealPlanItem)
|
|
.filter(
|
|
MealPlanItem.meal_plan_id == meal_plan_id,
|
|
MealPlanItem.day_of_week == day_of_week,
|
|
MealPlanItem.meal_type == meal_type,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Slot already occupied")
|
|
|
|
# Get all recipes, score them, pick the best not already in this plan
|
|
all_recipes = db.query(Recipe).all()
|
|
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
|
|
|
# Simple heuristic: pick a random un-used recipe (we can improve scoring later)
|
|
available = [r for r in all_recipes if r.id not in used_ids]
|
|
if not available:
|
|
available = all_recipes # reuse if all recipes are in play
|
|
|
|
recipe = random.choice(available)
|
|
|
|
new_item = MealPlanItem(
|
|
meal_plan_id=meal_plan_id,
|
|
recipe_id=recipe.id,
|
|
day_of_week=day_of_week,
|
|
meal_type=MealType[meal_type.upper()],
|
|
approval_status=MealPlanItemStatus.pending,
|
|
components=components_with_suggested_sides(recipe, meal_type),
|
|
)
|
|
db.add(new_item)
|
|
db.commit()
|
|
db.refresh(new_item)
|
|
return {"message": "Meal generated", "item": new_item}
|
|
|
|
|
|
@router.post("/{meal_plan_id}/fill-empty-slots", response_model=FillEmptySlotsResult)
|
|
def fill_empty_slots(
|
|
meal_plan_id: UUID,
|
|
payload: FillEmptySlotsRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Fill every empty slot in the plan whose meal_type is in the
|
|
request's `meal_types`. Returns a per-slot report (filled vs
|
|
failed) so the UI can show "12 of 21 filled, 9 failed — recipe
|
|
library exhausted".
|
|
|
|
Failure model: per-slot. The endpoint never aborts mid-batch on
|
|
a single failure; it commits what succeeded and reports the
|
|
rest. This matches the user's chosen model (partial-success
|
|
with detailed report).
|
|
"""
|
|
plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(status_code=404, detail="Meal plan not found")
|
|
|
|
# Normalise and validate the requested meal_types.
|
|
requested: list[str] = []
|
|
for mt in payload.meal_types:
|
|
try:
|
|
canonical = MealType[mt.upper()].value
|
|
except KeyError:
|
|
return FillEmptySlotsResult(
|
|
filled=[],
|
|
failed=[FailedSlot(day_of_week=0, meal_type=mt, reason=f"Unknown meal_type: {mt}")],
|
|
)
|
|
if canonical not in requested:
|
|
requested.append(canonical)
|
|
|
|
if not requested:
|
|
return FillEmptySlotsResult(filled=[], failed=[])
|
|
|
|
all_recipes = db.query(Recipe).all()
|
|
if not all_recipes:
|
|
# No recipes at all — every requested slot fails.
|
|
return FillEmptySlotsResult(
|
|
filled=[],
|
|
failed=[
|
|
FailedSlot(day_of_week=d, meal_type=mt, reason="No recipes available")
|
|
for d in range(1, 8)
|
|
for mt in requested
|
|
],
|
|
)
|
|
|
|
used_ids: set = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
|
|
|
filled: list[FilledSlot] = []
|
|
failed: list[FailedSlot] = []
|
|
|
|
for day in range(1, 8):
|
|
for mt in requested:
|
|
# Skip already-occupied slots.
|
|
existing = (
|
|
db.query(MealPlanItem)
|
|
.filter(
|
|
MealPlanItem.meal_plan_id == meal_plan_id,
|
|
MealPlanItem.day_of_week == day,
|
|
MealPlanItem.meal_type == mt,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
continue # not a failure, just nothing to do
|
|
|
|
# Pick a recipe; prefer un-used, fall back to any.
|
|
available = [r for r in all_recipes if r.id not in used_ids]
|
|
pool = available if available else all_recipes
|
|
recipe = random.choice(pool)
|
|
|
|
new_item = MealPlanItem(
|
|
meal_plan_id=meal_plan_id,
|
|
recipe_id=recipe.id,
|
|
day_of_week=day,
|
|
meal_type=MealType[mt.upper()],
|
|
approval_status=MealPlanItemStatus.pending,
|
|
components=components_with_suggested_sides(recipe, mt),
|
|
)
|
|
db.add(new_item)
|
|
try:
|
|
db.flush()
|
|
used_ids.add(recipe.id)
|
|
filled.append(FilledSlot(
|
|
day_of_week=day,
|
|
meal_type=mt,
|
|
item=MealPlanItemResponse.model_validate(new_item),
|
|
))
|
|
except Exception as exc:
|
|
db.rollback()
|
|
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
|
failed.append(FilledSlot if False else FailedSlot(
|
|
day_of_week=day,
|
|
meal_type=mt,
|
|
reason=str(exc) or "Insert failed",
|
|
))
|
|
|
|
db.commit()
|
|
return FillEmptySlotsResult(filled=filled, failed=failed)
|
|
|
|
|
|
@router.put("/items/{item_id}/move")
|
|
def move_meal_item(
|
|
item_id: UUID,
|
|
new_day_of_week: int,
|
|
new_meal_type: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Move a meal to a new day/type slot.
|
|
|
|
If the target slot is occupied by another item in the same meal plan,
|
|
swap the two items.
|
|
"""
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
|
|
plan_id = item.meal_plan_id
|
|
conflict = (
|
|
db.query(MealPlanItem)
|
|
.filter(
|
|
MealPlanItem.meal_plan_id == plan_id,
|
|
MealPlanItem.day_of_week == new_day_of_week,
|
|
MealPlanItem.meal_type == new_meal_type,
|
|
MealPlanItem.id != item_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if conflict:
|
|
# Atomic swap via single SQL CASE statement — no intermediate
|
|
# unique-constraint violations.
|
|
db.execute(
|
|
text(
|
|
"""
|
|
UPDATE meal_plan_item
|
|
SET day_of_week = CASE
|
|
WHEN id = :item_id THEN :new_day
|
|
WHEN id = :conflict_id THEN :old_day
|
|
END,
|
|
meal_type = CASE
|
|
WHEN id = :item_id THEN :new_type
|
|
WHEN id = :conflict_id THEN :old_type
|
|
END
|
|
WHERE id IN (:item_id, :conflict_id)
|
|
"""
|
|
),
|
|
{
|
|
"item_id": str(item_id),
|
|
"conflict_id": str(conflict.id),
|
|
"new_day": new_day_of_week,
|
|
"new_type": new_meal_type,
|
|
"old_day": item.day_of_week,
|
|
"old_type": item.meal_type,
|
|
},
|
|
)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return {"message": "Meal swapped", "item": item}
|
|
|
|
item.day_of_week = new_day_of_week
|
|
item.meal_type = new_meal_type
|
|
|
|
db.commit()
|
|
db.refresh(item)
|
|
return {"message": "Meal moved", "item": item}
|
|
|
|
|
|
# ── Dashboard badge: newly discovered recipes ─────────────────────────
|
|
|
|
|
|
@router.get("/dashboard/discovered-count")
|
|
def discovered_count(db: Session = Depends(get_db)):
|
|
"""Return number of recipes discovered in the last 7 days for the current family."""
|
|
profile = db.query(FamilyProfile).first()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
|
|
|
since = datetime.utcnow() - timedelta(days=7)
|
|
count = (
|
|
db.query(Recipe)
|
|
.filter(
|
|
Recipe.family_profile_id == profile.id,
|
|
Recipe.external_source.isnot(None),
|
|
Recipe.created_at >= since,
|
|
)
|
|
.count()
|
|
)
|
|
return {"discovered_count": count}
|
|
|
|
|
|
@router.get("/dashboard/discovery-insights")
|
|
def discovery_insights(db: Session = Depends(get_db)):
|
|
"""Return latest feedback analysis and top signals."""
|
|
profile = db.query(FamilyProfile).first()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
|
|
|
latest_run = (
|
|
db.query(WeeklyRun)
|
|
.filter(WeeklyRun.family_id == profile.id)
|
|
.order_by(WeeklyRun.week_start_date.desc())
|
|
.first()
|
|
)
|
|
if not latest_run or not latest_run.feedback_analysis:
|
|
return {"has_analysis": False}
|
|
|
|
analysis = latest_run.feedback_analysis
|
|
return {
|
|
"has_analysis": True,
|
|
"confidence": analysis.get("confidence", 0),
|
|
"positive_signals": analysis.get("positive_signals", []),
|
|
"negative_signals": analysis.get("negative_signals", []),
|
|
"top_rated_recipes": analysis.get("top_rated_recipes", []),
|
|
} |