Public Access
448 lines
18 KiB
Python
448 lines
18 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(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")
|
|
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)
|
|
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)
|
|
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, 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")
|