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
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
*.db
.env
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.venv/
venv/
*.db
.env
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+30
View File
@@ -0,0 +1,30 @@
from sqlmodel import Session, select
from app.models import Dish, DishShare
def dish_for_barcode(session: Session, barcode: str) -> Dish | None:
if not barcode.startswith("dish-"):
return None
return session.exec(select(Dish).where(Dish.product_barcode == barcode)).first()
def user_can_access_dish(session: Session, dish: Dish, user_id: int) -> bool:
if dish.user_id == user_id:
return True
return (
session.exec(
select(DishShare).where(
DishShare.dish_id == dish.id,
DishShare.shared_with_user_id == user_id,
)
).first()
is not None
)
def product_barcode_accessible(session: Session, barcode: str, user_id: int) -> bool:
dish = dish_for_barcode(session, barcode)
if dish is None:
return True
return user_can_access_dish(session, dish, user_id)
View File
+17
View File
@@ -0,0 +1,17 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
secret_key: str
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24
database_url: str = "sqlite:///./kalorientracker.db"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@lru_cache
def get_settings() -> Settings:
return Settings()
+57
View File
@@ -0,0 +1,57 @@
from enum import Enum
from sqlalchemy import inspect, text
from sqlmodel import Session, SQLModel, create_engine
from app.core.config import get_settings
settings = get_settings()
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args)
def _default_sql(column) -> str:
if column.default is None or not getattr(column.default, "is_scalar", False):
return ""
value = column.default.arg
if callable(value):
return ""
if isinstance(value, Enum):
value = value.value
if isinstance(value, str):
return f" DEFAULT '{value.replace(chr(39), chr(39) * 2)}'"
if isinstance(value, bool):
return f" DEFAULT {int(value)}"
return f" DEFAULT {value}"
def _add_missing_columns() -> None:
"""Additive, best-effort schema sync for SQLite: adds columns that exist on the
SQLModel definitions but not yet in the DB, so new fields don't require wiping
the database. Does not handle renames, type changes, or column removal."""
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
with engine.begin() as conn:
for table in SQLModel.metadata.sorted_tables:
if table.name not in existing_tables:
continue
existing_columns = {col["name"] for col in inspector.get_columns(table.name)}
for column in table.columns:
if column.name in existing_columns:
continue
col_type = column.type.compile(dialect=engine.dialect)
conn.execute(
text(f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {col_type}{_default_sql(column)}')
)
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
_add_missing_columns()
def get_session():
with Session(engine) as session:
yield session
+43
View File
@@ -0,0 +1,43 @@
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlmodel import Session, select
from app.core.config import get_settings
from app.database import get_session
from app.models import User
from app.schemas import TokenData
settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
SessionDep = Annotated[Session, Depends(get_session)]
def get_current_user(
session: SessionDep,
token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
username = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = session.exec(select(User).where(User.username == token_data.username)).first()
if user is None:
raise credentials_exception
return user
CurrentUserDep = Annotated[User, Depends(get_current_user)]
+41
View File
@@ -0,0 +1,41 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import create_db_and_tables
from app.routers import auth, dishes, logs, products, users
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(title="KalorienTracker API", lifespan=lifespan)
_frontend_port = os.getenv("FRONTEND_PORT", "8310")
app.add_middleware(
CORSMiddleware,
allow_origins=[
f"http://localhost:{_frontend_port}",
f"http://127.0.0.1:{_frontend_port}",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(products.router)
app.include_router(logs.router)
app.include_router(dishes.router)
@app.get("/api/health")
def health_check():
return {"status": "ok"}
+76
View File
@@ -0,0 +1,76 @@
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel
class MealType(str, Enum):
breakfast = "breakfast"
lunch = "lunch"
dinner = "dinner"
snack = "snack"
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True)
hashed_password: str
calorie_goal: float = 2000
protein_goal: float = 100
carbs_goal: float = 250
fat_goal: float = 70
class Product(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
barcode: str = Field(unique=True, index=True)
name: str
calories: float
carbs: float
protein: float
fat: float
sugar: float = 0.0
fiber: float = 0.0
saturated_fat: float = 0.0
salt: float = 0.0
class FoodLog(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
barcode: Optional[str] = Field(default=None, index=True)
name: str
meal_type: MealType = MealType.snack
amount_g: float
calories: float
carbs: float
protein: float
fat: float
sugar: float = 0.0
fiber: float = 0.0
saturated_fat: float = 0.0
salt: float = 0.0
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Dish(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
product_barcode: str = Field(foreign_key="product.barcode", unique=True, index=True)
name: str
instructions: Optional[str] = None
total_weight_g: float
class DishIngredient(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
dish_id: int = Field(foreign_key="dish.id", index=True)
product_barcode: str = Field(foreign_key="product.barcode")
amount_g: float
class DishShare(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
dish_id: int = Field(foreign_key="dish.id", index=True)
shared_with_user_id: int = Field(foreign_key="user.id", index=True)
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
+174
View File
@@ -0,0 +1,174 @@
from datetime import date as date_type
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models import MealType
class UserCreate(BaseModel):
username: str
password: str
class UserRead(BaseModel):
id: int
username: str
calorie_goal: float
protein_goal: float
carbs_goal: float
fat_goal: float
class UserSummary(BaseModel):
username: str
class GoalsUpdate(BaseModel):
calorie_goal: float
protein_goal: float
carbs_goal: float
fat_goal: float
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
username: str | None = None
class ProductBase(BaseModel):
barcode: str
name: str
calories: float
carbs: float
protein: float
fat: float
sugar: float = 0.0
fiber: float = 0.0
saturated_fat: float = 0.0
salt: float = 0.0
class ProductCreate(ProductBase):
barcode: str | None = None
class ProductRead(ProductBase):
pass
class ProductSearchResult(ProductRead):
last_amount_g: float | None = None
is_dish: bool = False
class LogCreate(BaseModel):
barcode: str | None = None
name: str | None = None
amount_g: float
meal_type: MealType
class LogUpdate(BaseModel):
amount_g: float | None = None
meal_type: MealType | None = None
class LogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
barcode: str | None
name: str
meal_type: MealType
amount_g: float
calories: float
carbs: float
protein: float
fat: float
sugar: float
fiber: float
saturated_fat: float
salt: float
timestamp: datetime
class DailyTotals(BaseModel):
calories: float
carbs: float
protein: float
fat: float
sugar: float
fiber: float
saturated_fat: float
salt: float
class DailyLogResponse(BaseModel):
logs: list[LogRead]
totals: DailyTotals
class DailyHistoryEntry(BaseModel):
date: date_type
totals: DailyTotals
class HistoryResponse(BaseModel):
days: list[DailyHistoryEntry]
class DishIngredientInput(BaseModel):
barcode: str
amount_g: float
class DishIngredientRead(BaseModel):
barcode: str
name: str
amount_g: float
calories: float
carbs: float
protein: float
fat: float
sugar: float
fiber: float
saturated_fat: float
salt: float
class DishCreate(BaseModel):
name: str
instructions: str | None = None
ingredients: list[DishIngredientInput]
class DishRead(BaseModel):
id: int
name: str
instructions: str | None
barcode: str
total_weight_g: float
calories: float
carbs: float
protein: float
fat: float
sugar: float
fiber: float
saturated_fat: float
salt: float
ingredients: list[DishIngredientRead]
is_owner: bool
owner_username: str
class DishShareCreate(BaseModel):
username: str
class DishShareRead(BaseModel):
username: str
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime, timedelta, timezone
from jose import jwt
from passlib.context import CryptContext
from app.core.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
+9
View File
@@ -0,0 +1,9 @@
fastapi
uvicorn[standard]
sqlmodel
python-jose[cryptography]
passlib[bcrypt]
bcrypt==4.0.1
python-multipart
pydantic-settings
httpx