chore: initial project setup with backend, frontend, and infrastructure
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:
53
backend/app/api/deps.py
Normal file
53
backend/app/api/deps.py
Normal 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
14
backend/app/api/router.py
Normal 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"])
|
||||
|
||||
1
backend/app/api/routes/__init__.py
Normal file
1
backend/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
143
backend/app/api/routes/admin.py
Normal file
143
backend/app/api/routes/admin.py
Normal 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"}
|
||||
|
||||
117
backend/app/api/routes/auth.py
Normal file
117
backend/app/api/routes/auth.py
Normal 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))
|
||||
|
||||
117
backend/app/api/routes/homes.py
Normal file
117
backend/app/api/routes/homes.py
Normal 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"}
|
||||
26
backend/app/api/routes/notifications.py
Normal file
26
backend/app/api/routes/notifications.py
Normal 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"}
|
||||
78
backend/app/api/routes/products.py
Normal file
78
backend/app/api/routes/products.py
Normal 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}
|
||||
18
backend/app/api/routes/recipes.py
Normal file
18
backend/app/api/routes/recipes.py
Normal 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)
|
||||
|
||||
60
backend/app/api/routes/setup.py
Normal file
60
backend/app/api/routes/setup.py
Normal 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
|
||||
|
||||
67
backend/app/api/routes/shopping.py
Normal file
67
backend/app/api/routes/shopping.py
Normal 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")
|
||||
|
||||
34
backend/app/core/config.py
Normal file
34
backend/app/core/config.py
Normal 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()
|
||||
|
||||
66
backend/app/core/security.py
Normal file
66
backend/app/core/security.py
Normal 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
23
backend/app/db/session.py
Normal 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
76
backend/app/main.py
Normal 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")
|
||||
|
||||
28
backend/app/models/__init__.py
Normal file
28
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
187
backend/app/models/entities.py
Normal file
187
backend/app/models/entities.py
Normal 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)
|
||||
|
||||
161
backend/app/schemas/common.py
Normal file
161
backend/app/schemas/common.py
Normal 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
|
||||
|
||||
16
backend/app/services/audit.py
Normal file
16
backend/app/services/audit.py
Normal 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 {},
|
||||
)
|
||||
)
|
||||
|
||||
70
backend/app/services/mail.py
Normal file
70
backend/app/services/mail.py
Normal 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."
|
||||
|
||||
50
backend/app/services/notifications.py
Normal file
50
backend/app/services/notifications.py
Normal 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
|
||||
51
backend/app/services/products.py
Normal file
51
backend/app/services/products.py
Normal 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
|
||||
]
|
||||
44
backend/app/services/recipes.py
Normal file
44
backend/app/services/recipes.py
Normal 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)
|
||||
|
||||
14
backend/app/tests/test_security.py
Normal file
14
backend/app/tests/test_security.py
Normal 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"
|
||||
|
||||
31
backend/app/worker/main.py
Normal file
31
backend/app/worker/main.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user