Public Access
feat: add Phase 1 infrastructure skeleton
Backend (FastAPI): - docker-compose with all 4 services - FastAPI app with health endpoints - SQLAlchemy models for all tables - Placeholder API endpoints for all routes - Config and database modules - requirements.txt with all dependencies Frontend (React): - package.json with React, Tailwind, React Query, React Router - Vite config with API proxy - Tailwind and TypeScript configs - Basic App with routing skeleton - Placeholder pages (Dashboard, MealDetail, Pantry) Infrastructure: - nginx config for reverse proxy - Dockerfile for backend and frontend
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN python -c "from playwright.sync_api import sync_playwright; \
|
||||
p = sync_playwright().start(); \
|
||||
p.chromium.download(); \
|
||||
p.stop()" || echo "Playwright browser download skipped"
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/scrape")
|
||||
def trigger_scrape(db: Session = Depends(get_db)):
|
||||
return {"message": "Scrape trigger - not yet implemented"}
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
def get_logs(db: Session = Depends(get_db)):
|
||||
return {"message": "Logs endpoint - not yet implemented"}
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
def test_email(db: Session = Depends(get_db)):
|
||||
return {"message": "Test email - not yet implemented"}
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/planned")
|
||||
def get_planned_meals(db: Session = Depends(get_db)):
|
||||
return {"message": "Planned meals endpoint - not yet implemented"}
|
||||
|
||||
|
||||
@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("/{meal_id}/deny")
|
||||
def deny_meal(meal_id: str, db: Session = Depends(get_db)):
|
||||
return {"message": f"Deny meal {meal_id} - not yet implemented"}
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_pantry(db: Session = Depends(get_db)):
|
||||
return {"message": "Pantry endpoint - not yet implemented"}
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def add_pantry_item(db: Session = Depends(get_db)):
|
||||
return {"message": "Add pantry item - not yet implemented"}
|
||||
|
||||
|
||||
@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"}
|
||||
@@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_profile(db: Session = Depends(get_db)):
|
||||
return {"message": "Profile endpoint - not yet implemented"}
|
||||
|
||||
|
||||
@router.put("/")
|
||||
def update_profile(db: Session = Depends(get_db)):
|
||||
return {"message": "Update profile endpoint - not yet implemented"}
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_recipes(db: Session = Depends(get_db)):
|
||||
return {"message": "Recipes endpoint - not yet implemented"}
|
||||
|
||||
|
||||
@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.post("/")
|
||||
def create_recipe(db: Session = Depends(get_db)):
|
||||
return {"message": "Create recipe endpoint - not yet implemented"}
|
||||
@@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_shopping_list(db: Session = Depends(get_db)):
|
||||
return {"message": "Shopping list endpoint - not yet implemented"}
|
||||
|
||||
|
||||
@router.get("/print")
|
||||
def get_printable_shopping_list(db: Session = Depends(get_db)):
|
||||
return {"message": "Printable shopping list - not yet implemented"}
|
||||
@@ -0,0 +1,23 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
SENDGRID_API_KEY: Optional[str] = None
|
||||
LUCKY_CA_URL: str = "https://www.luckyncal.com"
|
||||
AI_IMAGE_ENABLED: bool = False
|
||||
AI_IMAGE_PROVIDER: Optional[str] = None
|
||||
AI_IMAGE_API_KEY: Optional[str] = None
|
||||
LOG_LEVEL: str = "INFO"
|
||||
SECRET_KEY: str = "dev-secret-key"
|
||||
|
||||
FAMILY_EMAIL_1: Optional[str] = None
|
||||
FAMILY_EMAIL_2: Optional[str] = None
|
||||
RECIPES_EMAIL: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,41 @@
|
||||
from fastapi import FastAPI, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db, engine, Base
|
||||
from app.models import family_profile, ingredient, recipe, meal_plan, home_pantry, feedback, grocery_item, scrape_log, email_log
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="MealPlanner",
|
||||
description="Self-hosted meal planning system",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check(db: Session = Depends(get_db)):
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/health/db")
|
||||
def health_check_db(db: Session = Depends(get_db)):
|
||||
try:
|
||||
db.execute("SELECT 1")
|
||||
return {"status": "ok", "database": "connected"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "database": "disconnected", "error": str(e)}
|
||||
|
||||
|
||||
from app.api import profile, recipes, meals, shopping_list, pantry, admin
|
||||
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(recipes.router, prefix="/api/recipes", tags=["recipes"])
|
||||
app.include_router(meals.router, prefix="/api/meals", tags=["meals"])
|
||||
app.include_router(shopping_list.router, prefix="/api/shopping-list", tags=["shopping-list"])
|
||||
app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
@@ -0,0 +1,248 @@
|
||||
from sqlalchemy import (
|
||||
Column, String, Integer, Numeric, Text, Boolean, Date, DateTime,
|
||||
ForeignKey, CheckConstraint, UniqueConstraint, ARRAY
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
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())
|
||||
|
||||
recipes = relationship("Recipe", back_populates="family_profile")
|
||||
meal_plans = relationship("MealPlan", back_populates="family_profile")
|
||||
pantry_items = relationship("HomePantry", back_populates="family_profile")
|
||||
feedbacks = relationship("Feedback", back_populates="family_profile")
|
||||
never_suggests = relationship("NeverSuggest", back_populates="family_profile")
|
||||
|
||||
|
||||
class Ingredient(Base):
|
||||
__tablename__ = "ingredient"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = 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())
|
||||
|
||||
recipes = relationship("RecipeIngredient", back_populates="ingredient")
|
||||
pantry_items = relationship("HomePantry", back_populates="ingredient")
|
||||
never_suggests = relationship("NeverSuggest", 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")
|
||||
|
||||
|
||||
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(String(20), nullable=False, default="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(String(20), nullable=False)
|
||||
approval_status = Column(String(20), default="pending")
|
||||
approval_token = Column(UUID(as_uuid=True), unique=True, default=uuid.uuid4)
|
||||
approval_token_expires = Column(DateTime(timezone=True))
|
||||
denial_reason = Column(String(50))
|
||||
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 0 AND 6"),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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"))
|
||||
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(String(50))
|
||||
feedback_text = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("meal_plan_item_id"),
|
||||
CheckConstraint("rating BETWEEN 1 AND 5"),
|
||||
)
|
||||
|
||||
family_profile = relationship("FamilyProfile", 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"))
|
||||
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
|
||||
reason = Column(String(50))
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
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(String(20), 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(String(20), 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 RecipeIngredient(Base):
|
||||
__tablename__ = "recipe_ingredient"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id", ondelete="CASCADE"))
|
||||
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
|
||||
quantity = Column(Numeric(10, 2))
|
||||
unit = Column(String(50))
|
||||
is_optional = Column(Boolean, default=False)
|
||||
|
||||
recipe = relationship("Recipe", back_populates="recipe_ingredients")
|
||||
ingredient = relationship("Ingredient", back_populates="recipes")
|
||||
@@ -0,0 +1,16 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
alembic==1.13.1
|
||||
psycopg2-binary==2.9.9
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
sendgrid==6.12.0
|
||||
playwright==1.41.0
|
||||
beautifulsoup4==4.12.3
|
||||
lxml==5.1.0
|
||||
apscheduler==3.10.4
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.26.0
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
Reference in New Issue
Block a user