import datetime import os from typing import Annotated, Dict, List, Optional import aiosqlite from fastapi import Cookie, Depends, FastAPI, Query from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from pydantic import BaseModel, Field import db import ingredients import meals import persons import products import recipes import shopping app = FastAPI() DATABASE_PATH = os.environ.get("DOOF_DB", "./data/doof.sqlite") # Dependency to create SQLite connection async def get_db(): sql_db = await db.connect(DATABASE_PATH) try: yield sql_db finally: await sql_db.close() async def cookie_person( user_id: Annotated[int, Cookie(alias="user_id")], conn: aiosqlite.Connection = Depends(get_db) ) -> Optional[persons.Person]: return await persons.get_by_id(conn, user_id) @app.get("/api/recipes/parse", response_model=None) async def parse_recipe_handler( url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person) ) -> recipes.Recipe | JSONResponse: parsed = await recipes.parse_recipe(conn, person, url) if not parsed: return JSONResponse(status_code=400, content={"message": "Recipe not found"}) return parsed @app.get("/api/recipes/ingredients/parse") async def parse_ingredients( lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")], conn: aiosqlite.Connection = Depends(get_db), ) -> List[ingredients.Ingredient]: had_links = False result = [] for line in lines: ingredient = await ingredients.parse_ingredient_from_link(conn, line) if ingredient: result.append(ingredient) had_links = True continue ingredient = ingredients.parse_ingredient_from_nlp(line) if ingredient: result.append(ingredient) continue if had_links: await conn.commit() await ingredients.match_existing_products(conn, result) return result class ProductUrl(BaseModel): url: str tags: List[str] = Field(default_factory=list) @app.post("/api/products") async def create_product( url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db) ) -> Optional[products.Product]: return await products.get_or_create(conn, url.url, url.tags) async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]: r = await recipes.find_recipe_by_id(conn, id) if not r: return None r.ingredients = [] async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id): r.ingredients.append(ingredient) if r.created_by_id is not None: r.created_by = await persons.get_by_id(conn, r.created_by_id) return r @app.get("/api/recipes") async def get_recipes( q: Optional[str] = None, conn: aiosqlite.Connection = Depends(get_db) ) -> List[recipes.Recipe]: result = [] if q: async for recipe in recipes.find_recipes_by_name(conn, q): result.append(recipe) else: async for recipe in recipes.get_all(conn): result.append(recipe) for recipe in result: recipe.ingredients = [] async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, recipe.id): recipe.ingredients.append(ingredient) return result @app.get("/api/recipes/{recipe_id}", response_model=None) async def get_recipe( recipe_id: int, conn: aiosqlite.Connection = Depends(get_db) ) -> recipes.Recipe | JSONResponse: r = await load_full_recipe(conn, recipe_id) if not r: return JSONResponse(status_code=404, content={"message": "Recipe not found"}) return r @app.post("/api/recipes", response_model=None) async def create_recipe( recipe: recipes.Recipe, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), ) -> recipes.Recipe | JSONResponse: if not recipe.ingredients: return JSONResponse( status_code=400, content={"message": "Recipe must have at least one ingredient"} ) if recipe.id >= 0: await recipes.hide_recipe(conn, recipe.id, user) recipe.based_on_recipe = recipe.id recipe.id = 0 recipe.created_by_id = user.id await recipes.insert_recipe(conn, recipe) for ingredient in recipe.ingredients: ingredient.recipe_id = recipe.id if ingredient.product: ingredient.product_id = ingredient.product.id await ingredients.insert_ingredient(conn, ingredient) await conn.commit() return recipe @app.delete("/recipes/{recipe_id}", response_model=None) async def delete_recipe( recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), ) -> recipes.Recipe | JSONResponse: recipe = await recipes.find_recipe_by_id(conn, recipe_id) if not recipe: return JSONResponse(status_code=404, content={"message": "Recipe not found"}) await recipes.hide_recipe(conn, recipe_id, user) await conn.commit() return recipe @app.get("/api/meals/upcoming") async def get_upcoming_meals( date_from: Annotated[datetime.datetime, Query(alias="from")], to: datetime.datetime, conn: aiosqlite.Connection = Depends(get_db), ) -> List[meals.Meal]: result = [] async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to): await meals.load_recipes(conn, meal) await meals.load_extra_ingredients(conn, meal) await meals.load_participants(conn, meal) result.append(meal) return result @app.get("/api/meals/{meal_id}", response_model=None) async def get_meal( meal_id: int, conn: aiosqlite.Connection = Depends(get_db) ) -> meals.Meal | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return JSONResponse(status_code=404, content={"message": "Meal not found"}) return meal def get_duplicates(items: List[meals.Person]) -> set[str]: seen: set[int] = set() duplicates: set[str] = set() for item in items: if item.id in seen: duplicates.add(item.name) seen.add(item.id) return duplicates def validate_meal(meal: meals.Meal) -> Optional[JSONResponse]: if not meal.chefs: return JSONResponse( status_code=400, content={"message": "Meal must have at least one chef"} ) if not meal.cleanup: return JSONResponse( status_code=400, content={"message": "Meal must have at least one cleanup person"} ) if not meal.consumers: return JSONResponse( status_code=400, content={"message": "Meal must have at least one consumer"} ) if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0: return JSONResponse( status_code=400, content={"message": "Meal must have at least one recipe or ingredient"} ) duplicates = get_duplicates(meal.chefs) if duplicates: return JSONResponse( status_code=400, content={"message": f'Duplicate chef: {", ".join(duplicates)}'} ) duplicates = get_duplicates(meal.cleanup) if duplicates: return JSONResponse( status_code=400, content={"message": f'Duplicate cleanup person: {", ".join(duplicates)}'}, ) duplicates = get_duplicates(meal.consumers) if duplicates: return JSONResponse( status_code=400, content={"message": f'Duplicate consumer: {", ".join(duplicates)}'} ) zero_servings = [r for r in meal.recipes if r.servings == 0] if zero_servings: return JSONResponse( status_code=400, content={"message": "Recipe servings must be greater than 0"} ) return None @app.post("/api/meals", response_model=None) async def create_meal( meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db) ) -> meals.Meal | JSONResponse: validation_response = validate_meal(meal) if validation_response: return validation_response await meals.insert_meal(conn, meal) await conn.commit() return meal @app.put("/api/meals/{meal_id}", response_model=None) async def update_meal( meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db) ) -> meals.Meal | JSONResponse: if meal.id != meal_id: return JSONResponse( status_code=400, content={"message": "Meal ID in URL does not match meal ID in body"} ) existing = await meals.find_meal_by_id(conn, meal_id) if not existing: return JSONResponse(status_code=404, content={"message": "Meal not found"}) validation_response = validate_meal(meal) if validation_response: return validation_response await meals.update_meal(conn, meal) await conn.commit() return await get_meal(meal_id, conn) @app.post("/api/meals/{meal_id}/consumed", response_model=None) async def mark_consumed( meal_id: int, consumed_date: Optional[datetime.datetime] = None, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), ) -> meals.Meal | JSONResponse: if consumed_date and not consumed_date.tzinfo: return JSONResponse( status_code=400, content={"message": "Consumed date must include timezone"} ) meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return JSONResponse(status_code=404, content={"message": "Meal not found"}) await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone()) await shopping.remove_request(conn, person, meal=meal) await conn.commit() return meal @app.delete("/api/meals/{meal_id}", response_model=None) async def delete_meal( meal_id: int, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), ) -> meals.Meal | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return JSONResponse(status_code=404, content={"message": "Meal not found"}) await shopping.remove_request(conn, person, meal=meal) await meals.delete_meal(conn, meal.id) await conn.commit() return meal class CurrentShoppingList(BaseModel): outstanding_items: List[shopping.ShoppingListItem] requested_meals: List[shopping.ShoppingListItem] purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list) ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict) meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict) shopping_list_lookup: Dict[int, shopping.ShoppingList] = Field(default_factory=dict) recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) @app.get("/api/shopping/current") async def get_current_shopping_list( conn: aiosqlite.Connection = Depends(get_db), ) -> CurrentShoppingList: ( outstanding_requests, purchased_requests, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(conn) other_shopping_list_ids = {item.list_id for item in purchased_requests} shopping_list_lookup = {} for list_id in other_shopping_list_ids: if list_id is not None: sl = await shopping.load_shopping_list(conn, list_id) if sl is not None: shopping_list_lookup[list_id] = sl # Add any additional items from shopping lists to the existing lookups additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items] if additional_items: await shopping.to_lookups( conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup ) return CurrentShoppingList( outstanding_items=outstanding_requests, requested_meals=meal_requests, purchased_items=purchased_requests, meals_lookup=meals_lookup, shopping_list_lookup=shopping_list_lookup, ingredients_lookup=ingredients_lookup, recipes_lookup=recipes_lookup, ) class PurchasedShoppingList(BaseModel): list: shopping.ShoppingList meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict) ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict) recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) @app.get("/api/shopping/{list_id}", response_model=None) async def get_shopping_list( list_id: int, conn: aiosqlite.Connection = Depends(get_db) ) -> PurchasedShoppingList | JSONResponse: shopping_list = await shopping.load_shopping_list(conn, list_id) if not shopping_list: return JSONResponse(status_code=404, content={"message": "Shopping list not found"}) meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( conn, shopping_list.items ) return PurchasedShoppingList( list=shopping_list, meals_lookup=meals_lookup, recipes_lookup=recipes_lookup, ingredients_lookup=ingredients_lookup, ) @app.post("/api/shopping/") async def purchase_ingredients( shopping_list: shopping.ShoppingList, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), ) -> PurchasedShoppingList: shopping_list = shopping.ShoppingList( purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name ) await shopping.purchase(conn, shopping_list) await conn.commit() result = PurchasedShoppingList(list=shopping_list) await shopping.to_lookups( conn, shopping_list.items, result.meals_lookup, result.recipes_lookup, result.ingredients_lookup, ) return result @app.get("/api/shopping/current/me/ingredients") async def get_my_shopping_list( conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person) ) -> List[ingredients.Ingredient]: return await shopping.get_persons_requests(conn, person.id) @app.post("/api/shopping/current/me/ingredients") async def sync_my_shopping_list( requests: List[ingredients.Ingredient], conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), ) -> List[ingredients.Ingredient]: def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool: return a.id == b.id or a.line == b.line my_shopping_list = await shopping.get_persons_requests(conn, person.id) to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)] to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)] for r in to_remove: await shopping.remove_request(conn, person, ingredient=r) for r in to_add: if r.id < 0: await ingredients.insert_ingredient(conn, r) await shopping.request(conn, person, ingredient=r) await conn.commit() return await get_my_shopping_list(conn, person) class MealIdWrapper(BaseModel): meal_id: int @app.post("/api/shopping/current/meals/me", response_model=None) async def request_meal( r: MealIdWrapper, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), ) -> shopping.ShoppingListItem | JSONResponse: meal = await meals.find_meal_by_id(conn, r.meal_id) if not meal: return JSONResponse(status_code=404, content={"message": "Meal not found"}) response = await shopping.request(conn, person, meal=meal) await conn.commit() return response @app.delete("/api/shopping/current/meals/{meal_id}", response_model=None) async def unrequest_meal( meal_id: int, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), ) -> dict | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return JSONResponse(status_code=404, content={"message": "Meal not found"}) await shopping.remove_request(conn, person, meal=meal) await conn.commit() return {} @app.get("/api/persons") async def get_persons( q: Optional[str] = None, conn: aiosqlite.Connection = Depends(get_db) ) -> List[meals.Person]: query = persons.search_by_name(conn, q) if q else persons.get_all(conn) result = [] async for person in query: result.append(person) return result @app.post("/api/persons") async def create_person( person: persons.Person, conn: aiosqlite.Connection = Depends(get_db) ) -> persons.Person: await persons.insert_person(conn, person) await conn.commit() return person class LoginBody(BaseModel): username: str @app.post("/api/auth/login", response_model=None) async def login( data: LoginBody, conn: aiosqlite.Connection = Depends(get_db) ) -> persons.Person | JSONResponse: person = await persons.get_by_name(conn, data.username) if not person: return JSONResponse(status_code=404, content={"message": "Person not found"}) response = JSONResponse(content=jsonable_encoder(person)) response.set_cookie(key="user_id", value=str(person.id)) return response @app.post("/api/auth/refresh") async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: return user if os.environ.get("DOOF_PROD", False): from fastapi.staticfiles import StaticFiles app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist") else: # Proxy the request to the frontend development server import httpx from starlette.background import BackgroundTask from starlette.requests import Request from starlette.responses import StreamingResponse client = httpx.AsyncClient(base_url="http://localhost:8080/") async def _reverse_proxy(request: Request): url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) rp_req = client.build_request( request.method, url, headers=request.headers.raw, content=request.stream() ) rp_resp = await client.send(rp_req, stream=True) return StreamingResponse( rp_resp.aiter_raw(), status_code=rp_resp.status_code, headers=rp_resp.headers, background=BackgroundTask(rp_resp.aclose), ) app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])