munch-ease-backend/main.py
jableader 255ebd4613 Squashed commit of the following:
commit 1c707260a4059cbdebb3e2720d3899b8773a1c37
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 22:03:48 2025 +1000

    Sync tests

commit 5a71077121921a2cea9f3be9c1d1f7770691520a
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:57:00 2025 +1000

    minor refactorings

commit 0bafec1b40bf5cf6f1554f6e5ec6e8f4e043d1cc
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:39:52 2025 +1000

    Remove ids from shopping list

commit c1ee30d1b591b241dfa152d8a0fa9af1f6aaaed1
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:04:48 2025 +1000

    Added more cases to shopping tests

commit ad3d598bdb574b1aeaabc354bb825ee428aaaa41
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:26:36 2025 +1000

    Added 404 response when list not found

commit 336538dc47e12736f7f57d2c80252a700dc1e83b
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:15:52 2025 +1000

    Tests for main

commit 238c0f89b2671348091a162ee519e5f1bf2cd66c
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:07:12 2025 +1000

    Meals tests

commit b92f2432ee13f5384b6f1a9b8efe76bec9dbd920
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 19:57:45 2025 +1000

    Shopping tests

commit b3c7ae20e6207cf0f9f04bdec5cb6145b04638d9
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 19:40:19 2025 +1000

    product tests passing
2025-07-30 22:05:29 +10:00

418 lines
No EOL
17 KiB
Python

import sqlite3
import products, recipes, db, meals, persons, ingredients, shopping
import datetime
from pydantic import BaseModel
from typing import Dict, List, Annotated, Optional, Union
from fastapi import FastAPI, Depends, Query, Cookie
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
app = FastAPI()
import os
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: sqlite3.Connection = Depends(get_db)) -> persons.Person:
return await persons.get_by_id(conn, user_id)
@app.get("/api/recipes/parse")
async def parse_recipe_handler(url: str, conn: sqlite3.Connection = Depends(get_db), person = Depends(cookie_person)) -> recipes.Recipe:
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: sqlite3.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] = []
@app.post("/api/products")
async def create_product(url: ProductUrl, conn: sqlite3.Connection = Depends(get_db)) -> products.Product:
return await products.get_or_create(conn, url.url, url.tags)
async def load_full_recipe(conn: sqlite3.Connection, id: int) -> 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)
r.created_by = await persons.get_by_id(conn, r.created_by_id)
return r
@app.get("/api/recipes")
async def get_recipes(q: str | None = None, conn: sqlite3.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}")
async def get_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db)) -> recipes.Recipe:
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')
async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe:
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}')
async def delete_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe:
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: sqlite3.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}")
async def get_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal:
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) -> JSONResponse | None:
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")
async def create_meal(meal: meals.Meal, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal:
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}")
async def update_meal(meal_id: int, meal: meals.Meal, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal:
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")
async def mark_consumed(meal_id: int, consumed_date: Optional[datetime.datetime] = None, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> meals.Meal:
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}")
async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> meals.Meal:
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] = []
ingredients_lookup: Dict[int, ingredients.Ingredient] = {}
meals_lookup: Dict[int, meals.Meal] = {}
shopping_list_lookup: Dict[int, shopping.ShoppingList] = {}
recipes_lookup: Dict[int, recipes.Recipe] = {}
@app.get("/api/shopping/current")
async def get_current_shopping_list(conn: sqlite3.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 = { list_id: await shopping.load_shopping_list(conn, list_id) for list_id in other_shopping_list_ids }
# 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] = {}
ingredients_lookup: Dict[int, ingredients.Ingredient] = {}
recipes_lookup: Dict[int, recipes.Recipe] = {}
@app.get("/api/shopping/{list_id}")
async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> PurchasedShoppingList:
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: sqlite3.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: sqlite3.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: sqlite3.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")
async def request_meal(r: MealIdWrapper, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListItem:
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}")
async def unrequest_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> dict:
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: str = None, conn: sqlite3.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: sqlite3.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')
async def login(data: LoginBody, conn: sqlite3.Connection = Depends(get_db)) -> persons.Person:
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
from starlette.requests import Request
from starlette.responses import StreamingResponse
from starlette.background import BackgroundTask
import httpx
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"])