Files
xenia-pos-local/local_backend/routers/waiters.py
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

291 lines
12 KiB
Python

import os
import uuid
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.user import User, AssistantAssignment, WaiterZone
from models.shift import WaiterShift
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest, PermissionToggle
from routers.deps import require_staff_manager, get_current_user
from services.chat_service import add_user_to_system_group
from roles import ALL_ROLES, VALID_ROLES, get_default_permissions
router = APIRouter()
AVATAR_DIR = "/app/data/avatars"
# Permission fields that can be toggled via the API
PERMISSION_FIELDS = {
"perm_access_dashboard",
"perm_access_waiter_app",
"perm_access_kds",
"perm_cancel_orders",
"perm_apply_discounts",
"perm_modify_prices",
"perm_open_orders",
"perm_close_orders",
"perm_view_reports",
"perm_manage_staff",
"perm_manage_tables",
"perm_manage_menu",
"perm_manage_settings",
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _user_or_404(user_id: int, db: Session) -> User:
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="Staff member not found")
return u
def _guard_superadmin_target(target: User, acting_user: User):
"""Prevent any modification of a superadmin by a non-superadmin."""
if target.role == "superadmin" and acting_user.role != "superadmin":
raise HTTPException(status_code=403, detail="Cannot modify a superadmin account")
def _apply_default_permissions(user: User, role: str):
"""Apply the default permission set for a role onto a User object."""
for field, value in get_default_permissions(role).items():
setattr(user, field, value)
# ── Metadata ──────────────────────────────────────────────────────────────────
@router.get("/roles")
def list_roles():
"""Return the full ordered list of available roles."""
return ALL_ROLES
# ── CRUD ──────────────────────────────────────────────────────────────────────
@router.get("/on-shift", response_model=List[UserOut])
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Staff with an active (not-ended) shift. Accessible to all staff."""
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
return db.query(User).filter(User.id.in_(waiter_ids), User.perm_access_waiter_app == True, User.is_active == True).all()
@router.get("/", response_model=List[UserOut])
def list_staff(db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
"""List all staff members regardless of role."""
return db.query(User).order_by(User.created_at).all()
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_staff(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
if body.role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"Invalid role. Valid roles: {sorted(VALID_ROLES)}")
if body.role == "superadmin" and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Only a superadmin can create another superadmin")
if db.query(User).filter(User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
new_user = User(
username=body.username,
pin_hash=pin_hash,
role=body.role,
is_active=body.is_active,
full_name=body.full_name,
nickname=body.nickname,
mobile_phone=body.mobile_phone,
email=body.email,
note=body.note,
hourly_rate=body.hourly_rate,
)
# Apply role defaults (caller can override via the body fields)
_apply_default_permissions(new_user, body.role)
# Allow explicit overrides from the request body for any perm field that was set
for field in PERMISSION_FIELDS:
val = getattr(body, field, None)
if val is not None:
setattr(new_user, field, val)
db.add(new_user)
db.commit()
db.refresh(new_user)
# Give waiter-app users access to all zones by default
if new_user.perm_access_waiter_app:
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
db.commit()
add_user_to_system_group(db, new_user.id)
return new_user
@router.put("/{waiter_id}", response_model=UserOut)
def update_staff(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
if body.role is not None and body.role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"Invalid role. Valid roles: {sorted(VALID_ROLES)}")
if body.role == "superadmin" and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Only a superadmin can assign the superadmin role")
for field, value in body.model_dump(exclude_none=True).items():
setattr(target, field, value)
db.commit()
db.refresh(target)
return target
@router.put("/{waiter_id}/reset-pin")
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
target.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
db.commit()
return {"status": "pin reset"}
@router.put("/{waiter_id}/block")
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
target.is_active = not target.is_active
db.commit()
return {"is_active": target.is_active}
@router.put("/{waiter_id}/permission")
def toggle_permission(
waiter_id: int,
body: PermissionToggle,
db: Session = Depends(get_db),
user: User = Depends(require_staff_manager),
):
"""Toggle a single permission flag for a staff member."""
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
if body.permission not in PERMISSION_FIELDS:
raise HTTPException(status_code=400, detail=f"Unknown permission: {body.permission}")
# Superadmins always have all permissions — we store the value but enforce it at auth time
setattr(target, body.permission, body.value)
db.commit()
return {"permission": body.permission, "value": body.value}
@router.put("/{waiter_id}/reset-permissions")
def reset_permissions(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
"""Reset a staff member's permissions to their role's defaults."""
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
_apply_default_permissions(target, target.role)
db.commit()
db.refresh(target)
return target
@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_staff(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
db.delete(target)
db.commit()
# ── Avatar upload / delete ───────────────────────────────────────────────────
@router.post("/{waiter_id}/avatar", response_model=UserOut)
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
if target.avatar_url:
old_path = os.path.join(AVATAR_DIR, os.path.basename(target.avatar_url))
if os.path.exists(old_path):
os.remove(old_path)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"staff_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
dest = os.path.join(AVATAR_DIR, filename)
os.makedirs(AVATAR_DIR, exist_ok=True)
content = await file.read()
with open(dest, "wb") as f:
f.write(content)
target.avatar_url = f"/static/avatars/{filename}"
db.commit()
db.refresh(target)
return target
@router.delete("/{waiter_id}/avatar", response_model=UserOut)
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
if target.avatar_url:
old_path = os.path.join(AVATAR_DIR, os.path.basename(target.avatar_url))
if os.path.exists(old_path):
os.remove(old_path)
target.avatar_url = None
db.commit()
db.refresh(target)
return target
# ── Zone assignments ──────────────────────────────────────────────────────────
@router.put("/{waiter_id}/zones")
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
"""Replace all zone assignments for a staff member atomically.
- all_zones=True → single NULL group_id row (sees everything)
- group_ids=[1,2] → rows for groups 1 and 2 only
- group_ids=[] → no rows at all (sees nothing)
"""
_user_or_404(waiter_id, db)
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
if body.all_zones:
db.add(WaiterZone(waiter_id=waiter_id, group_id=None))
elif body.group_ids:
for gid in body.group_ids:
db.add(WaiterZone(waiter_id=waiter_id, group_id=gid))
db.commit()
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).all()
return {"waiter_id": waiter_id, "zones": [{"id": z.id, "group_id": z.group_id} for z in zones]}
# ── Assistant assignments (kept for backwards compat) ─────────────────────────
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
existing = db.query(AssistantAssignment).filter(
AssistantAssignment.primary_waiter_id == waiter_id,
AssistantAssignment.assistant_waiter_id == assistant_id,
).first()
if existing:
raise HTTPException(status_code=400, detail="Assignment already exists")
assignment = AssistantAssignment(primary_waiter_id=waiter_id, assistant_waiter_id=assistant_id)
db.add(assignment)
db.commit()
db.refresh(assignment)
return assignment
@router.delete("/{waiter_id}/assistant", status_code=status.HTTP_204_NO_CONTENT)
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
assignment = db.query(AssistantAssignment).filter(
AssistantAssignment.primary_waiter_id == waiter_id
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
db.delete(assignment)
db.commit()