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")
|
||||
|
||||
Reference in New Issue
Block a user