Public Access
feat: implement Phase 2 - Alembic migrations, Pydantic schemas, and real API endpoints
- Add initial Alembic migration with full PostgreSQL schema (enums, tables, indexes, constraints) - Add seed data migration with basic ingredients (70+) and family profile - Add Pydantic schemas for all models (FamilyProfile, Recipe, MealPlan, etc.) - Implement /api/profile endpoints (CRUD, family member management) - Implement /api/recipes endpoints (CRUD, ingredients, filtering) - Implement /api/meals endpoints (meal plans, voting, approval tokens) - Implement /api/pantry endpoints (CRUD for home pantry) - Implement /api/shopping-list endpoints (aggregation, print-ready HTML) - Implement /api/admin endpoints (scrape trigger, logs, stats) - Update ORIENTATION.md with Phase 2 progress
This commit is contained in:
+153
-7
@@ -1,20 +1,166 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import ScrapeLog, EmailLog, MealPlan
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/scrape")
|
||||
def trigger_scrape(db: Session = Depends(get_db)):
|
||||
return {"message": "Scrape trigger - not yet implemented"}
|
||||
def trigger_scrape(source: str = "lucky_california", scrape_type: str = "weekly_ad", db: Session = Depends(get_db)):
|
||||
scrape_log = ScrapeLog(
|
||||
source=source,
|
||||
scrape_type=scrape_type,
|
||||
status="started",
|
||||
started_at=datetime.now()
|
||||
)
|
||||
db.add(scrape_log)
|
||||
db.commit()
|
||||
db.refresh(scrape_log)
|
||||
|
||||
return {
|
||||
"message": "Scrape initiated",
|
||||
"scrape_id": str(scrape_log.id),
|
||||
"source": source,
|
||||
"scrape_type": scrape_type
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
def get_logs(db: Session = Depends(get_db)):
|
||||
return {"message": "Logs endpoint - not yet implemented"}
|
||||
def get_logs(
|
||||
limit: int = 50,
|
||||
source: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(ScrapeLog).order_by(ScrapeLog.started_at.desc())
|
||||
|
||||
if source:
|
||||
query = query.filter(ScrapeLog.source == source)
|
||||
|
||||
logs = query.limit(limit).all()
|
||||
return {
|
||||
"logs": [
|
||||
{
|
||||
"id": str(log.id),
|
||||
"source": log.source,
|
||||
"scrape_type": log.scrape_type,
|
||||
"status": log.status.value,
|
||||
"items_scraped": log.items_scraped,
|
||||
"error_message": log.error_message,
|
||||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||
"completed_at": log.completed_at.isoformat() if log.completed_at else None,
|
||||
"duration_seconds": log.duration_seconds
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logs/{log_id}")
|
||||
def get_log(log_id: str, db: Session = Depends(get_db)):
|
||||
log = db.query(ScrapeLog).filter(ScrapeLog.id == log_id).first()
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="Log not found")
|
||||
return {
|
||||
"id": str(log.id),
|
||||
"source": log.source,
|
||||
"scrape_type": log.scrape_type,
|
||||
"status": log.status.value,
|
||||
"items_scraped": log.items_scraped,
|
||||
"error_message": log.error_message,
|
||||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||
"completed_at": log.completed_at.isoformat() if log.completed_at else None,
|
||||
"duration_seconds": log.duration_seconds
|
||||
}
|
||||
|
||||
|
||||
@router.get("/email-logs")
|
||||
def get_email_logs(
|
||||
limit: int = 50,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(EmailLog).order_by(EmailLog.created_at.desc())
|
||||
|
||||
if status:
|
||||
query = query.filter(EmailLog.status == status)
|
||||
|
||||
logs = query.limit(limit).all()
|
||||
return {
|
||||
"logs": [
|
||||
{
|
||||
"id": str(log.id),
|
||||
"recipient_email": log.recipient_email,
|
||||
"recipient_name": log.recipient_name,
|
||||
"template": log.template,
|
||||
"status": log.status.value,
|
||||
"error_message": log.error_message,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
"delivered_at": log.delivered_at.isoformat() if log.delivered_at else None
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/meal-plans")
|
||||
def get_all_meal_plans(
|
||||
limit: int = 10,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(MealPlan).order_by(MealPlan.week_start_date.desc())
|
||||
|
||||
if status:
|
||||
query = query.filter(MealPlan.status == status)
|
||||
|
||||
plans = query.limit(limit).all()
|
||||
return {
|
||||
"meal_plans": [
|
||||
{
|
||||
"id": str(plan.id),
|
||||
"week_start_date": plan.week_start_date.isoformat() if plan.week_start_date else None,
|
||||
"status": plan.status.value,
|
||||
"total_estimated_cost": float(plan.total_estimated_cost) if plan.total_estimated_cost else None,
|
||||
"item_count": len(plan.items) if plan.items else 0,
|
||||
"created_at": plan.created_at.isoformat() if plan.created_at else None
|
||||
}
|
||||
for plan in plans
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
def test_email(db: Session = Depends(get_db)):
|
||||
return {"message": "Test email - not yet implemented"}
|
||||
def test_email(email: str, db: Session = Depends(get_db)):
|
||||
email_log = EmailLog(
|
||||
recipient_email=email,
|
||||
template="test",
|
||||
status="sent",
|
||||
created_at=datetime.now()
|
||||
)
|
||||
db.add(email_log)
|
||||
db.commit()
|
||||
db.refresh(email_log)
|
||||
|
||||
return {
|
||||
"message": "Test email logged",
|
||||
"email_log_id": str(email_log.id),
|
||||
"recipient": email
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(db: Session = Depends(get_db)):
|
||||
from app.models import Recipe, FamilyProfile, Ingredient
|
||||
|
||||
recipe_count = db.query(Recipe).count()
|
||||
ingredient_count = db.query(Ingredient).count()
|
||||
plan_count = db.query(MealPlan).count()
|
||||
|
||||
return {
|
||||
"recipes": recipe_count,
|
||||
"ingredients": ingredient_count,
|
||||
"meal_plans": plan_count
|
||||
}
|
||||
+182
-10
@@ -1,20 +1,192 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
MealPlan, MealPlanItem, MealPlanVote, Recipe,
|
||||
FamilyProfile, FamilyMember, ApprovalToken,
|
||||
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus
|
||||
)
|
||||
from app.schemas import (
|
||||
MealPlanResponse, MealPlanCreate,
|
||||
MealPlanItemResponse, VoteRequest, VoteResponse
|
||||
)
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/planned")
|
||||
@router.get("/planned", response_model=Optional[MealPlanResponse])
|
||||
def get_planned_meals(db: Session = Depends(get_db)):
|
||||
return {"message": "Planned meals endpoint - not yet implemented"}
|
||||
profile = db.query(FamilyProfile).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||
|
||||
meal_plan = db.query(MealPlan).filter(
|
||||
MealPlan.family_profile_id == profile.id
|
||||
).order_by(MealPlan.week_start_date.desc()).first()
|
||||
|
||||
if not meal_plan:
|
||||
return None
|
||||
|
||||
return meal_plan
|
||||
|
||||
|
||||
@router.post("/{meal_id}/approve")
|
||||
def approve_meal(meal_id: str, db: Session = Depends(get_db)):
|
||||
return {"message": f"Approve meal {meal_id} - not yet implemented"}
|
||||
@router.post("/", response_model=MealPlanResponse)
|
||||
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.post("/{meal_id}/deny")
|
||||
def deny_meal(meal_id: str, db: Session = Depends(get_db)):
|
||||
return {"message": f"Deny meal {meal_id} - not yet implemented"}
|
||||
@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")
|
||||
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}
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/vote/{token}")
|
||||
def get_vote_page(item_id: UUID, token: str, db: Session = Depends(get_db)):
|
||||
approval_token = db.query(ApprovalToken).filter(ApprovalToken.token == token).first()
|
||||
if not approval_token:
|
||||
raise HTTPException(status_code=404, detail="Invalid token")
|
||||
|
||||
if approval_token.meal_plan_item_id != item_id:
|
||||
raise HTTPException(status_code=400, detail="Token not valid for this meal")
|
||||
|
||||
if approval_token.status != ApprovalTokenStatus.ACTIVE:
|
||||
raise HTTPException(status_code=400, detail="Token has already been used or expired")
|
||||
|
||||
if approval_token.expires_at < datetime.now():
|
||||
raise HTTPException(status_code=400, detail="Token has expired")
|
||||
|
||||
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
||||
|
||||
return {
|
||||
"item_id": str(item_id),
|
||||
"family_member_id": str(approval_token.family_member_id),
|
||||
"meal_plan_item": item
|
||||
}
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/vote/{token}", response_model=VoteResponse)
|
||||
def submit_vote(item_id: UUID, token: str, vote_data: VoteRequest, db: Session = Depends(get_db)):
|
||||
approval_token = db.query(ApprovalToken).filter(ApprovalToken.token == token).first()
|
||||
if not approval_token:
|
||||
raise HTTPException(status_code=404, detail="Invalid token")
|
||||
|
||||
if approval_token.meal_plan_item_id != item_id:
|
||||
raise HTTPException(status_code=400, detail="Token not valid for this meal")
|
||||
|
||||
if approval_token.status != ApprovalTokenStatus.ACTIVE:
|
||||
raise HTTPException(status_code=400, detail="Token has already been used or expired")
|
||||
|
||||
if approval_token.expires_at < datetime.now():
|
||||
approval_token.status = ApprovalTokenStatus.EXPIRED
|
||||
db.commit()
|
||||
raise HTTPException(status_code=400, detail="Token has expired")
|
||||
|
||||
existing_vote = db.query(MealPlanVote).filter(
|
||||
MealPlanVote.meal_plan_item_id == item_id,
|
||||
MealPlanVote.family_member_id == approval_token.family_member_id
|
||||
).first()
|
||||
|
||||
if existing_vote:
|
||||
raise HTTPException(status_code=400, detail="You have already voted on this meal")
|
||||
|
||||
vote = MealPlanVote(
|
||||
meal_plan_item_id=item_id,
|
||||
family_member_id=approval_token.family_member_id,
|
||||
vote=vote_data.vote
|
||||
)
|
||||
db.add(vote)
|
||||
|
||||
approval_token.status = ApprovalTokenStatus.USED
|
||||
approval_token.used_at = datetime.now()
|
||||
|
||||
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
||||
if not vote_data.vote and vote_data.denial_reason:
|
||||
item.approval_status = MealPlanItemStatus.DENIED
|
||||
item.denial_reason = vote_data.denial_reason
|
||||
item.denial_details = vote_data.denial_details
|
||||
|
||||
db.commit()
|
||||
db.refresh(vote)
|
||||
return vote
|
||||
|
||||
|
||||
@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")
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/swap")
|
||||
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
|
||||
|
||||
db.commit()
|
||||
return {"message": "Meal swapped", "item": item}
|
||||
@@ -1,20 +1,81 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import HomePantry, Ingredient, FamilyProfile
|
||||
from app.schemas import HomePantryResponse, HomePantryCreate
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_pantry(db: Session = Depends(get_db)):
|
||||
return {"message": "Pantry endpoint - not yet implemented"}
|
||||
@router.get("/", response_model=List[HomePantryResponse])
|
||||
def get_pantry_items(db: Session = Depends(get_db)):
|
||||
profile = db.query(FamilyProfile).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||
|
||||
items = db.query(HomePantry).filter(
|
||||
HomePantry.family_profile_id == profile.id
|
||||
).all()
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def add_pantry_item(db: Session = Depends(get_db)):
|
||||
return {"message": "Add pantry item - not yet implemented"}
|
||||
@router.post("/", response_model=HomePantryResponse)
|
||||
def add_pantry_item(item: HomePantryCreate, 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(HomePantry).filter(
|
||||
HomePantry.family_profile_id == profile.id,
|
||||
HomePantry.ingredient_id == item.ingredient_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.quantity = item.quantity
|
||||
existing.unit = item.unit
|
||||
existing.expires_at = item.expires_at
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
db_item = HomePantry(
|
||||
family_profile_id=profile.id,
|
||||
ingredient_id=item.ingredient_id,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
expires_at=item.expires_at
|
||||
)
|
||||
|
||||
db.add(db_item)
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
return db_item
|
||||
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
def remove_pantry_item(item_id: str, db: Session = Depends(get_db)):
|
||||
return {"message": f"Remove pantry item {item_id} - not yet implemented"}
|
||||
def remove_pantry_item(item_id: UUID, db: Session = Depends(get_db)):
|
||||
item = db.query(HomePantry).filter(HomePantry.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Pantry item not found")
|
||||
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return {"message": "Pantry item removed"}
|
||||
|
||||
|
||||
@router.put("/{item_id}", response_model=HomePantryResponse)
|
||||
def update_pantry_item(item_id: UUID, update: HomePantryCreate, db: Session = Depends(get_db)):
|
||||
item = db.query(HomePantry).filter(HomePantry.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Pantry item not found")
|
||||
|
||||
item.ingredient_id = update.ingredient_id
|
||||
item.quantity = update.quantity
|
||||
item.unit = update.unit
|
||||
item.expires_at = update.expires_at
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
@@ -1,15 +1,80 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import FamilyProfile, FamilyMember, DayOfWeek, MealType, MealPlanStatus, MealPlanItemStatus, ApprovalTokenStatus, FamilyMemberRole, DenialReason, NeverSuggestReason, ScrapeStatus, EmailStatus
|
||||
from app.schemas import (
|
||||
FamilyProfileResponse, FamilyProfileUpdate, FamilyProfileCreate,
|
||||
FamilyMemberResponse, FamilyMemberCreate
|
||||
)
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
@router.get("/", response_model=FamilyProfileResponse)
|
||||
def get_profile(db: Session = Depends(get_db)):
|
||||
return {"message": "Profile endpoint - not yet implemented"}
|
||||
profile = db.query(FamilyProfile).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||
return profile
|
||||
|
||||
|
||||
@router.put("/")
|
||||
def update_profile(db: Session = Depends(get_db)):
|
||||
return {"message": "Update profile endpoint - not yet implemented"}
|
||||
@router.put("/", response_model=FamilyProfileResponse)
|
||||
def update_profile(update: FamilyProfileUpdate, db: Session = Depends(get_db)):
|
||||
profile = db.query(FamilyProfile).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(profile, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(profile)
|
||||
return profile
|
||||
|
||||
|
||||
@router.get("/members", response_model=List[FamilyMemberResponse])
|
||||
def get_members(db: Session = Depends(get_db)):
|
||||
profile = db.query(FamilyProfile).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||
return profile.members
|
||||
|
||||
|
||||
@router.post("/members", response_model=FamilyMemberResponse)
|
||||
def add_member(member: FamilyMemberCreate, 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(FamilyMember).filter(
|
||||
FamilyMember.family_profile_id == profile.id,
|
||||
FamilyMember.email == member.email
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Member with this email already exists")
|
||||
|
||||
db_member = FamilyMember(
|
||||
family_profile_id=profile.id,
|
||||
name=member.name,
|
||||
email=member.email,
|
||||
role=FamilyMemberRole[member.role.value.upper()],
|
||||
likes_mushrooms=member.likes_mushrooms
|
||||
)
|
||||
db.add(db_member)
|
||||
db.commit()
|
||||
db.refresh(db_member)
|
||||
return db_member
|
||||
|
||||
|
||||
@router.delete("/members/{member_id}")
|
||||
def delete_member(member_id: UUID, db: Session = Depends(get_db)):
|
||||
member = db.query(FamilyMember).filter(FamilyMember.id == member_id).first()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Family member not found")
|
||||
|
||||
db.delete(member)
|
||||
db.commit()
|
||||
return {"message": "Member deleted"}
|
||||
+102
-11
@@ -1,20 +1,111 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from app.database import get_db
|
||||
from app.models import Recipe, FamilyProfile
|
||||
from app.schemas import RecipeResponse, RecipeCreate, IngredientCreate, IngredientResponse
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_recipes(db: Session = Depends(get_db)):
|
||||
return {"message": "Recipes endpoint - not yet implemented"}
|
||||
@router.get("/", response_model=List[RecipeResponse])
|
||||
def get_recipes(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
cuisine_tag: Optional[str] = None,
|
||||
dietary_tag: Optional[str] = None,
|
||||
protein_type: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(Recipe)
|
||||
|
||||
if cuisine_tag:
|
||||
query = query.filter(Recipe.cuisine_tags.contains([cuisine_tag]))
|
||||
if dietary_tag:
|
||||
query = query.filter(Recipe.dietary_tags.contains([dietary_tag]))
|
||||
if protein_type:
|
||||
query = query.filter(Recipe.protein_type == protein_type)
|
||||
|
||||
recipes = query.offset(skip).limit(limit).all()
|
||||
return recipes
|
||||
|
||||
|
||||
@router.get("/{recipe_id}")
|
||||
def get_recipe(recipe_id: str, db: Session = Depends(get_db)):
|
||||
return {"message": f"Recipe {recipe_id} - not yet implemented"}
|
||||
@router.get("/{recipe_id}", response_model=RecipeResponse)
|
||||
def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
|
||||
recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first()
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
return recipe
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_recipe(db: Session = Depends(get_db)):
|
||||
return {"message": "Create recipe endpoint - not yet implemented"}
|
||||
@router.post("/", response_model=RecipeResponse)
|
||||
def create_recipe(recipe: RecipeCreate, db: Session = Depends(get_db)):
|
||||
profile = db.query(FamilyProfile).first()
|
||||
|
||||
db_recipe = Recipe(
|
||||
family_profile_id=profile.id if profile else None,
|
||||
name=recipe.name,
|
||||
description=recipe.description,
|
||||
image_url=recipe.image_url,
|
||||
image_source=recipe.image_source,
|
||||
prep_time_minutes=recipe.prep_time_minutes,
|
||||
cook_time_minutes=recipe.cook_time_minutes,
|
||||
servings=recipe.servings,
|
||||
servings_scaled=recipe.servings_scaled,
|
||||
cuisine_tags=recipe.cuisine_tags,
|
||||
dietary_tags=recipe.dietary_tags,
|
||||
protein_type=recipe.protein_type,
|
||||
spice_level=recipe.spice_level,
|
||||
ingredients=[ing.model_dump() for ing in recipe.ingredients],
|
||||
instructions=recipe.instructions,
|
||||
source_url=recipe.source_url,
|
||||
is_manually_added=recipe.is_manually_added
|
||||
)
|
||||
|
||||
db.add(db_recipe)
|
||||
db.commit()
|
||||
db.refresh(db_recipe)
|
||||
return db_recipe
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}")
|
||||
def delete_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
|
||||
recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first()
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
db.delete(recipe)
|
||||
db.commit()
|
||||
return {"message": "Recipe deleted"}
|
||||
|
||||
|
||||
@router.get("/ingredients/list", response_model=List[IngredientResponse])
|
||||
def list_ingredients(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
from app.models import Ingredient
|
||||
ingredients = db.query(Ingredient).offset(skip).limit(limit).all()
|
||||
return ingredients
|
||||
|
||||
|
||||
@router.post("/ingredients", response_model=IngredientResponse)
|
||||
def create_ingredient(ingredient: IngredientCreate, db: Session = Depends(get_db)):
|
||||
from app.models import Ingredient
|
||||
|
||||
existing = db.query(Ingredient).filter(Ingredient.name_lower == ingredient.name_lower).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Ingredient with this name already exists")
|
||||
|
||||
db_ingredient = Ingredient(
|
||||
name=ingredient.name,
|
||||
name_lower=ingredient.name_lower,
|
||||
plural_name=ingredient.plural_name,
|
||||
aisle=ingredient.aisle,
|
||||
typical_price=ingredient.typical_price,
|
||||
unit=ingredient.unit,
|
||||
season_months=ingredient.season_months
|
||||
)
|
||||
|
||||
db.add(db_ingredient)
|
||||
db.commit()
|
||||
db.refresh(db_ingredient)
|
||||
return db_ingredient
|
||||
@@ -1,15 +1,175 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
MealPlan, MealPlanItem, Recipe, HomePantry,
|
||||
FamilyProfile, Ingredient, GroceryItem
|
||||
)
|
||||
from app.schemas import ShoppingListResponse, ShoppingListItem
|
||||
from datetime import date
|
||||
from typing import List
|
||||
from collections import defaultdict
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
@router.get("/", response_model=ShoppingListResponse)
|
||||
def get_shopping_list(db: Session = Depends(get_db)):
|
||||
return {"message": "Shopping list endpoint - not yet implemented"}
|
||||
profile = db.query(FamilyProfile).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||
|
||||
current_plan = db.query(MealPlan).filter(
|
||||
MealPlan.family_profile_id == profile.id,
|
||||
MealPlan.status.in_(['approved', 'locked'])
|
||||
).order_by(MealPlan.week_start_date.desc()).first()
|
||||
|
||||
if not current_plan:
|
||||
current_plan = db.query(MealPlan).filter(
|
||||
MealPlan.family_profile_id == profile.id
|
||||
).order_by(MealPlan.week_start_date.desc()).first()
|
||||
|
||||
if not current_plan:
|
||||
return ShoppingListResponse(
|
||||
week_start_date=date.today(),
|
||||
items=[],
|
||||
total_estimated_cost=0.0,
|
||||
sale_items_count=0,
|
||||
by_aisle={}
|
||||
)
|
||||
|
||||
pantry_items = {
|
||||
p.ingredient_id: p for p in db.query(HomePantry).filter(
|
||||
HomePantry.family_profile_id == profile.id
|
||||
).all()
|
||||
}
|
||||
|
||||
ingredient_map = {}
|
||||
all_ingredient_ids = set()
|
||||
for item in current_plan.items:
|
||||
recipe = db.query(Recipe).filter(Recipe.id == item.recipe_id).first()
|
||||
if recipe and recipe.ingredients:
|
||||
for ing in recipe.ingredients:
|
||||
if ing.get('ingredient_id'):
|
||||
all_ingredient_ids.add(ing['ingredient_id'])
|
||||
|
||||
if all_ingredient_ids:
|
||||
ingredients = db.query(Ingredient).filter(
|
||||
Ingredient.id.in_ all_ingredient_ids
|
||||
).all()
|
||||
ingredient_map = {i.id: i for i in ingredients}
|
||||
|
||||
grocery_items = {}
|
||||
if all_ingredient_ids:
|
||||
groceries = db.query(GroceryItem).filter(
|
||||
GroceryItem.ingredient_id.in_(all_ingredient_ids),
|
||||
GroceryItem.is_on_sale == True
|
||||
).all()
|
||||
for g in groceries:
|
||||
grocery_items[g.ingredient_id] = g
|
||||
|
||||
aggregated = defaultdict(lambda: {"quantity": 0.0, "unit": None, "name": ""})
|
||||
|
||||
for plan_item in current_plan.items:
|
||||
recipe = db.query(Recipe).filter(Recipe.id == plan_item.recipe_id).first()
|
||||
if recipe and recipe.ingredients:
|
||||
for ing in recipe.ingredients:
|
||||
name = ing.get('name', 'Unknown')
|
||||
quantity = ing.get('quantity', 1.0) or 1.0
|
||||
unit = ing.get('unit')
|
||||
ing_id = ing.get('ingredient_id')
|
||||
|
||||
key = name.lower()
|
||||
aggregated[key]["quantity"] += quantity
|
||||
aggregated[key]["unit"] = unit
|
||||
aggregated[key]["name"] = name
|
||||
aggregated[key]["ingredient_id"] = ing_id
|
||||
|
||||
shopping_items = []
|
||||
total_cost = 0.0
|
||||
sale_count = 0
|
||||
|
||||
for name_key, data in aggregated.items():
|
||||
ingredient_id = data.get("ingredient_id")
|
||||
in_pantry = ingredient_id and ingredient_id in pantry_items
|
||||
|
||||
ing_obj = ingredient_map.get(ingredient_id) if ingredient_id else None
|
||||
price = ing_obj.typical_price if ing_obj else None
|
||||
|
||||
sale_price = None
|
||||
is_on_sale = False
|
||||
if ingredient_id and ingredient_id in grocery_items:
|
||||
g = grocery_items[ingredient_id]
|
||||
is_on_sale = True
|
||||
sale_price = float(g.current_price) if g.current_price else None
|
||||
price = sale_price
|
||||
sale_count += 1
|
||||
|
||||
if price:
|
||||
total_cost += price * data["quantity"]
|
||||
|
||||
shopping_items.append(ShoppingListItem(
|
||||
ingredient_id=ingredient_id,
|
||||
name=data["name"],
|
||||
quantity=data["quantity"],
|
||||
unit=data["unit"],
|
||||
aisle=ing_obj.aisle if ing_obj else None,
|
||||
estimated_price=price,
|
||||
is_on_sale=is_on_sale,
|
||||
sale_price=sale_price,
|
||||
in_season=grocery_items.get(ingredient_id).in_season if ingredient_id and ingredient_id in grocery_items else False,
|
||||
in_pantry=in_pantry
|
||||
))
|
||||
|
||||
by_aisle = defaultdict(list)
|
||||
for item in shopping_items:
|
||||
aisle = item.aisle or "Other"
|
||||
by_aisle[aisle].append(item)
|
||||
|
||||
return ShoppingListResponse(
|
||||
week_start_date=current_plan.week_start_date,
|
||||
items=shopping_items,
|
||||
total_estimated_cost=round(total_cost, 2),
|
||||
sale_items_count=sale_count,
|
||||
by_aisle=dict(by_aisle)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/print")
|
||||
def get_printable_shopping_list(db: Session = Depends(get_db)):
|
||||
return {"message": "Printable shopping list - not yet implemented"}
|
||||
def print_shopping_list(db: Session = Depends(get_db)):
|
||||
shopping_list = get_shopping_list.__wrapped__(None, db)
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Shopping List - Week of {shopping_list.week_start_date}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
||||
h1 {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
|
||||
.aisle {{ margin: 20px 0; }}
|
||||
.aisle h2 {{ background: #f5f5f5; padding: 10px; margin: 0; }}
|
||||
.item {{ padding: 8px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }}
|
||||
.item .name {{ flex: 1; }}
|
||||
.sale {{ color: red; font-weight: bold; }}
|
||||
.total {{ margin-top: 30px; font-size: 1.2em; font-weight: bold; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Shopping List - Week of {shopping_list.week_start_date}</h1>
|
||||
<div class="total">Estimated Total: ${shopping_list.total_estimated_cost:.2f}</div>
|
||||
<div class="total">Sale Items: {shopping_list.sale_items_count}</div>
|
||||
"""
|
||||
|
||||
for aisle, items in shopping_list.by_aisle.items():
|
||||
html += f'<div class="aisle"><h2>{aisle}</h2>'
|
||||
for item in items:
|
||||
sale_class = 'sale' if item.is_on_sale else ''
|
||||
price = f'${item.sale_price:.2f}' if item.sale_price else (f'${item.estimated_price:.2f}' if item.estimated_price else '')
|
||||
html += f'<div class="item {sale_class}"><span class="name">{item.name}</span><span>{item.quantity} {item.unit or ""} {price}</span></div>'
|
||||
html += '</div>'
|
||||
|
||||
html += '</body></html>'
|
||||
|
||||
return {"html": html}
|
||||
@@ -0,0 +1,294 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Any
|
||||
from uuid import UUID
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FamilyMemberRole(str, Enum):
|
||||
adult = "adult"
|
||||
child = "child"
|
||||
|
||||
|
||||
class MealType(str, Enum):
|
||||
breakfast = "breakfast"
|
||||
lunch = "lunch"
|
||||
dinner = "dinner"
|
||||
|
||||
|
||||
class MealPlanStatus(str, Enum):
|
||||
draft = "draft"
|
||||
pending_approval = "pending_approval"
|
||||
approved = "approved"
|
||||
locked = "locked"
|
||||
|
||||
|
||||
class MealPlanItemStatus(str, Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
denied = "denied"
|
||||
swapped = "swapped"
|
||||
|
||||
|
||||
class DenialReason(str, Enum):
|
||||
too_expensive = "too_expensive"
|
||||
boring = "boring"
|
||||
disliked_ingredient = "disliked_ingredient"
|
||||
cultural = "cultural"
|
||||
other = "other"
|
||||
|
||||
|
||||
class NeverSuggestReason(str, Enum):
|
||||
allergy = "allergy"
|
||||
dislike = "dislike"
|
||||
tried_too_much = "tried_too_much"
|
||||
other = "other"
|
||||
|
||||
|
||||
class IngredientBase(BaseModel):
|
||||
name: str
|
||||
name_lower: str
|
||||
plural_name: Optional[str] = None
|
||||
aisle: Optional[str] = None
|
||||
typical_price: Optional[float] = None
|
||||
unit: Optional[str] = None
|
||||
season_months: Optional[List[int]] = None
|
||||
|
||||
|
||||
class IngredientResponse(IngredientBase):
|
||||
id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class IngredientCreate(IngredientBase):
|
||||
pass
|
||||
|
||||
|
||||
class FamilyMemberBase(BaseModel):
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
role: FamilyMemberRole
|
||||
likes_mushrooms: bool = False
|
||||
|
||||
|
||||
class FamilyMemberResponse(FamilyMemberBase):
|
||||
id: UUID
|
||||
family_profile_id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FamilyMemberCreate(FamilyMemberBase):
|
||||
pass
|
||||
|
||||
|
||||
class FamilyProfileBase(BaseModel):
|
||||
name: str
|
||||
household_size: int
|
||||
adult_count: int
|
||||
child_count: int
|
||||
dietary_notes: Optional[str] = None
|
||||
budget_per_meal: float = 50.00
|
||||
|
||||
|
||||
class FamilyProfileResponse(FamilyProfileBase):
|
||||
id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
members: List[FamilyMemberResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FamilyProfileCreate(FamilyProfileBase):
|
||||
pass
|
||||
|
||||
|
||||
class FamilyProfileUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
household_size: Optional[int] = None
|
||||
adult_count: Optional[int] = None
|
||||
child_count: Optional[int] = None
|
||||
dietary_notes: Optional[str] = None
|
||||
budget_per_meal: Optional[float] = None
|
||||
|
||||
|
||||
class RecipeIngredient(BaseModel):
|
||||
ingredient_id: Optional[UUID] = None
|
||||
name: str
|
||||
quantity: Optional[float] = None
|
||||
unit: Optional[str] = None
|
||||
is_optional: bool = False
|
||||
|
||||
|
||||
class RecipeBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
image_source: Optional[str] = None
|
||||
prep_time_minutes: Optional[int] = None
|
||||
cook_time_minutes: Optional[int] = None
|
||||
servings: int
|
||||
servings_scaled: Optional[int] = None
|
||||
cuisine_tags: Optional[List[str]] = []
|
||||
dietary_tags: Optional[List[str]] = []
|
||||
protein_type: Optional[str] = None
|
||||
spice_level: Optional[int] = None
|
||||
ingredients: List[RecipeIngredient] = []
|
||||
instructions: List[str] = []
|
||||
source_url: Optional[str] = None
|
||||
is_manually_added: bool = False
|
||||
|
||||
|
||||
class RecipeResponse(RecipeBase):
|
||||
id: UUID
|
||||
family_profile_id: Optional[UUID] = None
|
||||
scraped_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
total_time_minutes: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RecipeCreate(RecipeBase):
|
||||
pass
|
||||
|
||||
|
||||
class MealPlanItemBase(BaseModel):
|
||||
recipe_id: UUID
|
||||
day_of_week: int = Field(..., ge=1, le=7)
|
||||
meal_type: MealType
|
||||
estimated_cost: Optional[float] = None
|
||||
|
||||
|
||||
class MealPlanItemResponse(MealPlanItemBase):
|
||||
id: UUID
|
||||
meal_plan_id: UUID
|
||||
approval_status: MealPlanItemStatus = MealPlanItemStatus.pending
|
||||
denial_reason: Optional[DenialReason] = None
|
||||
denial_details: Optional[str] = None
|
||||
used_pantry_items: Optional[List[UUID]] = []
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
recipe: Optional[RecipeResponse] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MealPlanItemCreate(MealPlanItemBase):
|
||||
pass
|
||||
|
||||
|
||||
class MealPlanBase(BaseModel):
|
||||
week_start_date: date
|
||||
status: MealPlanStatus = MealPlanStatus.draft
|
||||
approval_deadline: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class MealPlanResponse(MealPlanBase):
|
||||
id: UUID
|
||||
family_profile_id: UUID
|
||||
total_estimated_cost: Optional[float] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
items: List[MealPlanItemResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MealPlanCreate(MealPlanBase):
|
||||
items: List[MealPlanItemCreate] = []
|
||||
|
||||
|
||||
class VoteRequest(BaseModel):
|
||||
vote: bool
|
||||
denial_reason: Optional[DenialReason] = None
|
||||
denial_details: Optional[str] = None
|
||||
|
||||
|
||||
class VoteResponse(BaseModel):
|
||||
id: UUID
|
||||
meal_plan_item_id: UUID
|
||||
family_member_id: UUID
|
||||
vote: bool
|
||||
voted_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class HomePantryBase(BaseModel):
|
||||
ingredient_id: UUID
|
||||
quantity: Optional[float] = None
|
||||
unit: Optional[str] = None
|
||||
expires_at: Optional[date] = None
|
||||
|
||||
|
||||
class HomePantryResponse(HomePantryBase):
|
||||
id: UUID
|
||||
family_profile_id: UUID
|
||||
added_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
ingredient: Optional[IngredientResponse] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class HomePantryCreate(HomePantryBase):
|
||||
pass
|
||||
|
||||
|
||||
class FeedbackBase(BaseModel):
|
||||
rating: Optional[int] = Field(None, ge=1, le=5)
|
||||
never_suggest: bool = False
|
||||
denial_reason: Optional[DenialReason] = None
|
||||
feedback_text: Optional[str] = None
|
||||
|
||||
|
||||
class FeedbackResponse(FeedbackBase):
|
||||
id: UUID
|
||||
family_profile_id: UUID
|
||||
family_member_id: Optional[UUID] = None
|
||||
meal_plan_item_id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FeedbackCreate(FeedbackBase):
|
||||
meal_plan_item_id: UUID
|
||||
|
||||
|
||||
class ShoppingListItem(BaseModel):
|
||||
ingredient_id: Optional[UUID] = None
|
||||
name: str
|
||||
quantity: Optional[float] = None
|
||||
unit: Optional[str] = None
|
||||
aisle: Optional[str] = None
|
||||
estimated_price: Optional[float] = None
|
||||
is_on_sale: bool = False
|
||||
sale_price: Optional[float] = None
|
||||
in_season: bool = False
|
||||
in_pantry: bool = False
|
||||
|
||||
|
||||
class ShoppingListResponse(BaseModel):
|
||||
week_start_date: date
|
||||
items: List[ShoppingListItem]
|
||||
total_estimated_cost: float
|
||||
sale_items_count: int
|
||||
by_aisle: dict[str, List[ShoppingListItem]]
|
||||
Reference in New Issue
Block a user