munch-ease-backend/main.py

354 lines
No EOL
13 KiB
Python

import sqlite3
import products, recipes, db, meals, persons, ingredients, shopping
import datetime
from pydantic import BaseModel
from typing import List, Annotated, Union
from fastapi import FastAPI, Depends, Query, Cookie
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*", "http://localhost:8080", "https://localhost:8080", "http://192.168.68.183:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
# Dependency to create SQLite connection
async def get_db():
sql_db = await db.connect()
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("/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("/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("/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("/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("/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('/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'})
for ingredient in recipe.ingredients:
if not ingredient.product:
return JSONResponse(status_code=400, content={'message': 'Ingredient must have a product'})
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
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("/meals/")
async def get_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_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("/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)}'})
return None
@app.post("/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("/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.delete("/meals/{meal_id}")
async def delete_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'})
await meals.delete_meal(conn, meal.id)
await conn.commit()
return meal
@app.get("/shopping/{list_id}")
async def get_shopping_list(list_id: Union[int, str], conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList:
if list_id.lower() == 'current':
return await shopping.current_shopping_list(conn)
try:
list_id = int(list_id)
except ValueError:
return JSONResponse(status_code=400, content={'message': 'Invalid shopping list ID'})
return await shopping.load_shopping_list(conn, list_id)
@app.post("/shopping/current/purchased")
async def mark_purchased(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingList:
response = await shopping.mark_purchased(conn)
# Its easier to make the next shopping list now, while we know calling it requires commit()
await shopping.current_shopping_list(conn)
await conn.commit()
return response
class FoundResult(BaseModel):
created: List[shopping.ShoppingListResult] = []
removed: List[shopping.ShoppingListResult] = []
@app.post("/shopping/current/found")
async def mark_shopping_list(ingredients: List[ingredients.Ingredient], conn: sqlite3.Connection = Depends(get_db)) -> FoundResult:
now = datetime.datetime.now()
result = FoundResult()
for ingredient in ingredients:
existing, created = await shopping.mark_found(conn, ingredient, now)
result.created.append(created)
if existing:
result.removed.append(existing)
await conn.commit()
return result
@app.delete("/shopping/current/found/{product_id}")
async def unmark_shopping_list(product_id: int, conn: sqlite3.Connection = Depends(get_db)) -> List[shopping.ShoppingListResult]:
response = await shopping.unmark_found(conn, product_id)
await conn.commit()
return response
@app.get("/shopping/current/me/ingredients")
async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[shopping.ShoppingListRequest]:
current = await shopping.current_shopping_list(conn)
return [r async for r in shopping.get_persons_requests(conn, current, person) if r.ingredient]
@app.post("/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[shopping.ShoppingListRequest]:
current = await shopping.current_shopping_list(conn)
result = [r async for r in shopping.sync_persons_requested_ingredients(conn, current, person, requests) if r.ingredient]
await conn.commit()
return result
class MealIdWrapper(BaseModel):
meal_id: int
@app.post("/shopping/current/meals/me")
async def request_meal(r: MealIdWrapper, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListRequest:
current = await shopping.current_shopping_list(conn)
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_meal(conn, current, person, meal)
await conn.commit()
return response
@app.delete("/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:
current = await shopping.current_shopping_list(conn)
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.delete_requests(conn, current, meal)
await conn.commit()
return {}
@app.get("/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("/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('/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('/auth/refresh')
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
return user