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()