from sqlalchemy import ( Column, String, Integer, Numeric, Float, 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(str, 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 IngredientMatchSource(enum.Enum): AUTO = "auto" AUTO_LLM = "auto_llm" MANUAL = "manual" 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) pending_approval_policy = Column(String(10), nullable=False, server_default="approve") planner_config = Column(JSONB, nullable=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__ = ( 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") weekly_runs = relationship( "WeeklyRun", 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, values_callable=lambda obj: [e.value for e in obj]), 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)) aliases = Column(ARRAY(Text), nullable=False, server_default="{}") 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") matches = relationship("IngredientGroceryMatch", back_populates="ingredient", cascade="all, delete-orphan") 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) calories_per_serving = Column(Integer) ingredients = Column(JSONB, nullable=False) side_dishes = Column(JSONB, default=list) instructions = Column(ARRAY(Text), nullable=False) source_url = Column(Text) scraped_at = Column(DateTime(timezone=True)) is_manually_added = Column(Boolean, default=False) external_source = Column(String(50)) external_id = Column(String(100)) discovery_reason = 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()) 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, values_callable=lambda obj: [e.value for e in obj]), 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") 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, values_callable=lambda obj: [e.value for e in obj]), nullable=False) approval_status = Column(SQLEnum(MealPlanItemStatus, name="meal_plan_item_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), default=MealPlanItemStatus.pending) denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj])) denial_details = Column(Text) # Sprint 8: when this denial stops being a "soft" signal. NULL means # either an approve / a non-denial row, or a "Never again" denial # (no decay; promoted to NeverSuggest). denial_expires_at = Column(DateTime(timezone=True), nullable=True) estimated_cost = Column(Numeric(10, 2)) score = Column(Float) components = Column(JSONB) 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) # Sprint 8: which deny-scope the voter chose. NULL for approve votes. # "this_week" = soft denial, decays in 90d. "never_again" = hard block # (a NeverSuggest row is also written for permanence). denial_scope = Column(String(16), nullable=True) 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, values_callable=lambda obj: [e.value for e in obj]), 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, values_callable=lambda obj: [e.value for e in obj])) 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, values_callable=lambda obj: [e.value for e in obj])) 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) description = Column(Text, nullable=True) 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) # R3-0: Swiftly API idempotency key. (source, external_id) → upsert. external_id = Column(String(100), nullable=True, index=True) source = Column(String(50), nullable=True) 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, values_callable=lambda obj: [e.value for e in obj]), 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, values_callable=lambda obj: [e.value for e in obj]), 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") class IngredientGroceryMatch(Base): __tablename__ = "ingredient_grocery_match" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="CASCADE"), nullable=False) grocery_item_id = Column(UUID(as_uuid=True), ForeignKey("grocery_item.id", ondelete="CASCADE"), nullable=False) confidence = Column(Numeric(4, 3), nullable=False) source = Column(SQLEnum(IngredientMatchSource, name="ingredient_match_source_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) __table_args__ = ( UniqueConstraint("ingredient_id", "grocery_item_id", name="uq_ingredient_grocery_match_pair"), ) ingredient = relationship("Ingredient", back_populates="matches") grocery_item = relationship("GroceryItem") class WeeklyRun(Base): __tablename__ = "weekly_run" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) family_id = Column( UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"), nullable=False, ) week_start_date = Column(Date, nullable=False) status = Column(String(20), nullable=False, default="pending") scraped_at = Column(DateTime(timezone=True)) generated_at = Column(DateTime(timezone=True)) emailed_at = Column(DateTime(timezone=True)) reminded_at = Column(DateTime(timezone=True)) deadline_passed_at = Column(DateTime(timezone=True)) finalized_at = Column(DateTime(timezone=True)) feedback_analysis = Column(JSONB, nullable=True) used_stale_data = Column(Boolean, nullable=False, default=False) error_step = Column(String(50)) error_message = Column(Text) created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) __table_args__ = ( UniqueConstraint( "family_id", "week_start_date", name="uq_weekly_run_family_week" ), ) family_profile = relationship("FamilyProfile", back_populates="weekly_runs")