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>
This commit is contained in:
@@ -8,41 +8,86 @@ 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
|
||||
from routers.deps import require_manager, get_current_user
|
||||
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 _waiter_or_404(waiter_id: int, db: Session) -> User:
|
||||
w = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not w:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
return w
|
||||
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)):
|
||||
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
|
||||
"""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.role == "waiter", User.is_active == True).all()
|
||||
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_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
return db.query(User).filter(User.role == "waiter").all()
|
||||
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_waiter(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
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,
|
||||
@@ -54,101 +99,156 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
||||
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)
|
||||
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
|
||||
db.commit()
|
||||
|
||||
# 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_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
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(waiter, field, value)
|
||||
setattr(target, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
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_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
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_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.is_active = not waiter.is_active
|
||||
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": waiter.is_active}
|
||||
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_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
db.delete(waiter)
|
||||
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_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
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")
|
||||
|
||||
# Delete old avatar file if present
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
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"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
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)
|
||||
|
||||
waiter.avatar_url = f"/static/avatars/{filename}"
|
||||
target.avatar_url = f"/static/avatars/{filename}"
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
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_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
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)
|
||||
waiter.avatar_url = None
|
||||
target.avatar_url = None
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
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_manager)):
|
||||
"""Replace all zone assignments for a waiter atomically.
|
||||
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)
|
||||
"""
|
||||
_waiter_or_404(waiter_id, db)
|
||||
# Wipe existing assignments
|
||||
_user_or_404(waiter_id, db)
|
||||
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
|
||||
|
||||
if body.all_zones:
|
||||
@@ -165,7 +265,7 @@ def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_d
|
||||
# ── 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_manager)):
|
||||
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,
|
||||
@@ -180,7 +280,7 @@ def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(ge
|
||||
|
||||
|
||||
@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_manager)):
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user