Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.env
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
*.db
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,9 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlmodel
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
bcrypt==4.0.1
|
||||
python-multipart
|
||||
pydantic-settings
|
||||
httpx
|
||||
Reference in New Issue
Block a user