chore: initial project setup with backend, frontend, and infrastructure
Some checks failed
CI / backend (push) Failing after 31s
CI / frontend (push) Successful in 40s
CI / docker (push) Has been skipped

Add complete NexaPantry application structure including:
- Docker Compose configuration with PostgreSQL, Redis, FastAPI backend, worker, frontend and Caddy
- Environment configuration template with database, auth, and service settings
- GitHub Actions CI workflow for backend/frontend linting, testing, auditing and Docker builds
- AGPL-3.0 license and comprehensive README with setup, development, and security documentation
- Backend
This commit is contained in:
2026-06-04 10:26:38 +02:00
commit 3792ca55e7
74 changed files with 13417 additions and 0 deletions

19
backend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN groupadd -r app && useradd -r -g app app \
&& apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml /app/
RUN pip install --upgrade pip && pip install .
COPY app /app/app
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]

53
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,53 @@
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security import decode_session_token
from app.db.session import get_db
from app.models.entities import HomeMembership, InstanceRole, User
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
token = request.cookies.get("np_session")
user_id = decode_session_token(token) if token else None
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user = db.get(User, user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return user
def current_admin(user: User = Depends(current_user)) -> User:
if user.instance_role != InstanceRole.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
return user
def require_home_member(home_id: str, db: Session, user: User) -> HomeMembership:
membership = db.scalar(
select(HomeMembership).where(
HomeMembership.home_id == home_id,
HomeMembership.user_id == user.id,
)
)
if not membership and user.instance_role != InstanceRole.ADMIN:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Home not found")
if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Home membership required")
return membership
def require_home_write(home_id: str, db: Session, user: User) -> HomeMembership:
membership = require_home_member(home_id, db, user)
if membership.role == "read_only" and user.instance_role != InstanceRole.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Read-only home")
return membership
def require_home_owner(home_id: str, db: Session, user: User) -> HomeMembership:
membership = require_home_member(home_id, db, user)
if membership.role != "home_owner" and user.instance_role != InstanceRole.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Home owner required")
return membership

14
backend/app/api/router.py Normal file
View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.api.routes import admin, auth, homes, notifications, products, recipes, setup, shopping
api_router = APIRouter()
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])
api_router.include_router(homes.router, prefix="/homes", tags=["homes"])
api_router.include_router(products.router, prefix="/homes/{home_id}/products", tags=["products"])
api_router.include_router(shopping.router, prefix="/homes/{home_id}/shopping", tags=["shopping"])
api_router.include_router(notifications.router, prefix="/notifications", tags=["notifications"])
api_router.include_router(recipes.router, prefix="/homes/{home_id}/recipes", tags=["recipes"])

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,143 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import current_admin
from app.api.routes.auth import create_reset_token, send_invitation
from app.db.session import get_db
from app.models.entities import AppSetting, AuditLog, Home, Product, User
from app.schemas.common import (
MailSettingsIn,
MailSettingsOut,
Message,
TestMailIn,
UserCreate,
UserOut,
UserUpdate,
)
from app.services.audit import audit
from app.services.mail import (
get_mail_settings,
send_mail,
serialize_mail_settings,
update_mail_settings,
)
router = APIRouter(dependencies=[Depends(current_admin)])
@router.get("/dashboard")
def dashboard(db: Session = Depends(get_db)) -> dict:
return {
"users": db.scalar(select(func.count(User.id))),
"homes": db.scalar(select(func.count(Home.id))),
"products": db.scalar(select(func.count(Product.id))),
"active_users": db.scalar(select(func.count(User.id)).where(User.is_active)),
}
@router.get("/users", response_model=list[UserOut])
def list_users(db: Session = Depends(get_db)) -> list[User]:
return list(db.scalars(select(User).order_by(User.created_at.desc())).all())
@router.post("/users", response_model=UserOut, status_code=201)
def create_user(payload: UserCreate, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> User:
if db.scalar(select(User).where(User.email == str(payload.email).lower())):
raise HTTPException(status_code=409, detail="E-mail already exists")
user = User(email=str(payload.email).lower(), name=payload.name, instance_role=payload.role, is_active=True)
db.add(user)
db.flush()
if payload.send_invite:
send_invitation(db, user)
audit(db, admin, "admin.user.create", "user", user.id)
db.commit()
db.refresh(user)
return user
@router.patch("/users/{user_id}", response_model=UserOut)
def update_user(user_id: str, payload: UserUpdate, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> User:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(user, key, value)
audit(db, admin, "admin.user.update", "user", user.id)
db.commit()
db.refresh(user)
return user
@router.delete("/users/{user_id}", response_model=Message)
def delete_user(user_id: str, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> Message:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
audit(db, admin, "admin.user.delete", "user", user_id)
db.commit()
return Message(message="User deleted")
@router.post("/users/{user_id}/reset-password", response_model=Message)
def reset_password(user_id: str, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> Message:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
token = create_reset_token(db, user)
send_mail(db, user.email, "NexaPantry password reset", f"Reset token: {token}")
audit(db, admin, "admin.user.reset_password", "user", user.id)
db.commit()
return Message(message="Password reset mail sent")
@router.get("/homes")
def homes(db: Session = Depends(get_db)) -> list[dict]:
return [{"id": h.id, "name": h.name, "members": len(h.memberships), "products": len(h.products)} for h in db.scalars(select(Home)).all()]
@router.get("/mail", response_model=MailSettingsOut)
def mail_settings(db: Session = Depends(get_db)) -> MailSettingsOut:
return serialize_mail_settings(get_mail_settings(db))
@router.put("/mail", response_model=MailSettingsOut)
def save_mail_settings(payload: MailSettingsIn, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> MailSettingsOut:
settings = update_mail_settings(db, payload)
audit(db, admin, "admin.mail.update", "mail_settings", "1")
db.commit()
return serialize_mail_settings(settings)
@router.post("/mail/test", response_model=Message)
def test_mail(payload: TestMailIn, db: Session = Depends(get_db)) -> Message:
send_mail(db, str(payload.to), "NexaPantry test mail", "SMTP is configured correctly.")
return Message(message="Test mail sent")
@router.get("/settings")
def get_settings(db: Session = Depends(get_db)) -> dict:
return {s.key: s.value for s in db.scalars(select(AppSetting)).all()}
@router.put("/settings/{key}")
def set_setting(key: str, value: dict, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> dict:
setting = db.get(AppSetting, key) or AppSetting(key=key)
setting.value = value
db.merge(setting)
audit(db, admin, "admin.setting.update", "setting", key)
db.commit()
return setting.value
@router.get("/logs")
def logs(db: Session = Depends(get_db)) -> list[dict]:
rows = db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all()
return [{"created_at": r.created_at, "action": r.action, "target_type": r.target_type, "target_id": r.target_id, "metadata": r.metadata_json} for r in rows]
@router.get("/system")
def system_info() -> dict:
return {"app": "NexaPantry", "version": "0.1.0", "runtime": "FastAPI", "database": "PostgreSQL"}

View File

@@ -0,0 +1,117 @@
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user
from app.core.config import get_settings
from app.core.security import (
create_session_token,
hash_password,
hash_token,
make_csrf_token,
new_token,
verify_password,
)
from app.db.session import get_db
from app.models.entities import (
Home,
HomeMembership,
HomeRole,
InvitationToken,
PasswordResetToken,
User,
)
from app.schemas.common import InviteAccept, LoginRequest, Message, UserOut
from app.services.audit import audit
from app.services.mail import invite_body, send_mail
router = APIRouter()
def set_auth_cookies(response: Response, user_id: str) -> None:
settings = get_settings()
csrf = make_csrf_token()
response.set_cookie("np_session", create_session_token(user_id), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=60 * 60 * 24 * 14)
response.set_cookie("np_csrf", csrf, httponly=False, secure=settings.cookie_secure, samesite="lax", max_age=60 * 60 * 24 * 14)
@router.post("/login", response_model=UserOut)
def login(payload: LoginRequest, response: Response, db: Session = Depends(get_db)) -> User:
user = db.scalar(select(User).where(User.email == str(payload.email).lower()))
if not user or not verify_password(payload.password, user.password_hash) or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
set_auth_cookies(response, user.id)
audit(db, user, "auth.login", "user", user.id)
db.commit()
return user
@router.post("/logout", response_model=Message)
def logout(response: Response) -> Message:
response.delete_cookie("np_session")
response.delete_cookie("np_csrf")
return Message(message="Logged out")
@router.get("/me", response_model=UserOut)
def me(user: User = Depends(current_user)) -> User:
return user
@router.patch("/me", response_model=UserOut)
def update_me(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> User:
for field in ["name", "language", "theme", "timezone", "onboarding_completed"]:
if field in payload:
setattr(user, field, payload[field])
db.commit()
db.refresh(user)
return user
@router.post("/accept-invite", response_model=UserOut)
def accept_invite(payload: InviteAccept, response: Response, db: Session = Depends(get_db)) -> User:
hashed = hash_token(payload.token)
invite = db.scalar(select(InvitationToken).where(InvitationToken.token_hash == hashed))
if not invite or invite.consumed_at or invite.expires_at < datetime.now(UTC):
raise HTTPException(status_code=400, detail="Invalid or expired invitation")
user = db.get(User, invite.user_id)
if not user:
raise HTTPException(status_code=400, detail="Invalid invitation")
user.name = payload.name
user.password_hash = hash_password(payload.password)
user.language = payload.language
user.theme = payload.theme
invite.consumed_at = datetime.now(UTC)
if payload.home_name:
home = Home(name=payload.home_name)
db.add(home)
db.flush()
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
elif payload.join_code:
from app.api.routes.homes import join_home_by_code
join_home_by_code(payload.join_code, user, db)
audit(db, user, "auth.accept_invite", "user", user.id)
db.commit()
set_auth_cookies(response, user.id)
return user
def create_invitation(db: Session, user: User) -> str:
token = new_token()
db.add(InvitationToken(user_id=user.id, token_hash=hash_token(token), expires_at=datetime.now(UTC) + timedelta(days=7)))
return token
def create_reset_token(db: Session, user: User) -> str:
token = new_token()
db.add(PasswordResetToken(user_id=user.id, token_hash=hash_token(token), expires_at=datetime.now(UTC) + timedelta(hours=2)))
return token
def send_invitation(db: Session, user: User) -> None:
token = create_invitation(db, user)
send_mail(db, user.email, "Your NexaPantry invitation", invite_body(token))

View File

@@ -0,0 +1,117 @@
import secrets
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user, require_home_owner
from app.core.security import hash_token
from app.db.session import get_db
from app.models.entities import Home, HomeMembership, HomeRole, User
from app.schemas.common import HomeCreate, HomeOut, HomeSettingsUpdate, JoinCodeOut, Message
from app.services.audit import audit
router = APIRouter()
def home_out(home: Home, role: str | None = None) -> HomeOut:
return HomeOut.model_validate(home).model_copy(update={"role": role})
@router.get("", response_model=list[HomeOut])
def list_homes(user: User = Depends(current_user)) -> list[HomeOut]:
return [home_out(m.home, m.role) for m in user.memberships]
@router.post("", response_model=HomeOut, status_code=201)
def create_home(payload: HomeCreate, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
home = Home(name=payload.name)
db.add(home)
db.flush()
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
audit(db, user, "home.create", "home", home.id)
db.commit()
db.refresh(home)
return home_out(home, HomeRole.OWNER)
def join_home_by_code(join_code: str, user: User, db: Session) -> Home:
home = db.scalar(select(Home).where(Home.join_code_hash == hash_token(join_code.upper())))
if not home or not home.join_code_expires_at or home.join_code_expires_at < datetime.now(UTC):
raise HTTPException(status_code=400, detail="Invalid join code")
exists = db.scalar(select(HomeMembership).where(HomeMembership.home_id == home.id, HomeMembership.user_id == user.id))
if not exists:
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.MEMBER))
return home
@router.post("/join", response_model=HomeOut)
def join(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
home = join_home_by_code(str(payload.get("join_code", "")), user, db)
audit(db, user, "home.join", "home", home.id)
db.commit()
return home_out(home, HomeRole.MEMBER)
@router.patch("/{home_id}", response_model=HomeOut)
def update_home(home_id: str, payload: HomeSettingsUpdate, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
membership = require_home_owner(home_id, db, user)
home = db.get(Home, home_id)
if not home:
raise HTTPException(status_code=404, detail="Home not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(home, key, value)
audit(db, user, "home.update", "home", home.id)
db.commit()
return home_out(home, membership.role)
@router.post("/{home_id}/join-code", response_model=JoinCodeOut)
def create_join_code(home_id: str, payload: dict | None = None, user: User = Depends(current_user), db: Session = Depends(get_db)) -> JoinCodeOut:
require_home_owner(home_id, db, user)
home = db.get(Home, home_id)
if not home:
raise HTTPException(status_code=404, detail="Home not found")
days = int((payload or {}).get("days", 7))
code = f"NX-{secrets.randbelow(900000) + 100000}"
home.join_code_hash = hash_token(code)
home.join_code_expires_at = datetime.now(UTC) + timedelta(days=max(1, min(days, 60)))
audit(db, user, "home.join_code.create", "home", home.id)
db.commit()
return JoinCodeOut(join_code=code, expires_at=home.join_code_expires_at)
@router.delete("/{home_id}/join-code", response_model=Message)
def disable_join_code(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_owner(home_id, db, user)
home = db.get(Home, home_id)
if not home:
raise HTTPException(status_code=404, detail="Home not found")
home.join_code_hash = None
home.join_code_expires_at = None
audit(db, user, "home.join_code.disable", "home", home.id)
db.commit()
return Message(message="Join code disabled")
@router.get("/{home_id}/members")
def members(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
require_home_owner(home_id, db, user)
rows = db.scalars(select(HomeMembership).where(HomeMembership.home_id == home_id)).all()
return [{"id": m.id, "user_id": m.user_id, "name": m.user.name, "email": m.user.email, "role": m.role} for m in rows]
@router.patch("/{home_id}/members/{membership_id}")
def update_member(home_id: str, membership_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
require_home_owner(home_id, db, user)
membership = db.get(HomeMembership, membership_id)
if not membership or membership.home_id != home_id:
raise HTTPException(status_code=404, detail="Member not found")
if payload.get("role") in [HomeRole.OWNER, HomeRole.MEMBER, HomeRole.READ_ONLY]:
membership.role = payload["role"]
if "notification_preferences" in payload:
membership.notification_preferences = payload["notification_preferences"]
audit(db, user, "home.member.update", "membership", membership.id)
db.commit()
return {"message": "Member updated"}

View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user
from app.db.session import get_db
from app.models.entities import Notification, User
from app.services.notifications import mark_read
router = APIRouter()
@router.get("")
def list_notifications(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.scalars(select(Notification).where(Notification.user_id == user.id).order_by(Notification.created_at.desc()).limit(100)).all()
return [{"id": n.id, "title": n.title, "body": n.body, "kind": n.kind, "read_at": n.read_at, "created_at": n.created_at} for n in rows]
@router.post("/{notification_id}/read")
def read(notification_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
notification = db.get(Notification, notification_id)
if not notification or notification.user_id != user.id:
raise HTTPException(status_code=404, detail="Notification not found")
mark_read(db, notification)
db.commit()
return {"message": "Notification read"}

View File

@@ -0,0 +1,78 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user, require_home_member, require_home_write
from app.db.session import get_db
from app.models.entities import Product, ShoppingItem, User
from app.schemas.common import Message, ProductIn, ProductOut
from app.services.audit import audit
from app.services.products import OpenFoodFactsLookup, expiry_status
router = APIRouter()
def serialize(product: Product) -> ProductOut:
return ProductOut.model_validate(product).model_copy(update={"status": expiry_status(product, product.home)})
@router.get("", response_model=list[ProductOut])
def list_products(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[ProductOut]:
require_home_member(home_id, db, user)
rows = db.scalars(select(Product).where(Product.home_id == home_id).order_by(Product.expires_at.nullslast(), Product.name)).all()
return [serialize(product) for product in rows]
@router.post("", response_model=ProductOut, status_code=201)
def create_product(home_id: str, payload: ProductIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ProductOut:
require_home_write(home_id, db, user)
product = Product(home_id=home_id, created_by_id=user.id, **payload.model_dump())
db.add(product)
audit(db, user, "product.create", "product", product.id)
db.commit()
db.refresh(product)
return serialize(product)
@router.patch("/{product_id}", response_model=ProductOut)
def update_product(home_id: str, product_id: str, payload: ProductIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ProductOut:
require_home_write(home_id, db, user)
product = db.get(Product, product_id)
if not product or product.home_id != home_id:
raise HTTPException(status_code=404, detail="Product not found")
for key, value in payload.model_dump().items():
setattr(product, key, value)
audit(db, user, "product.update", "product", product.id)
db.commit()
return serialize(product)
@router.delete("/{product_id}", response_model=Message)
def delete_product(home_id: str, product_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_write(home_id, db, user)
product = db.get(Product, product_id)
if not product or product.home_id != home_id:
raise HTTPException(status_code=404, detail="Product not found")
db.delete(product)
audit(db, user, "product.delete", "product", product_id)
db.commit()
return Message(message="Product deleted")
@router.post("/{product_id}/add-to-shopping", response_model=Message)
def add_to_shopping(home_id: str, product_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_write(home_id, db, user)
product = db.get(Product, product_id)
if not product or product.home_id != home_id:
raise HTTPException(status_code=404, detail="Product not found")
db.add(ShoppingItem(home_id=home_id, product_id=product.id, name=product.name, category=product.category, quantity=max(product.min_quantity - product.quantity, 1), unit=product.unit))
audit(db, user, "shopping.from_product", "product", product.id)
db.commit()
return Message(message="Added to shopping list")
@router.get("/lookup/{barcode}")
async def lookup_barcode(home_id: str, barcode: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
require_home_member(home_id, db, user)
result = await OpenFoodFactsLookup().by_barcode(barcode)
return {"found": bool(result), "product": result}

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user, require_home_member
from app.db.session import get_db
from app.models.entities import Product, User
from app.services.recipes import suggest
router = APIRouter()
@router.get("")
def recipe_suggestions(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
require_home_member(home_id, db, user)
products = list(db.scalars(select(Product).where(Product.home_id == home_id)).all())
return suggest(products, user.language)

View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.db.session import get_db
from app.models.entities import AppSetting, Home, HomeMembership, HomeRole, InstanceRole, User
from app.schemas.common import SetupCreate, SetupStatus, UserOut
from app.services.audit import audit
router = APIRouter()
@router.get("/status", response_model=SetupStatus)
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
admin_exists = db.scalar(select(User).where(User.instance_role == InstanceRole.ADMIN)) is not None
instance = db.get(AppSetting, "instance")
return SetupStatus(needs_setup=not admin_exists, instance=instance.value if instance else None)
@router.post("/complete", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def complete_setup(payload: SetupCreate, db: Session = Depends(get_db)) -> User:
if db.scalar(select(User).where(User.instance_role == InstanceRole.ADMIN)):
raise HTTPException(status_code=409, detail="Instance already initialized")
user = User(
email=str(payload.email).lower(),
name=payload.name,
password_hash=hash_password(payload.password),
instance_role=InstanceRole.ADMIN,
language=payload.language,
theme=payload.theme,
timezone=payload.timezone,
)
home = Home(name=f"{payload.name}'s Home")
db.add_all(
[
user,
home,
AppSetting(
key="instance",
value={
"name": payload.instance_name,
"public_url": payload.public_url,
"language": payload.language,
"theme": payload.theme,
"timezone": payload.timezone,
"registration_enabled": False,
"security": {"login_rate_limit": "10/minute"},
"notifications": {"default_warning_days": 5},
},
),
]
)
db.flush()
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
audit(db, user, "instance.setup", "user", user.id)
db.commit()
db.refresh(user)
return user

View File

@@ -0,0 +1,67 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import current_user, require_home_member, require_home_write
from app.db.session import get_db
from app.models.entities import Product, ShoppingItem, User
from app.schemas.common import Message, ProductIn, ShoppingItemIn, ShoppingItemOut
from app.services.audit import audit
router = APIRouter()
@router.get("", response_model=list[ShoppingItemOut])
def list_items(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[ShoppingItem]:
require_home_member(home_id, db, user)
return list(db.scalars(select(ShoppingItem).where(ShoppingItem.home_id == home_id).order_by(ShoppingItem.checked, ShoppingItem.created_at.desc())).all())
@router.post("", response_model=ShoppingItemOut, status_code=201)
def create_item(home_id: str, payload: ShoppingItemIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ShoppingItem:
require_home_write(home_id, db, user)
item = ShoppingItem(home_id=home_id, **payload.model_dump())
db.add(item)
audit(db, user, "shopping.create", "shopping_item", item.id)
db.commit()
db.refresh(item)
return item
@router.patch("/{item_id}", response_model=ShoppingItemOut)
def update_item(home_id: str, item_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ShoppingItem:
require_home_write(home_id, db, user)
item = db.get(ShoppingItem, item_id)
if not item or item.home_id != home_id:
raise HTTPException(status_code=404, detail="Item not found")
for field in ["name", "category", "quantity", "unit", "checked"]:
if field in payload:
setattr(item, field, payload[field])
db.commit()
return item
@router.post("/{item_id}/move-to-inventory", response_model=Message)
def move_to_inventory(home_id: str, item_id: str, payload: ProductIn | None = None, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_write(home_id, db, user)
item = db.get(ShoppingItem, item_id)
if not item or item.home_id != home_id:
raise HTTPException(status_code=404, detail="Item not found")
data = payload.model_dump() if payload else {"name": item.name, "category": item.category, "quantity": item.quantity, "unit": item.unit}
db.add(Product(home_id=home_id, created_by_id=user.id, **data))
item.checked = True
audit(db, user, "shopping.move_to_inventory", "shopping_item", item.id)
db.commit()
return Message(message="Moved to inventory")
@router.delete("/{item_id}", response_model=Message)
def delete_item(home_id: str, item_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
require_home_write(home_id, db, user)
item = db.get(ShoppingItem, item_id)
if not item or item.home_id != home_id:
raise HTTPException(status_code=404, detail="Item not found")
db.delete(item)
db.commit()
return Message(message="Item deleted")

View File

@@ -0,0 +1,34 @@
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_env: str = "production"
instance_url: str = "http://localhost"
frontend_origin: str = "http://localhost"
database_url: str = "postgresql+psycopg://nexapantry:nexapantry@localhost:5432/nexapantry"
redis_url: str | None = None
jwt_secret_key: str
settings_secret_key: str
cookie_secure: bool = True
cors_origins: list[str] = ["http://localhost"]
log_level: str = "INFO"
default_timezone: str = "Europe/Vienna"
daily_worker_interval_seconds: int = 300
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@field_validator("cors_origins", mode="before")
@classmethod
def parse_origins(cls, value: str | list[str]) -> list[str]:
if isinstance(value, str):
return [origin.strip() for origin in value.split(",") if origin.strip()]
return value
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,66 @@
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from cryptography.fernet import Fernet, InvalidToken
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import get_settings
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
ALGORITHM = "HS256"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str | None) -> bool:
return bool(password_hash) and pwd_context.verify(password, password_hash)
def create_session_token(user_id: str, minutes: int = 60 * 24 * 14) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=minutes)
payload: dict[str, Any] = {"sub": user_id, "exp": expires_at}
return jwt.encode(payload, get_settings().jwt_secret_key, algorithm=ALGORITHM)
def decode_session_token(token: str) -> str | None:
try:
payload = jwt.decode(token, get_settings().jwt_secret_key, algorithms=[ALGORITHM])
return str(payload.get("sub"))
except JWTError:
return None
def new_token() -> str:
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def encrypt_secret(value: str | None) -> str | None:
if not value:
return None
return Fernet(get_settings().settings_secret_key.encode("utf-8")).encrypt(
value.encode("utf-8")
).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
try:
return Fernet(get_settings().settings_secret_key.encode("utf-8")).decrypt(
value.encode("utf-8")
).decode("utf-8")
except InvalidToken:
return None
def make_csrf_token() -> str:
return secrets.token_urlsafe(24)

23
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,23 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import get_settings
class Base(DeclarativeBase):
pass
engine = create_engine(get_settings().database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

76
backend/app/main.py Normal file
View File

@@ -0,0 +1,76 @@
import logging
import time
from collections import defaultdict, deque
from collections.abc import Callable
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.db.session import Base, engine
from app.models import * # noqa: F403
settings = get_settings()
logging.basicConfig(level=settings.log_level)
class SecurityMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable) -> Response:
unsafe = request.method in {"POST", "PUT", "PATCH", "DELETE"}
if unsafe and request.url.path.startswith("/api/"):
csrf_cookie = request.cookies.get("np_csrf")
csrf_header = request.headers.get("x-csrf-token")
exempt = request.url.path in {"/api/auth/login", "/api/setup/complete", "/api/auth/accept-invite"}
if not exempt and csrf_cookie != csrf_header:
return Response("CSRF validation failed", status_code=403)
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "camera=(self), geolocation=(), microphone=()")
return response
class RateLimitMiddleware(BaseHTTPMiddleware):
buckets: dict[str, deque[float]] = defaultdict(deque)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
limited_paths = ("/api/auth/login", "/api/auth/accept-invite", "/api/admin/users")
if request.url.path.startswith(limited_paths):
key = f"{request.client.host if request.client else 'unknown'}:{request.url.path}"
now = time.time()
bucket = self.buckets[key]
while bucket and now - bucket[0] > 60:
bucket.popleft()
if len(bucket) >= 10:
return Response("Too many requests", status_code=429)
bucket.append(now)
return await call_next(request)
app = FastAPI(title="NexaPantry API", version="0.1.0")
app.add_middleware(SecurityMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-CSRF-Token"],
)
@app.on_event("startup")
def on_startup() -> None:
Base.metadata.create_all(bind=engine)
@app.get("/healthz")
def healthz() -> dict:
return {"status": "ok"}
app.include_router(api_router, prefix="/api")

View File

@@ -0,0 +1,28 @@
from app.models.entities import (
AppSetting,
AuditLog,
Home,
HomeMembership,
InvitationToken,
MailSetting,
Notification,
PasswordResetToken,
Product,
ShoppingItem,
User,
)
__all__ = [
"AppSetting",
"AuditLog",
"Home",
"HomeMembership",
"InvitationToken",
"MailSetting",
"Notification",
"PasswordResetToken",
"Product",
"ShoppingItem",
"User",
]

View File

@@ -0,0 +1,187 @@
from datetime import UTC, datetime
from enum import StrEnum
from uuid import uuid4
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
def now_utc() -> datetime:
return datetime.now(UTC)
class InstanceRole(StrEnum):
ADMIN = "instance_admin"
USER = "user"
class HomeRole(StrEnum):
OWNER = "home_owner"
MEMBER = "home_member"
READ_ONLY = "read_only"
class Theme(StrEnum):
LIGHT = "light"
DARK = "dark"
SYSTEM = "system"
class Language(StrEnum):
DE = "de"
EN = "en"
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
name: Mapped[str] = mapped_column(String(160))
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
instance_role: Mapped[str] = mapped_column(String(40), default=InstanceRole.USER)
language: Mapped[str] = mapped_column(String(8), default=Language.DE)
theme: Mapped[str] = mapped_column(String(16), default=Theme.SYSTEM)
timezone: Mapped[str] = mapped_column(String(80), default="Europe/Vienna")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
onboarding_completed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
memberships: Mapped[list["HomeMembership"]] = relationship(back_populates="user", cascade="all, delete")
class Home(Base):
__tablename__ = "homes"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
name: Mapped[str] = mapped_column(String(160))
join_code_hash: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
join_code_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
expiry_warning_days: Mapped[int] = mapped_column(Integer, default=5)
daily_summary_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
daily_summary_time: Mapped[str] = mapped_column(String(5), default="08:00")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
memberships: Mapped[list["HomeMembership"]] = relationship(back_populates="home", cascade="all, delete")
products: Mapped[list["Product"]] = relationship(back_populates="home", cascade="all, delete")
class HomeMembership(Base):
__tablename__ = "home_memberships"
__table_args__ = (UniqueConstraint("home_id", "user_id", name="uq_home_user"),)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
home_id: Mapped[str] = mapped_column(ForeignKey("homes.id", ondelete="CASCADE"))
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
role: Mapped[str] = mapped_column(String(40), default=HomeRole.MEMBER)
notification_preferences: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
home: Mapped[Home] = relationship(back_populates="memberships")
user: Mapped[User] = relationship(back_populates="memberships")
class Product(Base):
__tablename__ = "products"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
home_id: Mapped[str] = mapped_column(ForeignKey("homes.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(220))
barcode: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
brand: Mapped[str | None] = mapped_column(String(160), nullable=True)
category: Mapped[str] = mapped_column(String(120), default="Other")
location: Mapped[str] = mapped_column(String(120), default="Pantry")
quantity: Mapped[float] = mapped_column(default=1)
unit: Mapped[str] = mapped_column(String(32), default="pcs")
expires_at: Mapped[datetime | None] = mapped_column(Date, nullable=True)
min_quantity: Mapped[float] = mapped_column(default=0)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
image_url: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
home: Mapped[Home] = relationship(back_populates="products")
class ShoppingItem(Base):
__tablename__ = "shopping_items"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
home_id: Mapped[str] = mapped_column(ForeignKey("homes.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(220))
category: Mapped[str] = mapped_column(String(120), default="Other")
quantity: Mapped[float] = mapped_column(default=1)
unit: Mapped[str] = mapped_column(String(32), default="pcs")
checked: Mapped[bool] = mapped_column(Boolean, default=False)
product_id: Mapped[str | None] = mapped_column(ForeignKey("products.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
class AppSetting(Base):
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(120), primary_key=True)
value: Mapped[dict] = mapped_column(JSONB, default=dict)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
class MailSetting(Base):
__tablename__ = "mail_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
smtp_host: Mapped[str | None] = mapped_column(String(220), nullable=True)
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_user: Mapped[str | None] = mapped_column(String(220), nullable=True)
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
use_starttls: Mapped[bool] = mapped_column(Boolean, default=True)
sender_address: Mapped[str | None] = mapped_column(String(320), nullable=True)
sender_name: Mapped[str] = mapped_column(String(160), default="NexaPantry")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
class InvitationToken(Base):
__tablename__ = "invitation_tokens"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
class Notification(Base):
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
home_id: Mapped[str | None] = mapped_column(ForeignKey("homes.id", ondelete="CASCADE"), nullable=True)
title: Mapped[str] = mapped_column(String(220))
body: Mapped[str] = mapped_column(Text)
kind: Mapped[str] = mapped_column(String(80), default="info")
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
action: Mapped[str] = mapped_column(String(160), index=True)
target_type: Mapped[str | None] = mapped_column(String(80), nullable=True)
target_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
metadata_json: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)

View File

@@ -0,0 +1,161 @@
from datetime import date, datetime
from pydantic import BaseModel, EmailStr, Field
class Message(BaseModel):
message: str
class SetupStatus(BaseModel):
needs_setup: bool
instance: dict | None = None
class SetupCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
email: EmailStr
password: str = Field(min_length=12, max_length=256)
language: str
theme: str
public_url: str = Field(min_length=1, max_length=500)
instance_name: str = Field(min_length=1, max_length=160)
timezone: str = Field(min_length=1, max_length=80)
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=256)
class UserOut(BaseModel):
id: str
email: EmailStr
name: str
instance_role: str
language: str
theme: str
timezone: str
is_active: bool
onboarding_completed: bool
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
email: EmailStr
name: str = Field(min_length=1, max_length=160)
role: str = "user"
send_invite: bool = True
class UserUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=160)
language: str | None = None
theme: str | None = None
timezone: str | None = None
instance_role: str | None = None
is_active: bool | None = None
onboarding_completed: bool | None = None
class InviteAccept(BaseModel):
token: str
name: str = Field(min_length=1, max_length=160)
password: str = Field(min_length=12, max_length=256)
language: str
theme: str
home_name: str | None = Field(default=None, max_length=160)
join_code: str | None = Field(default=None, max_length=40)
class HomeOut(BaseModel):
id: str
name: str
expiry_warning_days: int
daily_summary_enabled: bool
daily_summary_time: str
role: str | None = None
model_config = {"from_attributes": True}
class HomeCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
class HomeSettingsUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=160)
expiry_warning_days: int | None = Field(default=None, ge=0, le=60)
daily_summary_enabled: bool | None = None
daily_summary_time: str | None = Field(default=None, pattern=r"^\d{2}:\d{2}$")
class JoinCodeOut(BaseModel):
join_code: str
expires_at: datetime
class ProductIn(BaseModel):
name: str = Field(min_length=1, max_length=220)
barcode: str | None = Field(default=None, max_length=80)
brand: str | None = Field(default=None, max_length=160)
category: str = Field(default="Other", max_length=120)
location: str = Field(default="Pantry", max_length=120)
quantity: float = Field(default=1, ge=0)
unit: str = Field(default="pcs", max_length=32)
expires_at: date | None = None
min_quantity: float = Field(default=0, ge=0)
notes: str | None = Field(default=None, max_length=5000)
image_url: str | None = Field(default=None, max_length=1000)
class ProductOut(ProductIn):
id: str
home_id: str
status: str
model_config = {"from_attributes": True}
class ShoppingItemIn(BaseModel):
name: str = Field(min_length=1, max_length=220)
category: str = Field(default="Other", max_length=120)
quantity: float = Field(default=1, ge=0)
unit: str = Field(default="pcs", max_length=32)
product_id: str | None = None
class ShoppingItemOut(ShoppingItemIn):
id: str
home_id: str
checked: bool
model_config = {"from_attributes": True}
class MailSettingsIn(BaseModel):
smtp_host: str | None = Field(default=None, max_length=220)
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_user: str | None = Field(default=None, max_length=220)
smtp_password: str | None = Field(default=None, max_length=1000)
use_tls: bool = True
use_starttls: bool = True
sender_address: EmailStr | None = None
sender_name: str = Field(default="NexaPantry", max_length=160)
class MailSettingsOut(BaseModel):
smtp_host: str | None
smtp_port: int
smtp_user: str | None
has_password: bool
use_tls: bool
use_starttls: bool
sender_address: EmailStr | None
sender_name: str
class TestMailIn(BaseModel):
to: EmailStr

View File

@@ -0,0 +1,16 @@
from sqlalchemy.orm import Session
from app.models.entities import AuditLog, User
def audit(db: Session, actor: User | None, action: str, target_type: str | None = None, target_id: str | None = None, metadata: dict | None = None) -> None:
db.add(
AuditLog(
actor_user_id=actor.id if actor else None,
action=action,
target_type=target_type,
target_id=target_id,
metadata_json=metadata or {},
)
)

View File

@@ -0,0 +1,70 @@
import smtplib
from email.message import EmailMessage
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import decrypt_secret, encrypt_secret
from app.models.entities import MailSetting
from app.schemas.common import MailSettingsIn, MailSettingsOut
def get_mail_settings(db: Session) -> MailSetting:
settings = db.get(MailSetting, 1)
if not settings:
settings = MailSetting(id=1)
db.add(settings)
db.flush()
return settings
def serialize_mail_settings(settings: MailSetting) -> MailSettingsOut:
return MailSettingsOut(
smtp_host=settings.smtp_host,
smtp_port=settings.smtp_port,
smtp_user=settings.smtp_user,
has_password=bool(settings.smtp_password_encrypted),
use_tls=settings.use_tls,
use_starttls=settings.use_starttls,
sender_address=settings.sender_address,
sender_name=settings.sender_name,
)
def update_mail_settings(db: Session, payload: MailSettingsIn) -> MailSetting:
settings = get_mail_settings(db)
settings.smtp_host = payload.smtp_host
settings.smtp_port = payload.smtp_port
settings.smtp_user = payload.smtp_user
if payload.smtp_password is not None:
settings.smtp_password_encrypted = encrypt_secret(payload.smtp_password)
settings.use_tls = payload.use_tls
settings.use_starttls = payload.use_starttls
settings.sender_address = str(payload.sender_address) if payload.sender_address else None
settings.sender_name = payload.sender_name
return settings
def send_mail(db: Session, to: str, subject: str, body: str) -> None:
settings = get_mail_settings(db)
if not settings.smtp_host or not settings.sender_address:
raise RuntimeError("SMTP is not configured")
message = EmailMessage()
message["From"] = f"{settings.sender_name} <{settings.sender_address}>"
message["To"] = to
message["Subject"] = subject
message.set_content(body)
password = decrypt_secret(settings.smtp_password_encrypted)
client_cls = smtplib.SMTP_SSL if settings.use_tls and not settings.use_starttls else smtplib.SMTP
with client_cls(settings.smtp_host, settings.smtp_port, timeout=20) as smtp:
if settings.use_starttls:
smtp.starttls()
if settings.smtp_user and password:
smtp.login(settings.smtp_user, password)
smtp.send_message(message)
def invite_body(token: str) -> str:
link = f"{get_settings().instance_url.rstrip('/')}/accept-invite?token={token}"
return f"Welcome to NexaPantry.\n\nOpen this invitation link to set your password:\n{link}\n\nThe link expires automatically."

View File

@@ -0,0 +1,50 @@
import logging
from datetime import UTC, date, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import Home, HomeMembership, Notification, Product
from app.services.mail import send_mail
logger = logging.getLogger(__name__)
def create_expiry_notifications(db: Session) -> int:
count = 0
homes = db.scalars(select(Home)).all()
today = date.today()
for home in homes:
deadline = today + timedelta(days=home.expiry_warning_days)
products = db.scalars(
select(Product).where(Product.home_id == home.id, Product.expires_at <= deadline)
).all()
if not products:
continue
memberships = db.scalars(select(HomeMembership).where(HomeMembership.home_id == home.id)).all()
for membership in memberships:
prefs = membership.notification_preferences or {}
if prefs.get("in_app", True):
db.add(
Notification(
user_id=membership.user_id,
home_id=home.id,
title="NexaPantry expiry warning",
body=f"{len(products)} products expire soon in {home.name}.",
kind="expiry",
)
)
count += 1
if prefs.get("email", False):
try:
send_mail(db, membership.user.email, "NexaPantry expiry warning", f"{len(products)} products expire soon in {home.name}.")
except Exception:
logger.exception("Expiry e-mail delivery failed for user %s", membership.user_id)
continue
db.commit()
return count
def mark_read(db: Session, notification: Notification) -> Notification:
notification.read_at = datetime.now(UTC)
return notification

View File

@@ -0,0 +1,51 @@
from datetime import date
from typing import Protocol
import httpx
from sqlalchemy.orm import Session
from app.models.entities import Home, Product
def expiry_status(product: Product, home: Home) -> str:
if not product.expires_at:
return "ok"
today = date.today()
if product.expires_at <= today:
return "expired"
if (product.expires_at - today).days <= home.expiry_warning_days:
return "soon"
return "ok"
class ProductLookup(Protocol):
async def by_barcode(self, barcode: str) -> dict | None:
...
class OpenFoodFactsLookup(ProductLookup):
async def by_barcode(self, barcode: str) -> dict | None:
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
async with httpx.AsyncClient(timeout=8) as client:
response = await client.get(url)
if response.status_code != 200:
return None
data = response.json()
product = data.get("product")
if not product:
return None
return {
"name": product.get("product_name") or product.get("generic_name") or "",
"brand": product.get("brands"),
"category": (product.get("categories_tags") or ["Other"])[0].replace("en:", ""),
"image_url": product.get("image_front_small_url") or product.get("image_url"),
"barcode": barcode,
}
def low_stock_products(db: Session, home_id: str) -> list[Product]:
return [
product
for product in db.query(Product).filter(Product.home_id == home_id).all()
if product.quantity <= product.min_quantity
]

View File

@@ -0,0 +1,44 @@
from app.models.entities import Product
RECIPES = [
{
"id": "tomato-pasta",
"name": {"de": "Tomaten-Pasta", "en": "Tomato pasta"},
"ingredients": ["tomato", "tomate", "pasta", "nudeln", "cheese", "käse"],
"steps": {"de": ["Nudeln kochen", "Tomaten anbraten", "Mit Käse servieren"], "en": ["Cook pasta", "Warm tomatoes", "Serve with cheese"]},
},
{
"id": "omelette",
"name": {"de": "Gemüse-Omelett", "en": "Vegetable omelette"},
"ingredients": ["egg", "ei", "cheese", "käse", "pepper", "paprika", "milk", "milch"],
"steps": {"de": ["Eier verquirlen", "Gemüse anbraten", "Stocken lassen"], "en": ["Whisk eggs", "Saute vegetables", "Let it set"]},
},
{
"id": "rice-bowl",
"name": {"de": "Reis-Bowl", "en": "Rice bowl"},
"ingredients": ["rice", "reis", "beans", "bohnen", "corn", "mais", "yogurt", "joghurt"],
"steps": {"de": ["Reis erhitzen", "Toppings ergänzen", "Mit Sauce servieren"], "en": ["Warm rice", "Add toppings", "Serve with sauce"]},
},
]
def suggest(products: list[Product], language: str) -> list[dict]:
names = " ".join([p.name.lower() for p in products])
expiring = {p.id for p in products if p.expires_at}
suggestions: list[dict] = []
for recipe in RECIPES:
matches = [i for i in recipe["ingredients"] if i in names]
if not matches:
continue
score = len(matches) + min(len(expiring), 3)
suggestions.append(
{
"id": recipe["id"],
"name": recipe["name"].get(language, recipe["name"]["en"]),
"matchedIngredients": sorted(set(matches)),
"score": score,
"steps": recipe["steps"].get(language, recipe["steps"]["en"]),
}
)
return sorted(suggestions, key=lambda item: item["score"], reverse=True)

View File

@@ -0,0 +1,14 @@
from app.core.security import hash_password, hash_token, verify_password
def test_password_hash_roundtrip() -> None:
password_hash = hash_password("a-very-long-password")
assert password_hash != "a-very-long-password"
assert verify_password("a-very-long-password", password_hash)
assert not verify_password("wrong-password", password_hash)
def test_tokens_are_hashed() -> None:
assert hash_token("secret") == hash_token("secret")
assert hash_token("secret") != "secret"

View File

@@ -0,0 +1,31 @@
import logging
import time
from app.core.config import get_settings
from app.db.session import SessionLocal
from app.services.notifications import create_expiry_notifications
logging.basicConfig(level=get_settings().log_level)
logger = logging.getLogger("nexapantry.worker")
def run_once() -> int:
with SessionLocal() as db:
return create_expiry_notifications(db)
def main() -> None:
interval = get_settings().daily_worker_interval_seconds
logger.info("NexaPantry worker started")
while True:
try:
count = run_once()
logger.info("Created %s notification records", count)
except Exception:
logger.exception("Worker cycle failed")
time.sleep(interval)
if __name__ == "__main__":
main()

43
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,43 @@
[project]
name = "nexapantry-backend"
version = "0.1.0"
description = "Self-hosted pantry management backend"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy>=2.0.30",
"psycopg[binary]>=3.2.0",
"pydantic-settings>=2.4.0",
"python-jose[cryptography]>=3.3.0",
"passlib[argon2]>=1.7.4",
"python-multipart>=0.0.9",
"cryptography>=42.0.0",
"httpx>=0.27.0",
"email-validator>=2.2.0",
"redis>=5.0.0",
"tenacity>=8.5.0"
]
[project.optional-dependencies]
dev = [
"pytest>=8.2.0",
"pytest-cov>=5.0.0",
"ruff>=0.5.0",
"mypy>=1.10.0",
"bandit>=1.7.9",
"pip-audit>=2.7.0",
"types-python-jose>=3.3.4",
"types-passlib>=1.7.7"
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "S"]
ignore = ["B008", "E501", "S101", "S105"]
[tool.pytest.ini_options]
testpaths = ["app/tests"]