Initial commit

This commit is contained in:
2026-07-28 15:00:00 +02:00
commit 2f95eeda83
64 changed files with 5990 additions and 0 deletions
View File
+42
View File
@@ -0,0 +1,42 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import select
from app.deps import SessionDep
from app.models import User
from app.schemas import Token, UserCreate, UserRead
from app.security import create_access_token, get_password_hash, verify_password
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register(user_in: UserCreate, session: SessionDep):
existing_user = session.exec(select(User).where(User.username == user_in.username)).first()
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered")
user = User(username=user_in.username, hashed_password=get_password_hash(user_in.password))
session.add(user)
session.commit()
session.refresh(user)
return user
@router.post("/token", response_model=Token)
def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: SessionDep,
):
user = session.exec(select(User).where(User.username == form_data.username)).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": user.username})
return Token(access_token=access_token)
+218
View File
@@ -0,0 +1,218 @@
from uuid import uuid4
from fastapi import APIRouter, HTTPException, status
from sqlmodel import Session, select
from app.access import product_barcode_accessible, user_can_access_dish
from app.deps import CurrentUserDep, SessionDep
from app.models import Dish, DishIngredient, DishShare, Product, User
from app.schemas import DishCreate, DishIngredientRead, DishRead, DishShareCreate, DishShareRead
router = APIRouter(prefix="/api/dishes", tags=["dishes"])
NUTRIENT_FIELDS = ["calories", "carbs", "protein", "fat", "sugar", "fiber", "saturated_fat", "salt"]
def _compute_nutrition_per_100g(session: Session, ingredients: list, user_id: int) -> tuple[dict, float]:
total_weight = sum(ingredient.amount_g for ingredient in ingredients)
if total_weight <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Zutaten ergeben insgesamt 0g")
totals = dict.fromkeys(NUTRIENT_FIELDS, 0.0)
for ingredient in ingredients:
product = session.exec(select(Product).where(Product.barcode == ingredient.barcode)).first()
if product is None or not product_barcode_accessible(session, ingredient.barcode, user_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Zutat mit Barcode {ingredient.barcode} nicht gefunden",
)
factor = ingredient.amount_g / 100.0
for field in NUTRIENT_FIELDS:
totals[field] += getattr(product, field) * factor
per_100g = {field: totals[field] / total_weight * 100 for field in NUTRIENT_FIELDS}
return per_100g, total_weight
def _get_owned_dish(session: Session, dish_id: int, user_id: int) -> Dish:
dish = session.get(Dish, dish_id)
if dish is None or dish.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gericht nicht gefunden")
return dish
def _get_accessible_dish(session: Session, dish_id: int, user_id: int) -> Dish:
dish = session.get(Dish, dish_id)
if dish is None or not user_can_access_dish(session, dish, user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gericht nicht gefunden")
return dish
def _to_dish_read(session: Session, dish: Dish, current_user_id: int) -> DishRead:
product = session.exec(select(Product).where(Product.barcode == dish.product_barcode)).first()
ingredient_rows = session.exec(select(DishIngredient).where(DishIngredient.dish_id == dish.id)).all()
owner = session.get(User, dish.user_id)
ingredients = []
for row in ingredient_rows:
ingredient_product = session.exec(select(Product).where(Product.barcode == row.product_barcode)).first()
ingredients.append(
DishIngredientRead(
barcode=row.product_barcode,
name=ingredient_product.name if ingredient_product else "Unbekannt",
amount_g=row.amount_g,
**{
field: (getattr(ingredient_product, field) if ingredient_product else 0.0)
for field in NUTRIENT_FIELDS
},
)
)
return DishRead(
id=dish.id,
name=dish.name,
instructions=dish.instructions,
barcode=dish.product_barcode,
total_weight_g=dish.total_weight_g,
ingredients=ingredients,
is_owner=dish.user_id == current_user_id,
owner_username=owner.username if owner else "?",
**{field: getattr(product, field) for field in NUTRIENT_FIELDS},
)
def _replace_ingredients(session: Session, dish: Dish, ingredients_in: list) -> None:
existing = session.exec(select(DishIngredient).where(DishIngredient.dish_id == dish.id)).all()
for row in existing:
session.delete(row)
session.flush()
for ingredient in ingredients_in:
session.add(DishIngredient(dish_id=dish.id, product_barcode=ingredient.barcode, amount_g=ingredient.amount_g))
@router.post("", response_model=DishRead, status_code=status.HTTP_201_CREATED)
def create_dish(dish_in: DishCreate, current_user: CurrentUserDep, session: SessionDep):
per_100g, total_weight = _compute_nutrition_per_100g(session, dish_in.ingredients, current_user.id)
barcode = f"dish-{uuid4().hex[:12]}"
product = Product(barcode=barcode, name=dish_in.name, **per_100g)
session.add(product)
dish = Dish(
user_id=current_user.id,
product_barcode=barcode,
name=dish_in.name,
instructions=dish_in.instructions,
total_weight_g=total_weight,
)
session.add(dish)
session.flush()
_replace_ingredients(session, dish, dish_in.ingredients)
session.commit()
session.refresh(dish)
return _to_dish_read(session, dish, current_user.id)
@router.get("", response_model=list[DishRead])
def list_dishes(current_user: CurrentUserDep, session: SessionDep):
owned = session.exec(select(Dish).where(Dish.user_id == current_user.id)).all()
shared_dish_ids = session.exec(
select(DishShare.dish_id).where(DishShare.shared_with_user_id == current_user.id)
).all()
shared = session.exec(select(Dish).where(Dish.id.in_(shared_dish_ids))).all() if shared_dish_ids else []
dishes = sorted(owned + shared, key=lambda dish: dish.name.lower())
return [_to_dish_read(session, dish, current_user.id) for dish in dishes]
@router.get("/{dish_id}", response_model=DishRead)
def get_dish(dish_id: int, current_user: CurrentUserDep, session: SessionDep):
dish = _get_accessible_dish(session, dish_id, current_user.id)
return _to_dish_read(session, dish, current_user.id)
@router.put("/{dish_id}", response_model=DishRead)
def update_dish(dish_id: int, dish_in: DishCreate, current_user: CurrentUserDep, session: SessionDep):
dish = _get_owned_dish(session, dish_id, current_user.id)
per_100g, total_weight = _compute_nutrition_per_100g(session, dish_in.ingredients, current_user.id)
product = session.exec(select(Product).where(Product.barcode == dish.product_barcode)).first()
product.name = dish_in.name
for field in NUTRIENT_FIELDS:
setattr(product, field, per_100g[field])
session.add(product)
dish.name = dish_in.name
dish.instructions = dish_in.instructions
dish.total_weight_g = total_weight
session.add(dish)
_replace_ingredients(session, dish, dish_in.ingredients)
session.commit()
session.refresh(dish)
return _to_dish_read(session, dish, current_user.id)
@router.delete("/{dish_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_dish(dish_id: int, current_user: CurrentUserDep, session: SessionDep):
dish = _get_owned_dish(session, dish_id, current_user.id)
ingredient_rows = session.exec(select(DishIngredient).where(DishIngredient.dish_id == dish.id)).all()
for row in ingredient_rows:
session.delete(row)
share_rows = session.exec(select(DishShare).where(DishShare.dish_id == dish.id)).all()
for row in share_rows:
session.delete(row)
product = session.exec(select(Product).where(Product.barcode == dish.product_barcode)).first()
session.delete(dish)
if product:
session.delete(product)
session.commit()
@router.get("/{dish_id}/shares", response_model=list[DishShareRead])
def list_shares(dish_id: int, current_user: CurrentUserDep, session: SessionDep):
dish = _get_owned_dish(session, dish_id, current_user.id)
shares = session.exec(select(DishShare).where(DishShare.dish_id == dish.id)).all()
users = {user.id: user for user in session.exec(select(User)).all()}
return [DishShareRead(username=users[s.shared_with_user_id].username) for s in shares if s.shared_with_user_id in users]
@router.post("/{dish_id}/shares", response_model=list[DishShareRead], status_code=status.HTTP_201_CREATED)
def add_share(dish_id: int, share_in: DishShareCreate, current_user: CurrentUserDep, session: SessionDep):
dish = _get_owned_dish(session, dish_id, current_user.id)
target = session.exec(select(User).where(User.username == share_in.username)).first()
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nutzer nicht gefunden")
if target.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Kann nicht mit sich selbst geteilt werden")
existing = session.exec(
select(DishShare).where(DishShare.dish_id == dish.id, DishShare.shared_with_user_id == target.id)
).first()
if existing is None:
session.add(DishShare(dish_id=dish.id, shared_with_user_id=target.id))
session.commit()
return list_shares(dish_id, current_user, session)
@router.delete("/{dish_id}/shares/{username}", status_code=status.HTTP_204_NO_CONTENT)
def remove_share(dish_id: int, username: str, current_user: CurrentUserDep, session: SessionDep):
dish = _get_owned_dish(session, dish_id, current_user.id)
target = session.exec(select(User).where(User.username == username)).first()
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nutzer nicht gefunden")
row = session.exec(
select(DishShare).where(DishShare.dish_id == dish.id, DishShare.shared_with_user_id == target.id)
).first()
if row:
session.delete(row)
session.commit()
+176
View File
@@ -0,0 +1,176 @@
from datetime import date as date_type
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Query, status
from sqlmodel import Session, select
from app.access import product_barcode_accessible
from app.deps import CurrentUserDep, SessionDep
from app.models import FoodLog, Product
from app.schemas import (
DailyHistoryEntry,
DailyLogResponse,
DailyTotals,
HistoryResponse,
LogCreate,
LogRead,
LogUpdate,
)
router = APIRouter(prefix="/api/logs", tags=["logs"])
def _day_bounds(day: date_type) -> tuple[datetime, datetime]:
start = datetime(day.year, day.month, day.day, tzinfo=timezone.utc)
return start, start + timedelta(days=1)
def _totals_for_logs(logs: list[FoodLog]) -> DailyTotals:
return DailyTotals(
calories=sum(log.calories for log in logs),
carbs=sum(log.carbs for log in logs),
protein=sum(log.protein for log in logs),
fat=sum(log.fat for log in logs),
sugar=sum(log.sugar for log in logs),
fiber=sum(log.fiber for log in logs),
saturated_fat=sum(log.saturated_fat for log in logs),
salt=sum(log.salt for log in logs),
)
def _day_response(session: Session, user_id: int, day: date_type) -> DailyLogResponse:
start, end = _day_bounds(day)
logs = session.exec(
select(FoodLog)
.where(FoodLog.user_id == user_id)
.where(FoodLog.timestamp >= start)
.where(FoodLog.timestamp < end)
.order_by(FoodLog.timestamp)
).all()
return DailyLogResponse(logs=logs, totals=_totals_for_logs(logs))
@router.post("", response_model=LogRead, status_code=status.HTTP_201_CREATED)
def create_log(log_in: LogCreate, current_user: CurrentUserDep, session: SessionDep):
product = None
if log_in.barcode:
product = session.exec(select(Product).where(Product.barcode == log_in.barcode)).first()
elif log_in.name:
product = session.exec(select(Product).where(Product.name == log_in.name)).first()
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="barcode oder name erforderlich")
if product is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Produkt nicht gefunden. Zuerst per GET /api/products/{barcode} suchen oder per POST /api/products anlegen.",
)
if not product_barcode_accessible(session, product.barcode, current_user.id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Produkt nicht gefunden. Zuerst per GET /api/products/{barcode} suchen oder per POST /api/products anlegen.",
)
factor = log_in.amount_g / 100.0
log = FoodLog(
user_id=current_user.id,
barcode=product.barcode,
name=product.name,
meal_type=log_in.meal_type,
amount_g=log_in.amount_g,
calories=product.calories * factor,
carbs=product.carbs * factor,
protein=product.protein * factor,
fat=product.fat * factor,
sugar=product.sugar * factor,
fiber=product.fiber * factor,
saturated_fat=product.saturated_fat * factor,
salt=product.salt * factor,
)
session.add(log)
session.commit()
session.refresh(log)
return log
def _get_owned_log(session: Session, log_id: int, user_id: int) -> FoodLog:
log = session.get(FoodLog, log_id)
if log is None or log.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Eintrag nicht gefunden")
return log
@router.put("/{log_id}", response_model=LogRead)
def update_log(log_id: int, log_in: LogUpdate, current_user: CurrentUserDep, session: SessionDep):
log = _get_owned_log(session, log_id, current_user.id)
if log_in.amount_g is not None and log_in.amount_g != log.amount_g:
factor = log_in.amount_g / log.amount_g
log.calories *= factor
log.carbs *= factor
log.protein *= factor
log.fat *= factor
log.sugar *= factor
log.fiber *= factor
log.saturated_fat *= factor
log.salt *= factor
log.amount_g = log_in.amount_g
if log_in.meal_type is not None:
log.meal_type = log_in.meal_type
session.add(log)
session.commit()
session.refresh(log)
return log
@router.delete("/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_log(log_id: int, current_user: CurrentUserDep, session: SessionDep):
log = _get_owned_log(session, log_id, current_user.id)
session.delete(log)
session.commit()
@router.get("/today", response_model=DailyLogResponse)
def get_today_logs(current_user: CurrentUserDep, session: SessionDep):
today = datetime.now(timezone.utc).date()
return _day_response(session, current_user.id, today)
@router.get("/history", response_model=HistoryResponse)
def get_history(
current_user: CurrentUserDep,
session: SessionDep,
days: int = Query(default=30, ge=1, le=365),
):
today = datetime.now(timezone.utc).date()
range_start_day = today - timedelta(days=days - 1)
range_start, _ = _day_bounds(range_start_day)
_, range_end = _day_bounds(today)
logs = session.exec(
select(FoodLog)
.where(FoodLog.user_id == current_user.id)
.where(FoodLog.timestamp >= range_start)
.where(FoodLog.timestamp < range_end)
).all()
logs_by_day: dict[date_type, list[FoodLog]] = {}
for log in logs:
logs_by_day.setdefault(log.timestamp.date(), []).append(log)
result_days = [
DailyHistoryEntry(
date=range_start_day + timedelta(days=offset),
totals=_totals_for_logs(logs_by_day.get(range_start_day + timedelta(days=offset), [])),
)
for offset in range(days)
]
return HistoryResponse(days=result_days)
@router.get("/day/{day}", response_model=DailyLogResponse)
def get_day_logs(day: date_type, current_user: CurrentUserDep, session: SessionDep):
return _day_response(session, current_user.id, day)
+137
View File
@@ -0,0 +1,137 @@
from uuid import uuid4
import httpx
from fastapi import APIRouter, HTTPException, Query, status
from sqlmodel import select
from app.access import product_barcode_accessible
from app.deps import CurrentUserDep, SessionDep
from app.models import FoodLog, Product
from app.schemas import ProductCreate, ProductRead, ProductSearchResult
router = APIRouter(prefix="/api/products", tags=["products"])
OFF_API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
def _extract_macros(nutriments: dict) -> dict:
calories = nutriments.get("energy-kcal_100g")
if calories is None:
energy_kj = nutriments.get("energy_100g")
calories = energy_kj / 4.184 if energy_kj is not None else 0.0
return {
"calories": calories,
"carbs": nutriments.get("carbohydrates_100g", 0.0) or 0.0,
"protein": nutriments.get("proteins_100g", 0.0) or 0.0,
"fat": nutriments.get("fat_100g", 0.0) or 0.0,
"sugar": nutriments.get("sugars_100g", 0.0) or 0.0,
"fiber": nutriments.get("fiber_100g", 0.0) or 0.0,
"saturated_fat": nutriments.get("saturated-fat_100g", 0.0) or 0.0,
"salt": nutriments.get("salt_100g", 0.0) or 0.0,
}
async def _fetch_from_open_food_facts(barcode: str) -> Product | None:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(OFF_API_URL.format(barcode=barcode))
except httpx.RequestError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Open Food Facts ist gerade nicht erreichbar",
)
if response.status_code != 200:
return None
data = response.json()
if data.get("status") != 1:
return None
product_data = data.get("product", {})
macros = _extract_macros(product_data.get("nutriments", {}))
name = product_data.get("product_name") or product_data.get("generic_name") or "Unbekanntes Produkt"
return Product(barcode=barcode, name=name, **macros)
@router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
def create_product(product_in: ProductCreate, current_user: CurrentUserDep, session: SessionDep):
barcode = product_in.barcode or f"manual-{uuid4().hex[:12]}"
existing = session.exec(select(Product).where(Product.barcode == barcode)).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Barcode existiert bereits")
product = Product(**{**product_in.model_dump(), "barcode": barcode})
session.add(product)
session.commit()
session.refresh(product)
return product
@router.get("/search", response_model=list[ProductSearchResult])
def search_products(
current_user: CurrentUserDep,
session: SessionDep,
q: str = Query(default=""),
limit: int = Query(default=20, ge=1, le=50),
):
query = select(Product)
if q:
query = query.where(Product.name.ilike(f"%{q}%"))
products = [
product
for product in session.exec(query).all()
if product_barcode_accessible(session, product.barcode, current_user.id)
]
recent_logs = session.exec(
select(FoodLog)
.where(FoodLog.user_id == current_user.id)
.order_by(FoodLog.timestamp.desc())
.limit(500)
).all()
last_used: dict[str, FoodLog] = {}
for log in recent_logs:
if log.barcode and log.barcode not in last_used:
last_used[log.barcode] = log
def sort_key(product: Product):
log = last_used.get(product.barcode)
if log:
return (0, -log.timestamp.timestamp())
return (1, product.name.lower())
products.sort(key=sort_key)
results = []
for product in products[:limit]:
log = last_used.get(product.barcode)
results.append(
ProductSearchResult(
**product.model_dump(),
last_amount_g=log.amount_g if log else None,
is_dish=product.barcode.startswith("dish-"),
)
)
return results
@router.get("/{barcode}", response_model=ProductRead)
async def get_product(barcode: str, current_user: CurrentUserDep, session: SessionDep):
product = session.exec(select(Product).where(Product.barcode == barcode)).first()
if product:
if not product_barcode_accessible(session, barcode, current_user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produkt nicht gefunden")
return product
product = await _fetch_from_open_food_facts(barcode)
if product is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produkt nicht gefunden")
session.add(product)
session.commit()
session.refresh(product)
return product
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter
from sqlmodel import select
from app.deps import CurrentUserDep, SessionDep
from app.models import User
from app.schemas import GoalsUpdate, UserRead, UserSummary
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("", response_model=list[UserSummary])
def list_users(current_user: CurrentUserDep, session: SessionDep):
users = session.exec(select(User).where(User.id != current_user.id).order_by(User.username)).all()
return [UserSummary(username=user.username) for user in users]
@router.get("/me", response_model=UserRead)
def read_current_user(current_user: CurrentUserDep):
return current_user
@router.put("/me/goals", response_model=UserRead)
def update_goals(goals: GoalsUpdate, current_user: CurrentUserDep, session: SessionDep):
current_user.calorie_goal = goals.calorie_goal
current_user.protein_goal = goals.protein_goal
current_user.carbs_goal = goals.carbs_goal
current_user.fat_goal = goals.fat_goal
session.add(current_user)
session.commit()
session.refresh(current_user)
return current_user