187 lines
7.8 KiB
Python
187 lines
7.8 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
|
|
from routers.deps import require_manager, get_current_user
|
|
|
|
router = APIRouter()
|
|
|
|
AVATAR_DIR = "/app/data/avatars"
|
|
|
|
|
|
# ── 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
|
|
|
|
|
|
# ── 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."""
|
|
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()
|
|
|
|
|
|
@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()
|
|
|
|
|
|
@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)):
|
|
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,
|
|
)
|
|
db.add(new_user)
|
|
db.commit()
|
|
db.refresh(new_user)
|
|
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)
|
|
for field, value in body.model_dump(exclude_none=True).items():
|
|
setattr(waiter, field, value)
|
|
db.commit()
|
|
db.refresh(waiter)
|
|
return waiter
|
|
|
|
|
|
@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()
|
|
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
|
|
db.commit()
|
|
return {"is_active": waiter.is_active}
|
|
|
|
|
|
@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)
|
|
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)
|
|
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 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}"
|
|
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}"
|
|
db.commit()
|
|
db.refresh(waiter)
|
|
return waiter
|
|
|
|
|
|
@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))
|
|
if os.path.exists(old_path):
|
|
os.remove(old_path)
|
|
waiter.avatar_url = None
|
|
db.commit()
|
|
db.refresh(waiter)
|
|
return waiter
|
|
|
|
|
|
# ── 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.
|
|
|
|
- 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
|
|
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_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_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()
|