Public Access
All §1 consensus blockers and §2 high-risk gaps resolved: Schema fixes: - Remove RecipeIngredient join table, use JSONB for ingredients - Add family_member table for per-voter approval tracking - Add all ENUMs for status fields (no loose VARCHAR) - Add CHECK constraints (household_size, rating 1-5, day_of_week) - Add name_lower for case-insensitive ingredient matching - Add grocery_item → ingredient FK - Fix day_of_week to ISO-8601 (1=Monday, 7=Sunday) - Remove calorie_target (nutrition is non-goal) Approval flow redesign: - Email link → confirmation page (GET), not auto-approve - Actual vote is POST from confirmation page - Per-voter tokens (single-use, 72h TTL) - Record which member voted Auth model: - VPN-only for admin endpoints - Session-based for family web UI Docker hardening: - Remove direct port exposure for backend/frontend - nginx is sole entrypoint - Add docker-compose.dev.yml for local dev Skeleton fixes: - Add missing Pantry.tsx page - Add missing index.html (Vite entrypoint) - Add package-lock.json - Fix SQLAlchemy 2 text() for raw SQL - Remove create_all from startup (use migrations) - Configure Alembic properly Docs updates: - Update Lucky URL to luckysupermarkets.com - Add WCAG 2.1 AA accessibility target - Update family profile with correct mushroom preferences - Add external dependencies list to SPEC Verification: - docker compose config: PASS - docker compose build backend: PASS - docker compose build frontend: PASS - backend import: PASS - alembic context: PASS
379 lines
15 KiB
Python
379 lines
15 KiB
Python
from sqlalchemy import (
|
|
Column, String, Integer, Numeric, Text, Boolean, Date, DateTime,
|
|
ForeignKey, CheckConstraint, UniqueConstraint, Enum as SQLEnum
|
|
)
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
|
|
from sqlalchemy.orm import relationship
|
|
from sqlalchemy.sql import func
|
|
from app.database import Base
|
|
import uuid
|
|
import enum
|
|
|
|
|
|
class DayOfWeek(enum.Enum):
|
|
MONDAY = 1
|
|
TUESDAY = 2
|
|
WEDNESDAY = 3
|
|
THURSDAY = 4
|
|
FRIDAY = 5
|
|
SATURDAY = 6
|
|
SUNDAY = 7
|
|
|
|
|
|
class MealType(enum.Enum):
|
|
BREAKFAST = "breakfast"
|
|
LUNCH = "lunch"
|
|
DINNER = "dinner"
|
|
|
|
|
|
class MealPlanStatus(enum.Enum):
|
|
DRAFT = "draft"
|
|
PENDING_APPROVAL = "pending_approval"
|
|
APPROVED = "approved"
|
|
LOCKED = "locked"
|
|
|
|
|
|
class MealPlanItemStatus(enum.Enum):
|
|
PENDING = "pending"
|
|
APPROVED = "approved"
|
|
DENIED = "denied"
|
|
SWAPPED = "swapped"
|
|
|
|
|
|
class ApprovalTokenStatus(enum.Enum):
|
|
ACTIVE = "active"
|
|
USED = "used"
|
|
EXPIRED = "expired"
|
|
|
|
|
|
class FamilyMemberRole(enum.Enum):
|
|
ADULT = "adult"
|
|
CHILD = "child"
|
|
|
|
|
|
class DenialReason(enum.Enum):
|
|
TOO_EXPENSIVE = "too_expensive"
|
|
BORING = "boring"
|
|
DISLIKED_INGREDIENT = "disliked_ingredient"
|
|
CULTURAL = "cultural"
|
|
OTHER = "other"
|
|
|
|
|
|
class NeverSuggestReason(enum.Enum):
|
|
ALLERGY = "allergy"
|
|
DISLIKE = "dislike"
|
|
TRIED_TOO_MUCH = "tried_too_much"
|
|
OTHER = "other"
|
|
|
|
|
|
class ScrapeStatus(enum.Enum):
|
|
STARTED = "started"
|
|
SUCCESS = "success"
|
|
FAILED = "failed"
|
|
|
|
|
|
class EmailStatus(enum.Enum):
|
|
SENT = "sent"
|
|
DELIVERED = "delivered"
|
|
FAILED = "failed"
|
|
BOUNCED = "bounced"
|
|
|
|
|
|
class FamilyProfile(Base):
|
|
__tablename__ = "family_profile"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
name = Column(String(100), nullable=False)
|
|
household_size = Column(Integer, nullable=False)
|
|
adult_count = Column(Integer, nullable=False)
|
|
child_count = Column(Integer, nullable=False)
|
|
dietary_notes = Column(Text)
|
|
budget_per_meal = Column(Numeric(10, 2), default=50.00)
|
|
calorie_target = Column(Integer)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
|
|
__table_args__ = (
|
|
CheckConstraint("adult_count + child_count = household_size"),
|
|
CheckConstraint("household_size > 0"),
|
|
CheckConstraint("adult_count > 0"),
|
|
)
|
|
|
|
members = relationship("FamilyMember", back_populates="family_profile", cascade="all, delete-orphan")
|
|
recipes = relationship("Recipe", back_populates="family_profile")
|
|
meal_plans = relationship("MealPlan", back_populates="family_profile")
|
|
pantry_items = relationship("HomePantry", back_populates="family_profile", cascade="all, delete-orphan")
|
|
feedbacks = relationship("Feedback", back_populates="family_profile")
|
|
never_suggests = relationship("NeverSuggest", back_populates="family_profile", cascade="all, delete-orphan")
|
|
|
|
|
|
class FamilyMember(Base):
|
|
__tablename__ = "family_member"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
|
|
name = Column(String(100), nullable=False)
|
|
email = Column(String(300))
|
|
role = Column(SQLEnum(FamilyMemberRole, name="family_member_role_enum", create_type=False), nullable=False)
|
|
likes_mushrooms = Column(Boolean, default=False)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("family_profile_id", "email"),
|
|
)
|
|
|
|
family_profile = relationship("FamilyProfile", back_populates="members")
|
|
votes = relationship("MealPlanVote", back_populates="family_member", cascade="all, delete-orphan")
|
|
feedbacks = relationship("Feedback", back_populates="family_member")
|
|
|
|
|
|
class Ingredient(Base):
|
|
__tablename__ = "ingredient"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
name = Column(String(200), nullable=False)
|
|
name_lower = Column(String(200), nullable=False, unique=True)
|
|
plural_name = Column(String(200))
|
|
aisle = Column(String(100))
|
|
typical_price = Column(Numeric(10, 2))
|
|
unit = Column(String(50))
|
|
season_months = Column(ARRAY(Integer))
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
pantry_items = relationship("HomePantry", back_populates="ingredient")
|
|
never_suggests = relationship("NeverSuggest", back_populates="ingredient")
|
|
grocery_item_links = relationship("GroceryItem", back_populates="ingredient")
|
|
|
|
|
|
class Recipe(Base):
|
|
__tablename__ = "recipe"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
|
|
name = Column(String(300), nullable=False)
|
|
description = Column(Text)
|
|
image_url = Column(Text)
|
|
image_source = Column(String(50))
|
|
prep_time_minutes = Column(Integer)
|
|
cook_time_minutes = Column(Integer)
|
|
servings = Column(Integer, nullable=False)
|
|
servings_scaled = Column(Integer)
|
|
cuisine_tags = Column(ARRAY(String(50)))
|
|
dietary_tags = Column(ARRAY(String(50)))
|
|
protein_type = Column(String(50))
|
|
spice_level = Column(Integer)
|
|
ingredients = Column(JSONB, nullable=False)
|
|
instructions = Column(ARRAY(Text), nullable=False)
|
|
source_url = Column(Text)
|
|
scraped_at = Column(DateTime(timezone=True))
|
|
is_manually_added = Column(Boolean, default=False)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
|
|
family_profile = relationship("FamilyProfile", back_populates="recipes")
|
|
meal_plan_items = relationship("MealPlanItem", back_populates="recipe")
|
|
|
|
@property
|
|
def total_time_minutes(self):
|
|
return (self.prep_time_minutes or 0) + (self.cook_time_minutes or 0)
|
|
|
|
|
|
class MealPlan(Base):
|
|
__tablename__ = "meal_plan"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
|
|
week_start_date = Column(Date, nullable=False)
|
|
status = Column(SQLEnum(MealPlanStatus, name="meal_plan_status_enum", create_type=False), nullable=False, default=MealPlanStatus.DRAFT)
|
|
approval_deadline = Column(DateTime(timezone=True))
|
|
total_estimated_cost = Column(Numeric(10, 2))
|
|
notes = Column(Text)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("family_profile_id", "week_start_date"),
|
|
)
|
|
|
|
family_profile = relationship("FamilyProfile", back_populates="meal_plans")
|
|
items = relationship("MealPlanItem", back_populates="meal_plan", cascade="all, delete-orphan")
|
|
votes = relationship("MealPlanVote", back_populates="meal_plan", cascade="all, delete-orphan")
|
|
|
|
|
|
class MealPlanItem(Base):
|
|
__tablename__ = "meal_plan_item"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id", ondelete="CASCADE"))
|
|
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
|
|
day_of_week = Column(Integer, nullable=False)
|
|
meal_type = Column(SQLEnum(MealType, name="meal_type_enum", create_type=False), nullable=False)
|
|
approval_status = Column(SQLEnum(MealPlanItemStatus, name="meal_plan_item_status_enum", create_type=False), default=MealPlanItemStatus.PENDING)
|
|
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False))
|
|
denial_details = Column(Text)
|
|
estimated_cost = Column(Numeric(10, 2))
|
|
used_pantry_items = Column(ARRAY(UUID(as_uuid=True)))
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("meal_plan_id", "day_of_week", "meal_type"),
|
|
CheckConstraint("day_of_week BETWEEN 1 AND 7"),
|
|
)
|
|
|
|
meal_plan = relationship("MealPlan", back_populates="items")
|
|
recipe = relationship("Recipe", back_populates="meal_plan_items")
|
|
feedback = relationship("Feedback", back_populates="meal_plan_item", uselist=False)
|
|
votes = relationship("MealPlanVote", back_populates="meal_plan_item", cascade="all, delete-orphan")
|
|
|
|
|
|
class MealPlanVote(Base):
|
|
__tablename__ = "meal_plan_vote"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
|
|
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="CASCADE"))
|
|
vote = Column(Boolean, nullable=False)
|
|
voted_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("meal_plan_item_id", "family_member_id"),
|
|
)
|
|
|
|
meal_plan_item = relationship("MealPlanItem", back_populates="votes")
|
|
family_member = relationship("FamilyMember", back_populates="votes")
|
|
|
|
|
|
class ApprovalToken(Base):
|
|
__tablename__ = "approval_token"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
|
|
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="CASCADE"))
|
|
token = Column(String(64), nullable=False, unique=True)
|
|
status = Column(SQLEnum(ApprovalTokenStatus, name="approval_token_status_enum", create_type=False), default=ApprovalTokenStatus.ACTIVE)
|
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
|
used_at = Column(DateTime(timezone=True))
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("meal_plan_item_id", "family_member_id"),
|
|
)
|
|
|
|
meal_plan_item = relationship("MealPlanItem")
|
|
family_member = relationship("FamilyMember")
|
|
|
|
|
|
class HomePantry(Base):
|
|
__tablename__ = "home_pantry"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
|
|
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
|
|
quantity = Column(Numeric(10, 2))
|
|
unit = Column(String(50))
|
|
expires_at = Column(Date)
|
|
added_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("family_profile_id", "ingredient_id"),
|
|
)
|
|
|
|
family_profile = relationship("FamilyProfile", back_populates="pantry_items")
|
|
ingredient = relationship("Ingredient", back_populates="pantry_items")
|
|
|
|
|
|
class Feedback(Base):
|
|
__tablename__ = "feedback"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
|
|
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="SET NULL"))
|
|
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
|
|
rating = Column(Integer)
|
|
never_suggest = Column(Boolean, default=False)
|
|
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False))
|
|
feedback_text = Column(Text)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
__table_args__ = (
|
|
CheckConstraint("rating BETWEEN 1 AND 5"),
|
|
)
|
|
|
|
family_profile = relationship("FamilyProfile", back_populates="feedbacks")
|
|
family_member = relationship("FamilyMember", back_populates="feedbacks")
|
|
meal_plan_item = relationship("MealPlanItem", back_populates="feedback")
|
|
|
|
|
|
class NeverSuggest(Base):
|
|
__tablename__ = "never_suggest"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
|
|
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="CASCADE"))
|
|
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id", ondelete="CASCADE"))
|
|
reason = Column(SQLEnum(NeverSuggestReason, name="never_suggest_reason_enum", create_type=False))
|
|
notes = Column(Text)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
family_profile = relationship("FamilyProfile", back_populates="never_suggests")
|
|
ingredient = relationship("Ingredient", back_populates="never_suggests")
|
|
|
|
|
|
class GroceryItem(Base):
|
|
__tablename__ = "grocery_item"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="SET NULL"))
|
|
name = Column(String(300), nullable=False)
|
|
brand = Column(String(200))
|
|
current_price = Column(Numeric(10, 2))
|
|
regular_price = Column(Numeric(10, 2))
|
|
unit = Column(String(50))
|
|
aisle = Column(String(100))
|
|
image_url = Column(Text)
|
|
product_url = Column(Text)
|
|
is_on_sale = Column(Boolean, default=False)
|
|
sale_start_date = Column(Date)
|
|
sale_end_date = Column(Date)
|
|
in_season = Column(Boolean, default=False)
|
|
scraped_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
scraped_url = Column(Text)
|
|
|
|
ingredient = relationship("Ingredient", back_populates="grocery_item_links")
|
|
|
|
|
|
class ScrapeLog(Base):
|
|
__tablename__ = "scrape_log"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
source = Column(String(50), nullable=False)
|
|
scrape_type = Column(String(50), nullable=False)
|
|
status = Column(SQLEnum(ScrapeStatus, name="scrape_status_enum", create_type=False), nullable=False)
|
|
items_scraped = Column(Integer, default=0)
|
|
error_message = Column(Text)
|
|
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
completed_at = Column(DateTime(timezone=True))
|
|
duration_seconds = Column(Integer)
|
|
|
|
|
|
class EmailLog(Base):
|
|
__tablename__ = "email_log"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
recipient_email = Column(String(300), nullable=False)
|
|
recipient_name = Column(String(200))
|
|
template = Column(String(100), nullable=False)
|
|
meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id"))
|
|
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id"))
|
|
sendgrid_message_id = Column(String(100))
|
|
status = Column(SQLEnum(EmailStatus, name="email_status_enum", create_type=False), nullable=False)
|
|
error_message = Column(Text)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
delivered_at = Column(DateTime(timezone=True))
|
|
|
|
meal_plan = relationship("MealPlan")
|
|
meal_plan_item = relationship("MealPlanItem")
|