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:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View File

@@ -1,17 +1,22 @@
import bcrypt
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from config import settings
from models.user import User
from schemas.auth import LoginRequest, TokenResponse, UpdateMeRequest
from pydantic import BaseModel as _PydanticBase
_logger = logging.getLogger(__name__)
class LoginByIdRequest(_PydanticBase):
waiter_id: int
pin: str
from schemas.user import UserOut
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
from routers.deps import get_current_user, make_token, decode_token, blacklist_token, _make_ghost_superadmin
from routers.recovery import try_recovery_code
router = APIRouter()
@@ -28,7 +33,7 @@ def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
if not setting or setting.value != "none":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No-auth login is not enabled.")
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
if not user or user.role not in ("manager", "sysadmin"):
if not user or not user.perm_access_dashboard:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = make_token(user)
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
@@ -36,6 +41,18 @@ def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)):
# Break-glass master account — checked before DB, never stored as a real user
if (
settings.MASTER_USERNAME
and settings.MASTER_PASSWORD
and body.username == settings.MASTER_USERNAME
and body.password == settings.MASTER_PASSWORD
):
_logger.warning("MASTER LOGIN USED from username=%s", body.username)
ghost = _make_ghost_superadmin()
token = make_token(ghost)
return TokenResponse(access_token=token, user=UserOut.model_validate(ghost))
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
@@ -46,6 +63,10 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
elif body.pin and user.pin_hash:
authenticated = bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode())
# If normal auth failed, check if the submitted value is a recovery code
if not authenticated and body.password:
authenticated = try_recovery_code(body.password, user, db)
if not authenticated:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
@@ -96,9 +117,9 @@ class PublicManagerOut(_PydanticBase):
@router.get("/managers", response_model=list[PublicManagerOut])
def public_manager_list(db: Session = Depends(get_db)):
"""Public endpoint — returns active manager/sysadmin accounts for login screen."""
"""Public endpoint — returns accounts with dashboard access for login screen."""
managers = db.query(User).filter(
User.role.in_(["manager", "sysadmin"]),
User.perm_access_dashboard == True,
User.is_active == True,
).all()
return [PublicManagerOut(id=m.id, username=m.username, full_name=m.full_name) for m in managers]
@@ -121,7 +142,7 @@ class PublicWaiterOut(_BaseModel):
def public_waiter_list(db: Session = Depends(get_db)):
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
from models.shift import WaiterShift
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
on_shift_ids = {
row.waiter_id
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
@@ -172,3 +193,26 @@ def update_me(body: UpdateMeRequest, db: Session = Depends(get_db), user: User =
db.commit()
db.refresh(user)
return user
class WaiterSettingsPayload(_PydanticBase):
settings: str # raw JSON string
@router.get("/me/settings")
def get_my_settings(user: User = Depends(get_current_user)):
"""Return the stored waiter settings JSON blob for the current user."""
return {"settings": user.waiter_settings or "{}"}
@router.put("/me/settings")
def put_my_settings(body: WaiterSettingsPayload, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Replace the stored waiter settings JSON blob."""
import json as _json
try:
_json.loads(body.settings) # validate it's valid JSON
except Exception:
raise HTTPException(status_code=422, detail="settings must be valid JSON")
user.waiter_settings = body.settings
db.commit()
return {"settings": user.waiter_settings}

View File

@@ -3,6 +3,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timezone
from pydantic import BaseModel
from database import get_db
from models.business_day import BusinessDay
@@ -173,6 +174,60 @@ def close_business_day(
return day
class PatchBusinessDayRequest(BaseModel):
closed_at: str # ISO-8601 datetime string
@router.patch("/{day_id}", status_code=status.HTTP_200_OK)
def patch_business_day(
day_id: int,
body: "PatchBusinessDayRequest",
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Edit the close-time of a past (closed) business day.
Constraints:
- Day must already be closed.
- new closed_at must be ≥ the latest order closed_at in this day.
- new closed_at must be ≤ now (UTC).
"""
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
if day.status != "closed":
raise HTTPException(status_code=400, detail="Can only edit a closed business day")
try:
new_closed = datetime.fromisoformat(body.closed_at.replace("Z", "+00:00"))
if new_closed.tzinfo is None:
new_closed = new_closed.replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(status_code=422, detail="Invalid datetime format")
now = datetime.now(timezone.utc)
if new_closed > now:
raise HTTPException(status_code=400, detail="Close time cannot be in the future")
# Must not be earlier than the latest order closed_at in this day
last_order_close = (
db.query(func.max(Order.closed_at))
.filter(Order.business_day_id == day_id, Order.closed_at != None)
.scalar()
)
if last_order_close:
if last_order_close.tzinfo is None:
last_order_close = last_order_close.replace(tzinfo=timezone.utc)
if new_closed < last_order_close:
raise HTTPException(
status_code=400,
detail=f"Close time cannot be earlier than the last order's close time ({_dt(last_order_close)})"
)
day.closed_at = new_closed
db.commit()
return {"id": day.id, "closed_at": _dt(day.closed_at)}
@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_business_day(
day_id: int,

View File

@@ -0,0 +1,404 @@
"""
Chat router — /api/chat
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from database import get_db
from routers.deps import get_current_user
from models.user import User
from models.chat import Conversation, ConversationParticipant, ChatMessage
from schemas.user import UserOut
from schemas.chat import (
ConversationCreate,
ConversationOut,
MessageCreate,
MessageOut,
ParticipantOut,
)
from services.sse_bus import broadcast_sync
router = APIRouter()
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _build_participant_out(p: ConversationParticipant) -> ParticipantOut:
return ParticipantOut(
user_id=p.user_id,
username=p.user.username,
joined_at=p.joined_at,
last_read_at=p.last_read_at,
)
def _build_message_out(msg: ChatMessage) -> MessageOut:
return MessageOut(
id=msg.id,
conversation_id=msg.conversation_id,
sender_id=msg.sender_id,
sender_name=msg.sender.username,
body=msg.body,
sent_at=msg.sent_at,
is_deleted=msg.deleted_at is not None,
)
def _unread_count(db: Session, conv_id: int, user: User) -> int:
"""
Count messages in a conversation that are 'unread' for this user:
- not soft-deleted
- not sent by this user (your own messages don't count)
- sent after user's last_read_at (or ALL messages if last_read_at is None)
"""
participant = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.conversation_id == conv_id,
ConversationParticipant.user_id == user.id,
)
.first()
)
if not participant:
return 0
q = (
db.query(ChatMessage)
.filter(
ChatMessage.conversation_id == conv_id,
ChatMessage.deleted_at == None, # noqa: E711
ChatMessage.sender_id != user.id,
)
)
if participant.last_read_at is not None:
q = q.filter(ChatMessage.sent_at > participant.last_read_at)
return q.count()
def _last_message(db: Session, conv_id: int) -> Optional[ChatMessage]:
return (
db.query(ChatMessage)
.filter(
ChatMessage.conversation_id == conv_id,
ChatMessage.deleted_at == None, # noqa: E711
)
.order_by(ChatMessage.sent_at.desc())
.first()
)
def _get_participant_ids(conv: Conversation) -> List[int]:
return [p.user_id for p in conv.participants]
def _build_conversation_out(db: Session, conv: Conversation, user: User) -> ConversationOut:
last_msg = _last_message(db, conv.id)
return ConversationOut(
id=conv.id,
type=conv.type,
name=conv.name,
is_system=conv.is_system,
created_at=conv.created_at,
participants=[_build_participant_out(p) for p in conv.participants],
last_message=_build_message_out(last_msg) if last_msg else None,
unread_count=_unread_count(db, conv.id, user),
)
def _require_participant(db: Session, conv_id: int, user: User) -> Conversation:
"""Return the conversation if user is a participant, else 403/404."""
conv = db.query(Conversation).filter(Conversation.id == conv_id).first()
if not conv:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
is_participant = any(p.user_id == user.id for p in conv.participants)
if not is_participant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a participant")
return conv
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/conversations", response_model=List[ConversationOut])
def list_conversations(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""List all conversations the current user is a participant of, ordered by last message time desc."""
# Fetch all conversation_ids for this user
participant_rows = (
db.query(ConversationParticipant)
.filter(ConversationParticipant.user_id == user.id)
.all()
)
conv_ids = [p.conversation_id for p in participant_rows]
if not conv_ids:
return []
conversations = (
db.query(Conversation)
.filter(Conversation.id.in_(conv_ids))
.all()
)
# Sort by last message sent_at desc (conversations with no messages go last)
# SQLite returns naive datetimes even for timezone=True columns, so keep everything naive
def _sort_key(c: Conversation):
last = _last_message(db, c.id)
if not last:
return datetime.min
dt = last.sent_at
return dt.replace(tzinfo=None) if dt.tzinfo else dt
conversations.sort(key=_sort_key, reverse=True)
return [_build_conversation_out(db, c, user) for c in conversations]
@router.post("/conversations", response_model=ConversationOut, status_code=status.HTTP_201_CREATED)
def create_conversation(
payload: ConversationCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Create a direct or group conversation.
For direct: returns existing conversation if one already exists between the two users.
Creator is automatically added as a participant.
"""
# Normalise participant list — always include creator
participant_ids = list(set(payload.participant_ids + [user.id]))
if payload.type == "direct":
if len(participant_ids) != 2:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Direct conversations must have exactly 2 participants",
)
other_id = next(pid for pid in participant_ids if pid != user.id)
# Check for an existing non-system direct conversation between these two users
existing_parts_me = (
db.query(ConversationParticipant)
.filter(ConversationParticipant.user_id == user.id)
.all()
)
my_conv_ids = {p.conversation_id for p in existing_parts_me}
existing_parts_other = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.user_id == other_id,
ConversationParticipant.conversation_id.in_(my_conv_ids),
)
.all()
)
shared_conv_ids = {p.conversation_id for p in existing_parts_other}
for conv_id in shared_conv_ids:
conv = db.query(Conversation).filter(
Conversation.id == conv_id,
Conversation.type == "direct",
Conversation.is_system == False, # noqa: E712
).first()
if conv:
return _build_conversation_out(db, conv, user)
elif payload.type == "group":
if not payload.name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Group conversations require a name",
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="type must be 'direct' or 'group'",
)
# Create the conversation
conv = Conversation(
type=payload.type,
name=payload.name if payload.type == "group" else None,
is_system=False,
created_by=user.id,
)
db.add(conv)
db.flush()
now = _utcnow()
for uid in participant_ids:
db.add(ConversationParticipant(
conversation_id=conv.id,
user_id=uid,
joined_at=now,
))
db.commit()
db.refresh(conv)
return _build_conversation_out(db, conv, user)
@router.get("/conversations/{conv_id}", response_model=ConversationOut)
def get_conversation(
conv_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Get single conversation details with participants."""
conv = _require_participant(db, conv_id, user)
return _build_conversation_out(db, conv, user)
@router.get("/conversations/{conv_id}/messages", response_model=List[MessageOut])
def list_messages(
conv_id: int,
offset: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Paginated messages for a conversation, newest first. User must be a participant."""
_require_participant(db, conv_id, user)
messages = (
db.query(ChatMessage)
.filter(ChatMessage.conversation_id == conv_id)
.order_by(ChatMessage.sent_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return [_build_message_out(m) for m in messages]
@router.post("/conversations/{conv_id}/messages", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
def send_message(
conv_id: int,
payload: MessageCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Send a message to a conversation. Broadcasts chat_message SSE event to all participants."""
conv = _require_participant(db, conv_id, user)
if not payload.body.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Message body cannot be empty",
)
msg = ChatMessage(
conversation_id=conv_id,
sender_id=user.id,
body=payload.body,
sent_at=_utcnow(),
)
db.add(msg)
db.commit()
db.refresh(msg)
participant_ids = _get_participant_ids(conv)
# Ensure UTC Z suffix so JS Date() parses correctly regardless of tzinfo
sent_at_str = msg.sent_at.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
broadcast_sync(
"chat_message",
{
"conversation_id": conv_id,
"message_id": msg.id,
"sender_id": user.id,
"sender_name": user.username,
"body": msg.body,
"sent_at": sent_at_str,
},
user_ids=participant_ids,
)
return _build_message_out(msg)
@router.post("/conversations/{conv_id}/read", status_code=status.HTTP_204_NO_CONTENT)
def mark_read(
conv_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Update the current user's last_read_at to now. Broadcasts chat_read SSE event."""
conv = _require_participant(db, conv_id, user)
participant = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.conversation_id == conv_id,
ConversationParticipant.user_id == user.id,
)
.first()
)
now = _utcnow()
participant.last_read_at = now
db.commit()
participant_ids = _get_participant_ids(conv)
broadcast_sync(
"chat_read",
{
"conversation_id": conv_id,
"user_id": user.id,
"read_at": now.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z',
},
user_ids=participant_ids,
)
@router.post("/conversations/{conv_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
def leave_conversation(
conv_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Remove current user from the conversation.
Not allowed on system conversations or direct conversations.
"""
conv = _require_participant(db, conv_id, user)
if conv.is_system:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot leave the system group conversation",
)
if conv.type == "direct":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot leave a direct conversation — just stop using it",
)
participant = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.conversation_id == conv_id,
ConversationParticipant.user_id == user.id,
)
.first()
)
db.delete(participant)
db.commit()
@router.get("/users", response_model=List[UserOut])
def list_chat_users(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""All active users — accessible to any authenticated staff member, for starting chats."""
return db.query(User).filter(User.is_active == True).order_by(User.username).all() # noqa: E712

View File

@@ -16,6 +16,45 @@ _blacklisted_tokens: set[str] = set()
TOKEN_EXPIRY_HOURS = 8
MASTER_USER_ID = 0 # sentinel — never a real DB row
def _make_ghost_superadmin():
"""Synthetic in-memory superadmin — plain namespace, never touches SQLAlchemy."""
from types import SimpleNamespace
return SimpleNamespace(
id=MASTER_USER_ID,
username=settings.MASTER_USERNAME,
full_name="Xenia Support",
role="superadmin",
is_active=True,
pin_hash=None,
password_hash=None,
email=None,
nickname=None,
mobile_phone=None,
note=None,
avatar_url=None,
waiter_settings=None,
hourly_rate=None,
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
zone_assignments=[],
perm_access_dashboard=True,
perm_access_waiter_app=True,
perm_access_kds=True,
perm_cancel_orders=True,
perm_apply_discounts=True,
perm_modify_prices=True,
perm_open_orders=True,
perm_close_orders=True,
perm_view_reports=True,
perm_manage_staff=True,
perm_manage_tables=True,
perm_manage_menu=True,
perm_manage_settings=True,
)
def make_token(user: User) -> str:
payload = {
"sub": str(user.id),
@@ -46,19 +85,69 @@ def get_current_user(
db: Session = Depends(get_db),
) -> User:
payload = decode_token(credentials.credentials)
if int(payload["sub"]) == MASTER_USER_ID and payload.get("role") == "superadmin":
return _make_ghost_superadmin()
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def _has_perm(user: User, perm: str) -> bool:
"""Superadmins always have every permission."""
if user.role == "superadmin":
return True
return bool(getattr(user, perm, False))
def require_manager(user: User = Depends(get_current_user)) -> User:
if user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Manager access required")
"""Allow anyone with dashboard access (formerly role-based manager check)."""
if not _has_perm(user, "perm_access_dashboard"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Dashboard access required")
return user
def require_sysadmin(user: User = Depends(get_current_user)) -> User:
if user.role != "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin access required")
def require_staff_manager(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_manage_staff"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Staff management permission required")
return user
def require_reports(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_view_reports"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reports permission required")
return user
def require_menu_manager(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_manage_menu"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Menu management permission required")
return user
def require_settings_manager(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_manage_settings"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Settings management permission required")
return user
def require_kds(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_access_kds"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Δεν έχετε πρόσβαση στο KDS")
return user
def require_tables_manager(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_manage_tables"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tables management permission required")
return user
def require_superadmin(user: User = Depends(get_current_user)) -> User:
if user.role != "superadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin access required")
return user
# Kept for any callers that still use the old name
require_sysadmin = require_superadmin

View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database import get_db
from routers.deps import require_settings_manager
from models.user import User
router = APIRouter()
class TestFoldersRequest(BaseModel):
out_folder: str
in_folder: str
@router.post("/test-folders")
def test_folders(
body: TestFoldersRequest,
db: Session = Depends(get_db),
user: User = Depends(require_settings_manager),
):
"""Verify that both fiscal folders are accessible (read + write) from inside the container."""
from services.fiscal_service import test_folders
return test_folders(body.out_folder.strip(), body.in_folder.strip())

View File

@@ -1,30 +1,737 @@
"""
KDS — Kitchen Display System router.
GET /api/kds/items
Returns all active (status='active') order items for open orders,
enriched with zone name and table name. Used by the KDS frontend.
GET /api/kds/orders
Returns all open orders enriched with resolved modifier names,
grouped by nothing (flat list). Frontend handles grouping/filtering.
PUT /api/orders/{order_id}/items/{item_id}/status
Mark an item ready (active → ready). Only that transition is allowed here.
Broadcasts item_status_changed SSE event.
PUT /api/kds/orders/{order_id}/kds_status
Update an order's KDS aggregate status (pending|preparing|done).
Broadcasts kds_order_updated SSE event.
PUT /api/kds/orders/{order_id}/items/{item_id}/kds_status
Update a single item's KDS status (pending|preparing|done).
Broadcasts kds_item_updated SSE event.
PUT /api/kds/orders/{order_id}/order_type
Update an order's type (here|takeaway|delivery).
--- Legacy endpoint kept for backward compat (printer-zone grouped items) ---
GET /api/kds/items (unchanged)
PUT /api/kds/orders/{order_id}/items/{item_id}/status (unchanged)
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from datetime import datetime, timezone
from sqlalchemy.orm import Session, joinedload
from pydantic import BaseModel
from typing import List, Optional
from database import get_db
from models.order import Order, OrderItem
from models.product import Product
from models.order import Order, OrderItem, OrderWaiter
from models.product import Product, ProductPreferenceSet
from models.prep_zone import PrepZone
from models.printer import Printer
from models.table import Table
from models.user import User
from routers.deps import get_current_user
from models.message import StaffMessage, StaffMessageAck
from models.shift import WaiterShift
from routers.deps import get_current_user, require_kds
from services.sse_bus import broadcast_sync
router = APIRouter()
router = APIRouter(dependencies=[Depends(require_kds)])
VALID_KDS_STATUSES = {"pending", "preparing", "done", "served", "declined"}
VALID_ORDER_TYPES = {"here", "takeaway", "delivery"}
# ─────────────────────────── helpers ───────────────────────────────────────
def _resolve_item_modifiers(item: OrderItem, product: Product | None, db: Session):
"""
Returns a dict with four lists:
removed - ingredient names removed (from removed_ingredients JSON id array)
extras - option names selected (from selected_options JSON id array)
prefs - preference choice names selected (from selected_options JSON id array)
notes - plain text note
"""
removed, extras, prefs = [], [], []
if product is None:
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
# Build lookup maps (avoid N+1 — product relationships already loaded)
ing_map = {i.id: i.name for i in product.ingredients}
opt_map = {o.id: o.name for o in product.options}
quick_map = {q.id: q.name for q in product.quick_options}
# Preference choice lookup: pref_choice_map[choice_id] = (set_name, choice_name)
pref_choice_map: dict[int, tuple[str, str]] = {}
for ps in product.preference_sets:
for pc in ps.choices:
pref_choice_map[pc.id] = (ps.name, pc.name)
# Removed ingredients — stored as JSON array of name strings (not ids)
if item.removed_ingredients:
try:
vals = json.loads(item.removed_ingredients)
for v in vals:
if isinstance(v, str) and v:
removed.append(v)
elif isinstance(v, int):
# legacy: id reference
name = ing_map.get(v)
if name:
removed.append(name)
except (json.JSONDecodeError, TypeError):
pass
# Selected options — stored as JSON array of objects:
# [{"id": int, "name": str, "type": "extra"|"quick"|"pref"|"pref_sub", ...}]
# We use the stored name directly; type tag tells us which bucket.
if item.selected_options:
try:
opts = json.loads(item.selected_options)
for o in opts:
if isinstance(o, dict):
name = o.get("name") or ""
otype = o.get("type", "")
if not name:
continue
if otype in ("pref", "pref_sub"):
prefs.append(name)
else:
# "extra", "extra_sub", "quick", or unknown → extras
extras.append(name)
elif isinstance(o, int):
# legacy: id-only reference — fall back to lookup maps
if o in opt_map:
extras.append(opt_map[o])
elif o in quick_map:
extras.append(quick_map[o])
elif o in pref_choice_map:
_, choice_name = pref_choice_map[o]
prefs.append(choice_name)
except (json.JSONDecodeError, TypeError):
pass
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
def _build_order_payload(order: Order, tables_map: dict, db: Session) -> dict:
"""Build the full order dict the KDS frontend expects."""
items_out = []
for item in order.items:
if item.status == "cancelled":
continue
if item.kds_status == "served":
continue
product = item.product
mods = _resolve_item_modifiers(item, product, db)
unit_type = product.unit_type if product else "piece"
prep_zone_ids = [z.id for z in product.prep_zones] if product and hasattr(product, 'prep_zones') else []
items_out.append({
"id": item.id,
"product_id": item.product_id,
"product_name": product.name if product else f"#{item.product_id}",
"quantity": item.quantity,
"unit_type": unit_type or "piece",
"kds_status": item.kds_status,
"decline_note": item.decline_note,
"added_at": item.added_at.isoformat() if item.added_at else None,
"_added_at_raw": item.added_at, # stripped before response
"prep_zone_ids": prep_zone_ids,
"course_id": item.course_id,
"removed": mods["removed"],
"extras": mods["extras"],
"prefs": mods["prefs"],
"notes": mods["notes"],
})
# Waiter names from first assignment
waiter_names = [ow.waiter.username for ow in order.waiters if ow.waiter] if order.waiters else []
# Clock = oldest added_at among pending/preparing items (the active batch).
# Falls back to order.opened_at only if no such item exists.
def _fmt_ts(dt):
if dt is None:
return None
return dt.isoformat() + "Z" if not dt.tzinfo else dt.isoformat()
active_batch_times = [
item["_added_at_raw"] for item in items_out
if item.get("_added_at_raw") and item["kds_status"] in ("pending", "preparing")
]
batch_ts = min(active_batch_times) if active_batch_times else None
opened_at_out = _fmt_ts(batch_ts) if batch_ts else _fmt_ts(order.opened_at)
for item in items_out:
item.pop("_added_at_raw", None)
return {
"id": order.id,
"kds_status": order.kds_status,
"order_type": order.order_type,
"table_name": tables_map.get(order.table_id) if order.table_id else None,
"table_id": order.table_id,
"opened_at": opened_at_out,
"closed_at": _fmt_ts(order.closed_at) if order.closed_at else None,
"notes": order.notes,
"waiters": waiter_names,
"items": items_out,
}
def _sync_order_kds_status(order: Order):
"""Derive and set order.kds_status from all non-cancelled items. Call before db.commit()."""
relevant = [i for i in order.items if i.status != "cancelled"]
if not relevant:
return
statuses = {i.kds_status for i in relevant}
if statuses <= {"served"}:
new_status = "served"
elif statuses <= {"done", "served"}:
new_status = "done"
elif statuses <= {"declined"}:
new_status = "declined"
elif "preparing" in statuses or "done" in statuses:
new_status = "preparing"
else:
new_status = "pending"
if new_status != order.kds_status:
order.kds_status = new_status
order.kds_status_changed_at = datetime.now(timezone.utc)
# ─────────────────────────── endpoints ─────────────────────────────────────
@router.get("/orders")
def kds_orders(
zone_id: Optional[int] = Query(default=None, description="Filter orders to only those containing items in this prep zone"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return all open/partially_paid/paid orders that still have unserved items.
If zone_id is provided, only orders containing at least one item belonging to that prep zone are returned,
and items not in that zone are stripped from the payload.
"""
open_orders = (
db.query(Order)
.filter(Order.status.in_(["open", "partially_paid", "paid"]))
.options(
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.ingredients),
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.options),
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.quick_options),
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.preference_sets).joinedload(ProductPreferenceSet.choices),
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.prep_zones),
joinedload(Order.waiters).joinedload(OrderWaiter.waiter),
)
.order_by(Order.opened_at.asc())
.all()
)
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
payloads = []
for o in open_orders:
p = _build_order_payload(o, tables_map, db)
if not p["items"]:
continue
# Zone filtering: keep only items whose product belongs to the requested zone
if zone_id is not None:
p["items"] = [it for it in p["items"] if zone_id in it.get("prep_zone_ids", [])]
if not p["items"]:
continue
payloads.append(p)
return {"orders": payloads}
class KdsStatusBody(BaseModel):
status: str
class OrderTypeBody(BaseModel):
order_type: str
@router.put("/orders/{order_id}/kds_status")
def update_order_kds_status(
order_id: int,
body: KdsStatusBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.status not in VALID_KDS_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if body.status != order.kds_status:
order.kds_status = body.status
order.kds_status_changed_at = datetime.now(timezone.utc)
# Cascade order-level status to non-cancelled, non-served items only
if body.status in ("pending", "preparing", "done"):
for item in order.items:
if item.status != "cancelled" and item.kds_status != "served":
item.kds_status = body.status
# auto_ready_to_served: if cascading to 'done', immediately upgrade qualifying items
if body.status == "done" and item.product:
if any(bool(z.auto_ready_to_served) for z in item.product.prep_zones):
item.kds_status = "served"
db.commit()
broadcast_sync("kds_order_updated", {
"order_id": order_id,
"kds_status": body.status,
})
return {"order_id": order_id, "kds_status": body.status}
@router.put("/orders/{order_id}/items/{item_id}/kds_status")
def update_item_kds_status(
order_id: int,
item_id: int,
body: KdsStatusBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.status not in VALID_KDS_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
item = db.query(OrderItem).filter(
OrderItem.id == item_id,
OrderItem.order_id == order_id,
).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
item.kds_status = body.status
# auto_ready_to_served: if item reaches 'done' and any of its zones has the flag,
# immediately upgrade it to 'served' — item was ready at the KDS, no explicit serve needed.
if body.status == "done" and item.product:
auto_serve = any(bool(z.auto_ready_to_served) for z in item.product.prep_zones)
if auto_serve:
item.kds_status = "served"
if item.order:
_sync_order_kds_status(item.order)
db.commit()
broadcast_sync("kds_item_updated", {
"order_id": order_id,
"item_id": item_id,
"kds_status": item.kds_status,
})
return {"item_id": item_id, "kds_status": item.kds_status}
@router.put("/orders/{order_id}/order_type")
def update_order_type(
order_id: int,
body: OrderTypeBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.order_type not in VALID_ORDER_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid order_type '{body.order_type}'")
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
order.order_type = body.order_type
db.commit()
return {"order_id": order_id, "order_type": body.order_type}
class MarkServedBody(BaseModel):
item_ids: list[int]
@router.post("/orders/{order_id}/items/mark-served")
def mark_items_served(
order_id: int,
body: MarkServedBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark a list of items as served (kds_status=served). Works on active and paid items."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
updated = []
ids_set = set(body.item_ids)
for item in order.items:
if item.id in ids_set:
item.kds_status = "served"
updated.append(item.id)
_sync_order_kds_status(order)
db.commit()
broadcast_sync("kds_item_updated", {
"order_id": order_id,
"item_ids": updated,
"kds_status": "served",
})
return {"updated": updated}
@router.post("/orders/{order_id}/items/mark-all-served")
def mark_all_items_served(
order_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark all non-pending items in an order as served."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
updated = []
for item in order.items:
if item.kds_status in ("preparing", "done"):
item.kds_status = "served"
updated.append(item.id)
_sync_order_kds_status(order)
db.commit()
broadcast_sync("kds_item_updated", {
"order_id": order_id,
"item_ids": updated,
"kds_status": "served",
})
return {"updated": updated}
# ─────────────────────────── KDS print endpoint ─────────────────────────────
class KdsPrintBody(BaseModel):
printer_id: Optional[int] = None
item_ids: Optional[List[int]] = None
copies: int = 1
zone_id: Optional[int] = None
mode: str = "primary" # none | primary | all
@router.post("/orders/{order_id}/print")
def kds_print_order(
order_id: int,
body: KdsPrintBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Print order to zone printers. mode=primary prints to first printer, mode=all prints to all."""
from services import printer_service
if body.mode == "none":
return {"printed": 0}
if body.printer_id is not None:
# Direct printer specified
result = printer_service.print_to_printer(order_id, body.item_ids, body.printer_id, body.copies, db)
return result
# Zone-based print
if body.zone_id is None:
return {"printed": 0, "message": "No zone configured"}
from models.prep_zone import PrepZone
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
if not zone or not zone.printers:
return {"printed": 0, "message": "Zone has no printers"}
printers_to_use = zone.printers if body.mode == "all" else [zone.printers[0]]
printed = 0
for printer in printers_to_use:
try:
printer_service.print_to_printer(order_id, body.item_ids, printer.id, body.copies, db)
printed += 1
except Exception:
pass
return {"printed": printed}
# ─────────────────────────── KDS decline endpoints ─────────────────────────
class DeclineBody(BaseModel):
decline_note: Optional[str] = None # reason string (preset or free text)
@router.put("/orders/{order_id}/decline")
def decline_order(
order_id: int,
body: DeclineBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark all active items on an order as declined."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
for item in order.items:
if item.status != "cancelled":
item.kds_status = "declined"
item.decline_note = body.decline_note
order.kds_status = "declined"
db.commit()
broadcast_sync("kds_order_updated", {
"order_id": order_id,
"kds_status": "declined",
"decline_note": body.decline_note,
})
return {"order_id": order_id, "kds_status": "declined"}
@router.put("/orders/{order_id}/items/{item_id}/decline")
def decline_item(
order_id: int,
item_id: int,
body: DeclineBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark a single item as declined."""
item = db.query(OrderItem).filter(
OrderItem.id == item_id,
OrderItem.order_id == order_id,
).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
item.kds_status = "declined"
item.decline_note = body.decline_note
if item.order:
_sync_order_kds_status(item.order)
db.commit()
broadcast_sync("kds_item_updated", {
"order_id": order_id,
"item_id": item_id,
"kds_status": "declined",
"decline_note": body.decline_note,
})
return {"item_id": item_id, "kds_status": "declined"}
# ─────────────────────────── KDS notifications ──────────────────────────────
def _waiter_ids_for_order(order: Order) -> list[int]:
"""Return the list of waiter IDs assigned to an order."""
return [ow.waiter_id for ow in order.waiters if ow.waiter_id]
def _save_and_broadcast_message(
db: Session,
sender_id: int,
body: str,
target_waiter_ids: list[int],
message_type: str,
kds_zone: str | None,
table_ids: list[int] | None = None,
) -> dict:
"""Persist a StaffMessage and broadcast via SSE. Returns the SSE payload dict."""
from models.message import StaffMessage
from datetime import datetime, timezone
msg = StaffMessage(
sender_id=sender_id,
body=body,
target_waiter_ids=json.dumps(target_waiter_ids),
table_ids=json.dumps(table_ids or []),
message_type=message_type,
kds_zone=kds_zone,
)
db.add(msg)
db.commit()
db.refresh(msg)
payload = {
"id": msg.id,
"sender_id": msg.sender_id,
"sender_name": kds_zone or "KDS",
"body": msg.body,
"table_ids": msg.table_ids,
"message_type": message_type,
"kds_zone": kds_zone,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
}
user_ids = target_waiter_ids if target_waiter_ids else None
broadcast_sync("message_sent", payload, user_ids=user_ids)
return payload
class KdsNotifyBody(BaseModel):
kds_zone: Optional[str] = None
class KdsCallWaiterBody(BaseModel):
kds_zone: Optional[str] = None
waiter_ids: List[int] # for call_general: list of on-shift waiter ids to notify
@router.post("/orders/{order_id}/notify-complete")
def kds_notify_order_complete(
order_id: int,
body: KdsNotifyBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Notify assigned waiters that their order is fully complete."""
order = db.query(Order).options(
joinedload(Order.waiters),
).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
waiter_ids = _waiter_ids_for_order(order)
if not waiter_ids:
return {"sent": False, "reason": "no_waiters"}
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
zone = body.kds_zone or "Κουζίνα"
msg_body = (
f"Η παραγγελία #{order.id} για το τραπέζι {table_name} "
f"είναι έτοιμη προς παραλαβή - {zone}"
)
_save_and_broadcast_message(
db, user.id, msg_body, waiter_ids,
"kds_order_done", zone,
table_ids=[order.table_id] if order.table_id else [],
)
return {"sent": True, "waiter_ids": waiter_ids}
class KdsNotifyItemsBody(BaseModel):
kds_zone: Optional[str] = None
ready_count: int
total_count: int
@router.post("/orders/{order_id}/notify-items-ready")
def kds_notify_items_ready(
order_id: int,
body: KdsNotifyItemsBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Notify assigned waiters that some (but not all) items are ready."""
order = db.query(Order).options(
joinedload(Order.waiters),
).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
waiter_ids = _waiter_ids_for_order(order)
if not waiter_ids:
return {"sent": False, "reason": "no_waiters"}
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
zone = body.kds_zone or "Κουζίνα"
msg_body = (
f"{body.ready_count} αντικείμενα από την παραγγελία #{order.id} "
f"για το τραπέζι {table_name} είναι έτοιμα - {zone}"
)
_save_and_broadcast_message(
db, user.id, msg_body, waiter_ids,
"kds_item_done", zone,
table_ids=[order.table_id] if order.table_id else [],
)
return {"sent": True, "waiter_ids": waiter_ids}
@router.post("/orders/{order_id}/call-waiter")
def kds_call_waiter_order(
order_id: int,
body: KdsNotifyBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Manually call the waiters assigned to a specific order."""
order = db.query(Order).options(
joinedload(Order.waiters),
).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
waiter_ids = _waiter_ids_for_order(order)
if not waiter_ids:
return {"sent": False, "reason": "no_waiters"}
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
zone = body.kds_zone or "Κουζίνα"
msg_body = (
f"Παραγγελία #{order.id} - τραπέζι {table_name} "
f"- {zone}"
)
_save_and_broadcast_message(
db, user.id, msg_body, waiter_ids,
"kds_call_order", zone,
table_ids=[order.table_id] if order.table_id else [],
)
return {"sent": True, "waiter_ids": waiter_ids}
@router.post("/call-waiter-general")
def kds_call_waiter_general(
body: KdsCallWaiterBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Call one or more on-shift waiters to the prep zone."""
if not body.waiter_ids:
raise HTTPException(status_code=400, detail="waiter_ids must not be empty")
zone = body.kds_zone or "Κουζίνα"
msg_body = f"Κλήση από {zone}"
_save_and_broadcast_message(
db, user.id, msg_body, body.waiter_ids,
"kds_call_general", zone,
)
return {"sent": True, "waiter_ids": body.waiter_ids}
@router.get("/on-shift-waiters")
def kds_on_shift_waiters(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return waiters currently on shift (ended_at IS NULL)."""
on_shift_ids = {
row.waiter_id
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
}
waiters = db.query(User).filter(
User.id.in_(on_shift_ids),
User.perm_access_waiter_app == True,
User.is_active == True,
).order_by(User.username).all()
return [
{"id": w.id, "username": w.username, "nickname": w.nickname, "avatar_url": w.avatar_url}
for w in waiters
]
# ─────────────────────────── legacy endpoints ──────────────────────────────
class ItemStatusUpdate(BaseModel):
status: str # only "ready" is accepted
@@ -35,15 +742,13 @@ def kds_items(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return all active order items grouped by zone for the KDS display."""
# Only look at open / partially_paid orders
"""Legacy: return active order items grouped by printer zone."""
open_orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid"])).all()
order_ids = [o.id for o in open_orders]
if not order_ids:
return {"zones": []}
order_map = {o.id: o for o in open_orders}
items = db.query(OrderItem).filter(
OrderItem.order_id.in_(order_ids),
OrderItem.status == "active",
@@ -52,13 +757,10 @@ def kds_items(
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
printers_map = {p.id: p.name for p in db.query(Printer).all()}
# Zone = printer_zone_id (None = no zone)
zones: dict = {}
def _zone_key(zone_id):
if zone_id is None:
return 0
return zone_id
return 0 if zone_id is None else zone_id
def _zone_name(zone_id):
if zone_id is None:
@@ -69,17 +771,10 @@ def kds_items(
product = item.product
zone_id = product.printer_zone_id if product else None
zkey = _zone_key(zone_id)
if zkey not in zones:
zones[zkey] = {
"zone_id": zone_id,
"zone_name": _zone_name(zone_id),
"items": [],
}
zones[zkey] = {"zone_id": zone_id, "zone_name": _zone_name(zone_id), "items": []}
order = order_map.get(item.order_id)
table_name = tables_map.get(order.table_id) if order and order.table_id else None
zones[zkey]["items"].append({
"id": item.id,
"order_id": item.order_id,
@@ -91,19 +786,19 @@ def kds_items(
"status": item.status,
})
# Sort zones: named zones first (by zone_id), then no-zone last
zone_list = sorted(zones.values(), key=lambda z: (z["zone_id"] is None, z["zone_id"] or 0))
return {"zones": zone_list}
@router.put("/orders/{order_id}/items/{item_id}/status")
def update_item_status(
def update_item_status_legacy(
order_id: int,
item_id: int,
body: ItemStatusUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Legacy: mark an item ready (active → ready)."""
if body.status != "ready":
raise HTTPException(status_code=400, detail="Only 'ready' is a valid status transition via this endpoint")

View File

@@ -37,6 +37,8 @@ def _message_out(msg: StaffMessage) -> StaffMessageOut:
body=msg.body,
target_waiter_ids=msg.target_waiter_ids,
table_ids=msg.table_ids,
message_type=msg.message_type or "manager",
kds_zone=msg.kds_zone,
created_at=msg.created_at,
acked_by=[ack.waiter_id for ack in msg.acks],
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
"""
Phone call event receiver + WebSocket broadcaster.
The Grandstream UCM sends a simple HTTP GET to /api/phone/call-event
with caller ID and extension details. We broadcast that to all connected
WebSocket clients (manager dashboard Phone page).
The call-event endpoint is intentionally unauthenticated so the UCM device
(which has no JWT) can POST to it directly on the local LAN.
"""
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
router = APIRouter()
logger = logging.getLogger(__name__)
# In-memory list of active WebSocket connections to the Phone page.
_connections: list[WebSocket] = []
@router.websocket("/ws")
async def phone_ws(websocket: WebSocket):
await websocket.accept()
_connections.append(websocket)
logger.debug("phone_ws: client connected (%d total)", len(_connections))
try:
# Keep the connection alive; client drives nothing, server pushes events.
while True:
await asyncio.sleep(25)
await websocket.send_text(json.dumps({"type": "ping"}))
except (WebSocketDisconnect, Exception):
pass
finally:
_connections.remove(websocket)
logger.debug("phone_ws: client disconnected (%d total)", len(_connections))
@router.get("/call-event")
async def call_event(
caller: str = Query(..., description="Caller ID number from UCM"),
ext: str = Query("", description="Dialled extension"),
):
"""
Called by the Grandstream UCM Action URL on every incoming call.
Example: GET /api/phone/call-event?caller=2106001234&ext=100
"""
payload = json.dumps({"type": "incoming_call", "caller": caller, "ext": ext})
dead = []
for ws in list(_connections):
try:
await ws.send_text(payload)
except Exception:
dead.append(ws)
for ws in dead:
try:
_connections.remove(ws)
except ValueError:
pass
logger.info("phone call-event: caller=%s ext=%s (notified %d clients)", caller, ext, len(_connections) - len(dead))
return {"status": "ok", "notified": len(_connections) - len(dead)}

View File

@@ -0,0 +1,215 @@
"""
Prep Zones router.
GET /api/prep-zones list all prep zones (with printer ids)
POST /api/prep-zones create a prep zone
PUT /api/prep-zones/{id} update name / description / printer assignments / all settings
DELETE /api/prep-zones/{id} delete a prep zone
"""
import json
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from database import get_db
from models.prep_zone import PrepZone
from models.printer import Printer
from routers.deps import require_manager
from models.user import User
router = APIRouter()
class PrepZoneCreate(BaseModel):
name: str
description: Optional[str] = None
notification_name: Optional[str] = None
# Printer routing
printer_ids: List[int] = [] # all printers (master + secondary)
master_printer_id: Optional[int] = None
auto_print: str = 'none' # 'none' | 'master' | 'all'
master_copies: int = 1
secondary_copies: int = 1
# Ticket formatting
sort_items_by: str = 'order_time' # 'order_time' | 'item_count' | 'alpha'
group_by_category: bool = False
category_order: List[int] = []
print_checkboxes: bool = False
# KDS bypass / auto-progression
bypass_pending: bool = False
bypass_kds: bool = False
auto_ready_to_served: bool = False
class PrepZoneUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
notification_name: Optional[str] = None
# Printer routing
printer_ids: Optional[List[int]] = None
master_printer_id: Optional[int] = None
auto_print: Optional[str] = None
master_copies: Optional[int] = None
secondary_copies: Optional[int] = None
# Ticket formatting
sort_items_by: Optional[str] = None
group_by_category: Optional[bool] = None
category_order: Optional[List[int]] = None
print_checkboxes: Optional[bool] = None
# KDS bypass / auto-progression
bypass_pending: Optional[bool] = None
bypass_kds: Optional[bool] = None
auto_ready_to_served: Optional[bool] = None
def _zone_out(zone: PrepZone) -> dict:
all_printer_ids = [p.id for p in zone.printers]
secondary_ids = [pid for pid in all_printer_ids if pid != zone.master_printer_id]
return {
"id": zone.id,
"name": zone.name,
"description": zone.description,
"notification_name": zone.notification_name,
# Printer routing
"printer_ids": all_printer_ids,
"printers": [{"id": p.id, "name": p.name} for p in zone.printers],
"master_printer_id": zone.master_printer_id,
"secondary_printer_ids": secondary_ids,
"auto_print": zone.auto_print or 'none',
"master_copies": zone.master_copies or 1,
"secondary_copies": zone.secondary_copies or 1,
# Ticket formatting
"sort_items_by": zone.sort_items_by or 'order_time',
"group_by_category": bool(zone.group_by_category),
"category_order": json.loads(zone.category_order) if zone.category_order else [],
"print_checkboxes": bool(zone.print_checkboxes),
# KDS bypass / auto-progression
"bypass_pending": bool(zone.bypass_pending),
"bypass_kds": bool(zone.bypass_kds),
"auto_ready_to_served": bool(zone.auto_ready_to_served),
}
@router.get("")
def list_prep_zones(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
zones = db.query(PrepZone).order_by(PrepZone.id).all()
return [_zone_out(z) for z in zones]
@router.post("")
def create_prep_zone(
body: PrepZoneCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
zone = PrepZone(
name=body.name,
description=body.description,
notification_name=body.notification_name,
master_printer_id=body.master_printer_id,
auto_print=body.auto_print,
master_copies=max(1, body.master_copies),
secondary_copies=max(1, body.secondary_copies),
print_copies=max(1, body.master_copies), # keep legacy field in sync
sort_items_by=body.sort_items_by,
group_by_category=1 if body.group_by_category else 0,
category_order=json.dumps(body.category_order),
print_checkboxes=1 if body.print_checkboxes else 0,
bypass_pending=1 if (body.bypass_pending or body.bypass_kds) else 0,
bypass_kds=1 if body.bypass_kds else 0,
auto_ready_to_served=1 if body.auto_ready_to_served else 0,
)
if body.printer_ids:
printers = db.query(Printer).filter(Printer.id.in_(body.printer_ids)).all()
zone.printers = printers
db.add(zone)
db.commit()
db.refresh(zone)
return _zone_out(zone)
@router.put("/{zone_id}")
def update_prep_zone(
zone_id: int,
body: PrepZoneUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
zone = db.query(PrepZone).filter(PrepZone.id == zone_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Prep zone not found")
if body.name is not None:
zone.name = body.name
if body.description is not None:
zone.description = body.description
if body.notification_name is not None:
zone.notification_name = body.notification_name
# Printer routing
if body.printer_ids is not None:
printers = db.query(Printer).filter(Printer.id.in_(body.printer_ids)).all()
zone.printers = printers
# master_printer_id: always write if field present in request body (null = clear)
if 'master_printer_id' in (body.model_fields_set if hasattr(body, 'model_fields_set') else {}):
zone.master_printer_id = body.master_printer_id
elif body.master_printer_id is not None:
zone.master_printer_id = body.master_printer_id
if body.auto_print is not None:
zone.auto_print = body.auto_print
if body.master_copies is not None:
zone.master_copies = max(1, body.master_copies)
zone.print_copies = zone.master_copies # keep legacy in sync
if body.secondary_copies is not None:
zone.secondary_copies = max(1, body.secondary_copies)
# Ticket formatting
if body.sort_items_by is not None:
zone.sort_items_by = body.sort_items_by
if body.group_by_category is not None:
zone.group_by_category = 1 if body.group_by_category else 0
if body.category_order is not None:
zone.category_order = json.dumps(body.category_order)
if body.print_checkboxes is not None:
zone.print_checkboxes = 1 if body.print_checkboxes else 0
# KDS bypass — bypass_kds implies bypass_pending
if body.bypass_kds is not None:
zone.bypass_kds = 1 if body.bypass_kds else 0
if body.bypass_kds:
zone.bypass_pending = 1
if body.bypass_pending is not None:
zone.bypass_pending = 1 if body.bypass_pending else 0
if body.auto_ready_to_served is not None:
zone.auto_ready_to_served = 1 if body.auto_ready_to_served else 0
db.commit()
db.refresh(zone)
return _zone_out(zone)
@router.delete("/{zone_id}")
def delete_prep_zone(
zone_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
zone = db.query(PrepZone).filter(PrepZone.id == zone_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Prep zone not found")
db.delete(zone)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,684 @@
import json
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models.pricing import (
PriceGroup, PriceModifier, PriceModifierCondition, PriceModifierTarget,
Deal, DealCondition, DealTarget,
PriceEventLog, WaiterDiscountSettings,
)
from schemas.pricing import (
PriceGroupCreate, PriceGroupUpdate, PriceGroupOut,
PriceModifierCreate, PriceModifierUpdate, PriceModifierOut,
PriceModifierReorderRequest,
DealCreate, DealUpdate, DealOut,
WaiterDiscountSettingsIn, WaiterDiscountSettingsOut,
PriceEventOut,
)
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
def _utcnow():
return datetime.now(timezone.utc)
# =============================================================================
# PRICE GROUPS
# =============================================================================
@router.get("/groups", response_model=List[PriceGroupOut])
def list_price_groups(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(PriceGroup).order_by(PriceGroup.name).all()
@router.post("/groups", response_model=PriceGroupOut, status_code=status.HTTP_201_CREATED)
def create_price_group(
body: PriceGroupCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = PriceGroup(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
auto_enable_time=body.auto_enable_time,
auto_disable_time=body.auto_disable_time,
auto_days=json.dumps(body.auto_days) if body.auto_days is not None else None,
created_by=user.id,
)
db.add(pg)
db.commit()
db.refresh(pg)
return pg
@router.put("/groups/{group_id}", response_model=PriceGroupOut)
def update_price_group(
group_id: int,
body: PriceGroupUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
pg.name = body.name
pg.description = body.description
pg.color = body.color
pg.is_active = int(body.is_active)
pg.auto_enable_time = body.auto_enable_time
pg.auto_disable_time = body.auto_disable_time
pg.auto_days = json.dumps(body.auto_days) if body.auto_days is not None else None
db.commit()
db.refresh(pg)
return pg
@router.patch("/groups/{group_id}/toggle", response_model=PriceGroupOut)
def toggle_price_group(
group_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
pg.is_active = 0 if pg.is_active else 1
db.commit()
db.refresh(pg)
return pg
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_price_group(
group_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
db.delete(pg)
db.commit()
# =============================================================================
# PRICE MODIFIERS
# =============================================================================
@router.get("/modifiers", response_model=List[PriceModifierOut])
def list_modifiers(
scope: Optional[str] = None,
item_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
q = db.query(PriceModifier)
if scope:
q = q.filter(PriceModifier.scope == scope)
if item_id is not None:
q = q.filter(PriceModifier.item_id == item_id)
return q.order_by(PriceModifier.sort_order).all()
@router.get("/modifiers/favorites", response_model=List[PriceModifierOut])
def list_favorite_modifiers(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Returns modifiers flagged as favorites for the dashboard quick-toggle panel."""
return (
db.query(PriceModifier)
.filter(PriceModifier.is_favorite == 1)
.order_by(PriceModifier.sort_order)
.all()
)
@router.post("/modifiers", response_model=PriceModifierOut, status_code=status.HTTP_201_CREATED)
def create_modifier(
body: PriceModifierCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = PriceModifier(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
is_favorite=int(body.is_favorite),
allow_stack=int(body.allow_stack),
sort_order=body.sort_order,
scope=body.scope,
item_id=body.item_id,
action_type=body.action_type,
action_value=body.action_value,
round_to=body.round_to,
created_by=user.id,
)
db.add(m)
db.flush()
_sync_conditions(m, body.conditions, db, model="modifier")
_sync_modifier_targets(m, body.targets, db)
db.commit()
db.refresh(m)
return m
@router.put("/modifiers/{modifier_id}", response_model=PriceModifierOut)
def update_modifier(
modifier_id: int,
body: PriceModifierUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
m.name = body.name
m.description = body.description
m.color = body.color
m.is_active = int(body.is_active)
m.is_favorite = int(body.is_favorite)
m.allow_stack = int(body.allow_stack)
m.sort_order = body.sort_order
m.scope = body.scope
m.item_id = body.item_id
m.action_type = body.action_type
m.action_value = body.action_value
m.round_to = body.round_to
m.updated_at = _utcnow()
m.updated_by = user.id
# Replace conditions and targets
for c in list(m.conditions):
db.delete(c)
for t in list(m.targets):
db.delete(t)
db.flush()
_sync_conditions(m, body.conditions, db, model="modifier")
_sync_modifier_targets(m, body.targets, db)
db.commit()
db.refresh(m)
return m
@router.patch("/modifiers/{modifier_id}/toggle", response_model=PriceModifierOut)
def toggle_modifier(
modifier_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
m.is_active = 0 if m.is_active else 1
m.updated_at = _utcnow()
m.updated_by = user.id
db.commit()
db.refresh(m)
return m
@router.patch("/modifiers/reorder")
def reorder_modifiers(
body: PriceModifierReorderRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
for item in body.items:
db.query(PriceModifier).filter(PriceModifier.id == item.id).update(
{"sort_order": item.sort_order}
)
db.commit()
return {"ok": True}
@router.delete("/modifiers/{modifier_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_modifier(
modifier_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
db.delete(m)
db.commit()
# =============================================================================
# DEALS
# =============================================================================
@router.get("/deals", response_model=List[DealOut])
def list_deals(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(Deal).order_by(Deal.sort_order).all()
@router.post("/deals", response_model=DealOut, status_code=status.HTTP_201_CREATED)
def create_deal(
body: DealCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = Deal(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
sort_order=body.sort_order,
action_type=body.action_type,
action_modifier_id=body.action_modifier_id,
action_value=body.action_value,
action_free_item_id=body.action_free_item_id,
action_free_target_type=body.action_free_target_type,
action_free_target_ids=(
json.dumps(body.action_free_target_ids)
if body.action_free_target_ids is not None else None
),
action_free_quantity=body.action_free_quantity,
created_by=user.id,
)
db.add(d)
db.flush()
_sync_conditions(d, body.conditions, db, model="deal")
_sync_deal_targets(d, body.targets, db)
db.commit()
db.refresh(d)
return d
@router.put("/deals/{deal_id}", response_model=DealOut)
def update_deal(
deal_id: int,
body: DealUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
d.name = body.name
d.description = body.description
d.color = body.color
d.is_active = int(body.is_active)
d.sort_order = body.sort_order
d.action_type = body.action_type
d.action_modifier_id = body.action_modifier_id
d.action_value = body.action_value
d.action_free_item_id = body.action_free_item_id
d.action_free_target_type = body.action_free_target_type
d.action_free_target_ids = (
json.dumps(body.action_free_target_ids)
if body.action_free_target_ids is not None else None
)
d.action_free_quantity = body.action_free_quantity
for c in list(d.conditions):
db.delete(c)
for t in list(d.targets):
db.delete(t)
db.flush()
_sync_conditions(d, body.conditions, db, model="deal")
_sync_deal_targets(d, body.targets, db)
db.commit()
db.refresh(d)
return d
@router.patch("/deals/{deal_id}/toggle", response_model=DealOut)
def toggle_deal(
deal_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
d.is_active = 0 if d.is_active else 1
db.commit()
db.refresh(d)
return d
@router.delete("/deals/{deal_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_deal(
deal_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
db.delete(d)
db.commit()
# =============================================================================
# WAITER DISCOUNT SETTINGS
# =============================================================================
@router.get("/discount-settings/global")
def get_global_discount_settings(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.settings import PosSettings
keys = [
"discounts.enabled",
"discounts.max_total_value_workday",
"discounts.max_total_value_shift",
"discounts.max_items_per_shift",
]
rows = db.query(PosSettings).filter(PosSettings.key.in_(keys)).all()
row_map = {r.key.split(".", 1)[1]: r.value for r in rows}
# Coerce types so frontend toggle/number fields work correctly
def _coerce(k, v):
if v is None:
return None
if k == "enabled":
return v.lower() in ("true", "1", "yes")
try:
return float(v) if "." in str(v) else int(v)
except Exception:
return v
return {k: _coerce(k, v) for k, v in row_map.items()}
@router.put("/discount-settings/global")
def set_global_discount_settings(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.settings import PosSettings
allowed = {
"enabled", "max_total_value_workday",
"max_total_value_shift", "max_items_per_shift",
}
now = _utcnow().isoformat()
for k, v in body.items():
if k not in allowed:
continue
full_key = f"discounts.{k}"
row = db.query(PosSettings).filter(PosSettings.key == full_key).first()
if row:
row.value = str(v)
row.updated_at = now
else:
db.add(PosSettings(key=full_key, value=str(v), updated_at=now))
db.commit()
return {"ok": True}
@router.get("/discount-settings/me", response_model=WaiterDiscountSettingsOut)
def get_my_discount_settings(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user.id
).first()
if not s:
return WaiterDiscountSettingsOut(id=0, user_id=user.id, can_apply_discounts=False)
return s
@router.get("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
def get_waiter_discount_settings(
user_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user_id
).first()
if not s:
# Return defaults (all None = no limits, disabled)
return WaiterDiscountSettingsOut(id=0, user_id=user_id, can_apply_discounts=False)
return s
@router.put("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
def set_waiter_discount_settings(
user_id: int,
body: WaiterDiscountSettingsIn,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user_id
).first()
if not s:
s = WaiterDiscountSettings(user_id=user_id)
db.add(s)
s.can_apply_discounts = int(body.can_apply_discounts)
s.max_discount_percent = body.max_discount_percent
s.max_discount_amount = body.max_discount_amount
s.max_total_value_shift = body.max_total_value_shift
s.max_total_value_workday = body.max_total_value_workday
s.max_items_per_shift = body.max_items_per_shift
s.max_items_per_workday = body.max_items_per_workday
s.max_items_per_order = body.max_items_per_order
db.commit()
db.refresh(s)
return s
# =============================================================================
# PRICE EVENT LOG (read-only, for order detail and reports)
# =============================================================================
@router.get("/events/order/{order_id}", response_model=List[PriceEventOut])
def get_order_price_events(
order_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
events = (
db.query(PriceEventLog)
.filter(PriceEventLog.order_id == order_id)
.order_by(PriceEventLog.applied_at)
.all()
)
result = []
for ev in events:
out = PriceEventOut.model_validate(ev)
if ev.modifier:
out.modifier_name = ev.modifier.name
if ev.deal:
out.deal_name = ev.deal.name
if ev.applied_by:
out.applied_by_username = ev.applied_by.username
result.append(out)
return result
# =============================================================================
# DEAL OFFER ACCEPT / DISMISS (PWA-facing)
# =============================================================================
@router.post("/deals/accept")
def accept_deal_offer(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Waiter confirms a deal offer. Logs the acceptance and directly adds free item(s)
to the order with deal_id + linked_item_id set for binding.
Returns added item IDs so the PWA can refresh.
"""
from models.pricing import PriceEventLog, Deal
from models.order import Order, OrderItem
from models.product import Product
deal_id = body.get("deal_id")
order_id = body.get("order_id")
# For free_choice: list of product_ids the waiter selected + their option snapshots
# Shape: [{ product_id, quantity, selected_options, notes }] or legacy [int, ...]
free_items_raw = body.get("free_items", [])
# Trigger item — the order_item_id that caused the deal to fire (for linking)
trigger_item_id = body.get("trigger_item_id", None)
deal = db.query(Deal).filter(Deal.id == deal_id).first()
if not deal:
raise HTTPException(status_code=404, detail="Deal not found")
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
added_item_ids = []
if deal.action_type in ("free_item", "free_choice"):
# Resolve which products to add
if deal.action_type == "free_item" and deal.action_free_item_id:
items_to_add = [{"product_id": deal.action_free_item_id, "quantity": deal.action_free_quantity,
"selected_options": None, "notes": None}]
else:
# free_choice: caller provides list of chosen products
items_to_add = []
for entry in free_items_raw:
if isinstance(entry, dict):
items_to_add.append({
"product_id": entry.get("product_id"),
"quantity": entry.get("quantity", deal.action_free_quantity),
"selected_options": entry.get("selected_options"),
"notes": entry.get("notes"),
})
else:
items_to_add.append({"product_id": int(entry), "quantity": deal.action_free_quantity,
"selected_options": None, "notes": None})
for entry in items_to_add:
product = db.query(Product).filter(Product.id == entry["product_id"]).first()
if not product:
continue
base_price = product.base_price or 0.0
new_item = OrderItem(
order_id=order_id,
product_id=product.id,
added_by=user.id,
quantity=entry["quantity"],
unit_price=base_price, # full price so the breakdown is readable
price_adjustment=-base_price, # negated to bring effective price to 0
selected_options=json.dumps(entry["selected_options"]) if entry["selected_options"] else None,
notes=entry.get("notes"),
deal_id=deal_id,
linked_item_id=trigger_item_id,
)
db.add(new_item)
db.flush()
added_item_ids.append(new_item.id)
db.add(PriceEventLog(
order_id=order_id,
order_item_id=new_item.id,
event_type="free_item_added",
deal_id=deal_id,
price_before=product.base_price or 0.0,
price_after=0.0,
delta_amount=-(product.base_price or 0.0),
applied_by_user_id=user.id,
))
# Log acceptance
db.add(PriceEventLog(
order_id=order_id,
order_item_id=trigger_item_id,
event_type="deal_offer_accepted",
deal_id=deal_id,
applied_by_user_id=user.id,
selected_item_ids=json.dumps([e.get("product_id") if isinstance(e, dict) else int(e) for e in free_items_raw]) if free_items_raw else None,
))
db.commit()
from services.sse_bus import broadcast_sync
broadcast_sync()
return {
"deal_id": deal_id,
"action_type": deal.action_type,
"added_item_ids": added_item_ids,
}
@router.post("/deals/dismiss")
def dismiss_deal_offer(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Waiter dismisses a deal offer. Logged so it can be re-triggered later."""
from models.pricing import PriceEventLog
deal_id = body.get("deal_id")
order_id = body.get("order_id")
db.add(PriceEventLog(
order_id=order_id,
order_item_id=None,
event_type="deal_offer_dismissed",
deal_id=deal_id,
applied_by_user_id=user.id,
))
db.commit()
return {"ok": True}
# =============================================================================
# Internal helpers
# =============================================================================
def _sync_conditions(parent, conditions_in, db: Session, model: str):
"""Write condition rows for a modifier or deal."""
for c in conditions_in:
if model == "modifier":
db.add(PriceModifierCondition(
modifier_id=parent.id,
condition_type=c.condition_type,
params=json.dumps(c.params),
))
else:
db.add(DealCondition(
deal_id=parent.id,
condition_type=c.condition_type,
params=json.dumps(c.params),
))
def _sync_modifier_targets(modifier, targets_in, db: Session):
for t in targets_in:
db.add(PriceModifierTarget(
modifier_id=modifier.id,
target_type=t.target_type,
target_id=t.target_id,
target_tag=t.target_tag,
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
))
def _sync_deal_targets(deal, targets_in, db: Session):
for t in targets_in:
db.add(DealTarget(
deal_id=deal.id,
target_type=t.target_type,
target_id=t.target_id,
target_tag=t.target_tag,
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
))

View File

@@ -2,11 +2,13 @@ import os
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice, ProductModifierGroup
from models.prep_zone import PrepZone
from models.order import OrderItem
from models.user import User
from schemas.product import (
@@ -14,9 +16,9 @@ from schemas.product import (
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
SubcategoryReorderItem, ParentGeneralReorderItem,
PreferenceSetCreate, ProductQuickOptionCreate,
CategoryReparentRequest,
CategoryReparentRequest, ModifierGroupCreate, ModifierGroupOut,
)
from routers.deps import get_current_user, require_manager
from routers.deps import get_current_user, require_menu_manager
from services.sse_bus import broadcast_sync
router = APIRouter()
@@ -28,6 +30,25 @@ def _broadcast_products_changed():
IMAGE_DIR = "/app/data/product_images"
def _replace_modifier_groups(db, product, groups):
"""Recreate modifier groups and return a list of created DB objects (index-aligned)."""
for g in product.modifier_groups:
db.delete(g)
db.flush()
created = []
for i, g in enumerate(groups):
new_g = ProductModifierGroup(
product_id=product.id,
modifier_type=g.modifier_type,
name=g.name,
sort_order=i,
)
db.add(new_g)
db.flush()
created.append(new_g)
return created
def _replace_quick_options(db, product, quick_options):
for qo in product.quick_options:
db.delete(qo)
@@ -45,43 +66,52 @@ def _replace_quick_options(db, product, quick_options):
))
def _replace_options(db, product, options):
def _replace_options(db, product, options, group_id_map=None):
for opt in product.options:
db.delete(opt)
db.flush()
for opt in options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
resolved_group = group_id_map[opt.group_id] if (group_id_map and opt.group_id is not None and opt.group_id < len(group_id_map)) else None
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
multi_select=opt.multi_select,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
is_compact=opt.is_compact,
group_id=resolved_group,
))
def _replace_ingredients(db, product, ingredients):
def _replace_ingredients(db, product, ingredients, group_id_map=None):
for ing in product.ingredients:
db.delete(ing)
db.flush()
for ing in ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
resolved_group = group_id_map[ing.group_id] if (group_id_map and ing.group_id is not None and ing.group_id < len(group_id_map)) else None
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate], group_id_map=None):
for ps in product.preference_sets:
db.delete(ps)
db.flush()
for ps in sets:
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
resolved_group = group_id_map[ps.group_id] if (group_id_map and ps.group_id is not None and ps.group_id < len(group_id_map)) else None
new_set = ProductPreferenceSet(
product_id=product.id,
name=ps.name,
shared_subset=shared_json,
is_favorite=ps.is_favorite,
favorite_sort_order=ps.favorite_sort_order,
group_id=resolved_group,
allow_multi_select=ps.allow_multi_select,
allow_choice_quantity=ps.allow_choice_quantity,
)
db.add(new_set)
db.flush()
@@ -94,6 +124,7 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
extra_cost=ch.extra_cost,
sub_choices=sub_json,
disables_subset=ch.disables_subset,
is_compact=ch.is_compact,
)
db.add(choice)
db.flush()
@@ -104,13 +135,27 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
# ── Categories ────────────────────────────────────────────────────────────────
@router.get("/tags")
def list_all_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Return sorted unique tag strings across all products."""
rows = db.query(Product.tags).filter(Product.tags != None, Product.tags != "[]").all()
tag_set = set()
for (tags_json,) in rows:
try:
tags = json.loads(tags_json) if tags_json else []
tag_set.update(tags)
except Exception:
pass
return sorted(tag_set)
@router.get("/categories", response_model=List[CategoryOut])
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Category).order_by(Category.sort_order).all()
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
# sort_order is among siblings (same parent_id level)
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
cat = Category(
@@ -128,7 +173,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
@@ -138,7 +183,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Reorder sub-categories within their parent (sort_order among siblings)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
@@ -149,7 +194,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Update general_sort_order on parent categories (position of the General group)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
@@ -160,7 +205,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Move a category to a new parent (or promote to top-level if parent_id is null).
All products assigned to this category follow it automatically (no product updates needed).
"""
@@ -190,7 +235,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
@router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
@@ -203,7 +248,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
@@ -217,14 +262,31 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
@router.get("/", response_model=List[ProductOut])
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
q = db.query(Product)
if not all or user.role not in ("manager", "sysadmin"):
has_dashboard = user.role in ("superadmin", "manager") or getattr(user, "perm_access_dashboard", False)
if not all or not has_dashboard:
# Waiters only see active, available products
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
return q.order_by(Product.sort_order, Product.id).all()
class BulkPrepZoneBody(BaseModel):
product_ids: List[int]
prep_zone_ids: List[int]
@router.post("/bulk-prep-zones", status_code=status.HTTP_204_NO_CONTENT)
def bulk_assign_prep_zones(body: BulkPrepZoneBody, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Replace the prep zone assignments for a list of products at once."""
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
products = db.query(Product).filter(Product.id.in_(body.product_ids)).all()
for p in products:
p.prep_zones = zones
db.commit()
_broadcast_products_changed()
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
for item in items:
product = db.query(Product).filter(Product.id == item.id).first()
if product:
@@ -234,15 +296,20 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"})
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags"})
if data.get("sort_order") == 0:
data["sort_order"] = db.query(Product).count()
if body.cost_breakdown is not None:
data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown])
data["tags"] = json.dumps(body.tags) if body.tags is not None else None
product = Product(**data)
db.add(product)
db.flush()
# Assign prep zones
if body.prep_zone_ids:
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
product.prep_zones = zones
for i, qo in enumerate(body.quick_options):
db.add(ProductQuickOption(
product_id=product.id,
@@ -254,20 +321,27 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
favorite_sort_order=qo.favorite_sort_order,
is_compact=qo.is_compact,
))
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
group_id_map = [g.id for g in created_groups]
for opt in body.options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
resolved_group = group_id_map[opt.group_id] if (opt.group_id is not None and opt.group_id < len(group_id_map)) else None
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
multi_select=opt.multi_select,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
is_compact=opt.is_compact,
group_id=resolved_group,
))
for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
_replace_preference_sets(db, product, body.preference_sets)
resolved_group = group_id_map[ing.group_id] if (ing.group_id is not None and ing.group_id < len(group_id_map)) else None
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
db.commit()
db.refresh(product)
_broadcast_products_changed()
@@ -275,30 +349,46 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
@router.put("/{product_id}", response_model=ProductOut)
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
scalar_fields = body.model_dump(
exclude_none=True,
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"},
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags", "modifier_groups"},
)
for field, value in scalar_fields.items():
setattr(product, field, value)
# Always clear legacy per-product printer — routing is now via prep zones only
product.printer_zone_id = None
# Update prep zones if provided
if body.prep_zone_ids is not None:
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
product.prep_zones = zones
# cost_breakdown is a list of objects — serialize to JSON for storage
if body.cost_breakdown is not None:
product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown])
elif "cost_breakdown" in body.model_fields_set:
# explicitly set to null — clear it
product.cost_breakdown = None
# tags is a list of strings — serialize to JSON for storage
if body.tags is not None:
product.tags = json.dumps(body.tags)
elif "tags" in body.model_fields_set:
product.tags = None
if body.quick_options is not None:
_replace_quick_options(db, product, body.quick_options)
# Modifier groups must be recreated before items so we have the ID map
group_id_map = None
if body.modifier_groups is not None:
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
group_id_map = [g.id for g in created_groups]
if body.options is not None:
_replace_options(db, product, body.options)
_replace_options(db, product, body.options, group_id_map)
if body.ingredients is not None:
_replace_ingredients(db, product, body.ingredients)
_replace_ingredients(db, product, body.ingredients, group_id_map)
if body.preference_sets is not None:
_replace_preference_sets(db, product, body.preference_sets)
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
db.commit()
db.refresh(product)
_broadcast_products_changed()
@@ -306,7 +396,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
@router.post("/{product_id}/image", response_model=ProductOut)
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
@@ -336,8 +426,55 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
return product
# ── Modifier Groups ───────────────────────────────────────────────────────────
@router.get("/{product_id}/modifier-groups", response_model=List[ModifierGroupOut])
def list_modifier_groups(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(ProductModifierGroup).filter(ProductModifierGroup.product_id == product_id).order_by(ProductModifierGroup.sort_order).all()
@router.post("/{product_id}/modifier-groups", response_model=ModifierGroupOut, status_code=status.HTTP_201_CREATED)
def create_modifier_group(product_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
g = ProductModifierGroup(product_id=product_id, modifier_type=body.modifier_type, name=body.name, sort_order=body.sort_order)
db.add(g)
db.commit()
db.refresh(g)
_broadcast_products_changed()
return g
@router.put("/{product_id}/modifier-groups/{group_id}", response_model=ModifierGroupOut)
def update_modifier_group(product_id: int, group_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
if not g:
raise HTTPException(status_code=404, detail="Group not found")
g.name = body.name
g.sort_order = body.sort_order
db.commit()
db.refresh(g)
_broadcast_products_changed()
return g
@router.delete("/{product_id}/modifier-groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_modifier_group(product_id: int, group_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
if not g:
raise HTTPException(status_code=404, detail="Group not found")
# Un-group all items in this group before deleting
db.query(ProductOption).filter(ProductOption.group_id == group_id).update({"group_id": None})
db.query(ProductIngredient).filter(ProductIngredient.group_id == group_id).update({"group_id": None})
db.query(ProductPreferenceSet).filter(ProductPreferenceSet.group_id == group_id).update({"group_id": None})
db.delete(g)
db.commit()
_broadcast_products_changed()
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")

View File

@@ -0,0 +1,113 @@
import secrets
import bcrypt
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database import get_db
from models.user import User
from models.recovery_code import RecoveryCode
from schemas.user import UserOut
from schemas.auth import TokenResponse
from routers.deps import get_current_user, make_token
router = APIRouter()
_logger = logging.getLogger(__name__)
CODES_PER_BATCH = 5
def _generate_code() -> str:
"""Return a human-readable code like XENIA-A3K9-MW2F."""
part = lambda: secrets.token_hex(2).upper()
return f"XENIA-{part()}-{part()}"
def _hash_code(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def _verify_code(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def try_recovery_code(plain: str, user: "User", db: "Session") -> bool:
"""Try to consume a recovery code for user. Returns True and burns the code if matched."""
unused = db.query(RecoveryCode).filter(
RecoveryCode.user_id == user.id,
RecoveryCode.used_at.is_(None),
).all()
matched = next((rc for rc in unused if _verify_code(plain, rc.code_hash)), None)
if not matched:
return False
matched.used_at = datetime.now(timezone.utc)
db.commit()
_logger.warning("RECOVERY CODE USED for user_id=%d username=%s code_id=%d", user.id, user.username, matched.id)
return True
# ─── Public: use a recovery code to log in (kept for direct API use) ─────────
class UseRecoveryCodeRequest(BaseModel):
username: str
code: str
@router.post("/use", response_model=TokenResponse)
def use_recovery_code(body: UseRecoveryCodeRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(
User.username == body.username,
User.is_active == True,
).first()
if not user or not try_recovery_code(body.code, user, db):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = make_token(user)
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
# ─── Authenticated: generate a new batch (burns all existing unused codes) ───
class RecoveryCodesGenerated(BaseModel):
codes: list[str]
remaining_after: int
@router.post("/generate", response_model=RecoveryCodesGenerated)
def generate_recovery_codes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Burn all existing unused codes for this user
db.query(RecoveryCode).filter(
RecoveryCode.user_id == current_user.id,
RecoveryCode.used_at.is_(None),
).delete(synchronize_session=False)
db.flush()
plain_codes = [_generate_code() for _ in range(CODES_PER_BATCH)]
for plain in plain_codes:
db.add(RecoveryCode(user_id=current_user.id, code_hash=_hash_code(plain)))
db.commit()
_logger.info("Recovery codes regenerated for user_id=%d", current_user.id)
return RecoveryCodesGenerated(codes=plain_codes, remaining_after=CODES_PER_BATCH)
# ─── Authenticated: check how many unused codes remain ───────────────────────
class RecoveryCodeStatus(BaseModel):
unused_count: int
@router.get("/status", response_model=RecoveryCodeStatus)
def recovery_code_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
count = db.query(RecoveryCode).filter(
RecoveryCode.user_id == current_user.id,
RecoveryCode.used_at == None,
).count()
return RecoveryCodeStatus(unused_count=count)

View File

@@ -13,6 +13,7 @@ from typing import Optional, List
from database import get_db
from models.order import Order, OrderItem, OrderWaiter, PrintLog
from models.prep_zone import PrepZone
from models.user import User
from models.table import Table
from models.printer import Printer
@@ -20,15 +21,21 @@ from models.shift import WaiterShift
from models.business_day import BusinessDay
from schemas.order import OrderOut
from schemas.table import TableOut
from routers.deps import require_manager
from routers.deps import require_reports
from services.printer_service import (
print_waiter_report, print_printer_report, print_order_receipt, load_divider_style,
print_products_report, print_categories_report, print_tables_report,
print_prep_zone_summary,
)
router = APIRouter()
def _item_val(item) -> float:
"""Effective line value: unit_price + any price adjustment, times quantity."""
return ((item.unit_price or 0.0) + (item.price_adjustment or 0.0)) * item.quantity
def _dt(dt):
if dt is None:
return None
@@ -42,7 +49,7 @@ def shift_summary(
report_date: Optional[date] = Query(default=None, alias="date"),
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
"""Payments collected per waiter — based on paid_by on order items."""
if from_dt and to_dt:
@@ -80,7 +87,7 @@ def shift_summary(
"order_data": {},
}
summary[wid]["items"] += item.quantity
val = item.unit_price * item.quantity
val = _item_val(item)
summary[wid]["total"] += val
oid = item.order_id
@@ -117,7 +124,7 @@ def shift_orders_summary(
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
"""Items sent (added) per waiter — regardless of payment status."""
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
@@ -181,7 +188,7 @@ def shift_orders_summary(
"order_data": {},
}
summary[wid]["items"] += item.quantity
val = item.unit_price * item.quantity
val = _item_val(item)
summary[wid]["total"] += val
oid = item.order_id
@@ -237,7 +244,7 @@ def order_history(
page: int = 1,
page_size: int = 50,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from sqlalchemy.orm import joinedload
from models.table import Table as TableModel
@@ -318,6 +325,7 @@ def order_history(
"added_at": _dt_local(item.added_at),
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"price_adjustment": float(item.price_adjustment or 0.0),
"status": item.status,
"paid_by": item.paid_by,
"paid_by_name": _wname(item.paid_by),
@@ -366,7 +374,7 @@ def order_history(
@router.get("/tables/summary")
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_manager)):
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_reports)):
tables = db.query(Table).filter(Table.is_active == True).all()
result = []
for table in tables:
@@ -387,7 +395,7 @@ def printer_totals(
from_date: Optional[str] = Query(default=None, alias="from"),
to_date: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
"""Returns totals per printer based on print_log entries in the date range."""
q = db.query(PrintLog).filter(PrintLog.success == True)
@@ -437,11 +445,13 @@ def printer_totals(
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
if item_ids is None:
item_ids = []
for item_id in item_ids:
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
if item and item.status in ("active", "paid"):
summary[pid]["items"] += item.quantity
val = item.unit_price * item.quantity
val = _item_val(item)
summary[pid]["total"] += val
order_map[pid][oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
@@ -510,7 +520,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
if item and item.status in ("active", "paid"):
items_count += item.quantity
val = item.unit_price * item.quantity
val = _item_val(item)
grand_total += val
product_name = item.product.name if item.product else f"#{item.product_id}"
if oid in order_map:
@@ -518,7 +528,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
order_map[oid]["items"].append({
"name": product_name,
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"unit_price": float((item.unit_price or 0.0) + (item.price_adjustment or 0.0)),
"total": round(val, 2),
})
# Accumulate item breakdown (always, regardless of mode)
@@ -551,7 +561,7 @@ def print_waiter(
body: PrintWaiterReportBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
@@ -577,7 +587,7 @@ def print_waiter(
order_data = []
for o in orders:
active_items = [i for i in o.items if i.status in ("active", "paid")]
total = sum(i.unit_price * i.quantity for i in active_items)
total = sum(_item_val(i) for i in active_items)
order_data.append({
"id": o.id,
"time_open": local_strftime(o.opened_at, "%H:%M"),
@@ -605,7 +615,7 @@ def print_waiter(
"to_dt": local_strftime(to_dt, "%d/%m/%Y %H:%M"),
}
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode)
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode, printer.codepage_n)
return {"status": "printing"}
@@ -614,7 +624,7 @@ def print_printer_totals(
body: PrintPrinterReportBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
# The physical printer that will receive the paper
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
@@ -674,7 +684,7 @@ def print_printer_totals(
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode, printer.codepage_n)
return {"status": "printing"}
@@ -710,7 +720,7 @@ def print_products(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product, Category
@@ -737,7 +747,7 @@ def print_products(
if pid not in sold:
sold[pid] = {"qty": 0, "revenue": 0.0}
sold[pid]["qty"] += item.quantity
sold[pid]["revenue"] += item.unit_price * item.quantity
sold[pid]["revenue"] += _item_val(item)
if body.mode == "full":
# All active products, 0-sold included
@@ -762,7 +772,7 @@ def print_products(
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report)
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report, printer.codepage_n)
return {"status": "printing"}
@@ -771,7 +781,7 @@ def print_categories(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product, Category
@@ -805,11 +815,11 @@ def print_categories(
cat = categories_db.get(cid)
summary[cid] = {"name": cat.name if cat else f"#{cid}", "units_sold": 0, "revenue": 0.0, "products": {}}
summary[cid]["units_sold"] += item.quantity
summary[cid]["revenue"] += item.unit_price * item.quantity
summary[cid]["revenue"] += _item_val(item)
if pid not in summary[cid]["products"]:
summary[cid]["products"][pid] = {"name": product.name, "qty": 0, "revenue": 0.0}
summary[cid]["products"][pid]["qty"] += item.quantity
summary[cid]["products"][pid]["revenue"] += item.unit_price * item.quantity
summary[cid]["products"][pid]["revenue"] += _item_val(item)
total_rev = sum(v["revenue"] for v in summary.values())
total_qty = sum(v["units_sold"] for v in summary.values())
@@ -841,7 +851,7 @@ def print_categories(
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report)
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report, printer.codepage_n)
return {"status": "printing"}
@@ -850,7 +860,7 @@ def print_tables(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
@@ -879,7 +889,7 @@ def print_tables(
"order_count": 0, "revenue": 0.0, "durations": [],
}
summary[tid]["order_count"] += 1
summary[tid]["revenue"] += sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
summary[tid]["revenue"] += sum(_item_val(i) for i in order.items if i.status in ("active", "paid"))
if order.closed_at and order.opened_at:
summary[tid]["durations"].append((order.closed_at - order.opened_at).total_seconds() / 60)
@@ -899,7 +909,7 @@ def print_tables(
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report)
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report, printer.codepage_n)
return {"status": "printing"}
@@ -915,7 +925,7 @@ def shifts_report(
to_dt: Optional[str] = Query(default=None, alias="to"),
active_only: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from routers.shifts import _enrich_shift
@@ -946,7 +956,7 @@ def product_performance(
business_day_id: Optional[int] = None,
category_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product
from models.waste import WasteLog
@@ -1000,7 +1010,7 @@ def product_performance(
"order_ids": set(),
}
qty = item.quantity
revenue = item.unit_price * qty
revenue = _item_val(item)
summary[pid]["qty_sold"] += qty
summary[pid]["revenue"] += revenue
summary[pid]["order_ids"].add(item.order_id)
@@ -1054,7 +1064,7 @@ def table_performance(
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if from_dt:
@@ -1081,7 +1091,7 @@ def table_performance(
}
summary[tid]["order_count"] += 1
summary[tid]["revenue"] += sum(
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
_item_val(i) for i in order.items if i.status in ("active", "paid")
)
if order.closed_at and order.opened_at:
summary[tid]["durations"].append(
@@ -1109,7 +1119,7 @@ def traffic_analysis(
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(Order)
if from_dt:
@@ -1126,7 +1136,7 @@ def traffic_analysis(
for order in orders:
revenue = sum(
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
_item_val(i) for i in order.items if i.status in ("active", "paid")
)
h = order.opened_at.hour
d = order.opened_at.weekday()
@@ -1155,7 +1165,7 @@ def business_days_list(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(BusinessDay)
if from_dt:
@@ -1180,7 +1190,7 @@ def business_days_list(
for i in o.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
rev = _item_val(i)
revenue += rev
if i.unit_cost is not None:
cost = i.unit_cost * i.quantity
@@ -1214,7 +1224,7 @@ def business_days_list(
@router.get("/business-days/current")
def current_business_day(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
day = db.query(BusinessDay).filter(BusinessDay.status == "open").order_by(BusinessDay.opened_at.desc()).first()
if not day:
@@ -1236,7 +1246,7 @@ def current_business_day(
for i in o.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
rev = _item_val(i)
revenue += rev
if i.unit_cost is not None:
cost = i.unit_cost * i.quantity
@@ -1304,7 +1314,7 @@ def revenue_trends(
to_dt: Optional[str] = Query(default=None, alias="to"),
granularity: str = Query(default="daily"), # daily | weekly | monthly
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if from_dt:
@@ -1329,7 +1339,7 @@ def revenue_trends(
for i in order.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
rev = _item_val(i)
buckets[key]["revenue"] += rev
if i.unit_cost is not None:
buckets[key]["profit"] += rev - i.unit_cost * i.quantity
@@ -1360,7 +1370,7 @@ def category_performance(
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product, Category
@@ -1397,7 +1407,7 @@ def category_performance(
"product_ids": set(),
}
qty = item.quantity
rev = item.unit_price * qty
rev = _item_val(item)
summary[cid]["units_sold"] += qty
summary[cid]["revenue"] += rev
summary[cid]["product_ids"].add(item.product_id)
@@ -1438,7 +1448,7 @@ def cancellations_log(
business_day_id: Optional[int] = None,
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
@@ -1495,7 +1505,7 @@ def cancellations_log(
"product_name": product_name,
"quantity": item.quantity,
"unit_price": item.unit_price,
"value": round(item.unit_price * item.quantity, 2),
"value": round(_item_val(item), 2),
"cancelled_by": cancelled_by_name,
"cancel_reason": getattr(item, "cancel_reason", None),
"cancelled_at": _dt(cancelled_at) if cancelled_at else _dt(item.added_at),
@@ -1516,7 +1526,7 @@ def printer_history(
business_day_id: Optional[int] = None,
printer_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(PrintLog)
if from_dt:
@@ -1549,6 +1559,8 @@ def printer_history(
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
if item_ids is None:
item_ids = []
items = []
for iid in item_ids:
oi = db.query(OrderItem).filter(OrderItem.id == iid).first()
@@ -1560,7 +1572,7 @@ def printer_history(
order_total = None
if order:
order_total = sum(
i.unit_price * i.quantity
_item_val(i)
for i in order.items
if i.status in ("active", "paid")
)
@@ -1593,16 +1605,16 @@ def printer_history(
@router.get("/meta/waiters")
def meta_waiters(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
return {"waiters": [{"id": w.id, "name": w.full_name or w.username} for w in waiters]}
@router.get("/meta/tables")
def meta_tables(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
tables = db.query(Table).filter(Table.is_active == True).all()
return {"tables": [{"id": t.id, "name": t.label or f"T{t.number}", "group": t.group.name if t.group else None} for t in tables]}
@@ -1611,7 +1623,7 @@ def meta_tables(
@router.get("/meta/printers")
def meta_printers(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
printers = db.query(Printer).filter(Printer.is_active == True).all()
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
@@ -1620,7 +1632,7 @@ def meta_printers(
@router.get("/meta/products")
def meta_products(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product, Category
products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
@@ -1665,7 +1677,7 @@ def shifts_export(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
data = shifts_report(waiter_id=waiter_id, business_day_id=business_day_id, from_dt=from_dt, to_dt=to_dt, active_only=False, db=db, user=user)
rows = []
@@ -1697,7 +1709,7 @@ def orders_export(
order_status: Optional[str] = Query(default=None, alias="status"),
table_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
orders = order_history(from_date=from_date, to_date=to_date, waiter_id=waiter_id, order_status=order_status, table_id=table_id, page=1, page_size=10000, db=db, user=user)
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
@@ -1712,7 +1724,7 @@ def orders_export(
"opened_at": _dt(o.opened_at),
"closed_at": _dt(o.closed_at) if o.closed_at else "",
"status": o.status,
"total": round(sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")), 2),
"total": round(sum(_item_val(i) for i in o.items if i.status in ("active", "paid")), 2),
})
date_str = (from_date or "")[:10]
return _csv_response(rows, f"orders-{date_str}.csv")
@@ -1725,7 +1737,7 @@ def products_export(
business_day_id: Optional[int] = None,
category_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
data = product_performance(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, category_id=category_id, db=db, user=user)
rows = [{
@@ -1747,7 +1759,7 @@ def printers_export(
printer_id: Optional[int] = None,
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
data = printer_history(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, printer_id=printer_id, db=db, user=user)
rows = [{
@@ -1770,7 +1782,7 @@ def cancellations_export(
business_day_id: Optional[int] = None,
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
data = cancellations_log(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, waiter_id=waiter_id, db=db, user=user)
rows = [{
@@ -1789,6 +1801,192 @@ def cancellations_export(
return _csv_response(rows, f"cancellations-{date_str}.csv")
# ── Prep Zones report ──────────────────────────────────────────────────────────
@router.get("/prep-zones")
def prep_zones_report(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_reports),
):
"""
Per-prep-zone stats: item totals, full product list, and full order list
for the requested date range / business day.
"""
zones = db.query(PrepZone).order_by(PrepZone.id).all()
# Build product → zones mapping
product_zone_map: dict[int, list[int]] = {}
for zone in zones:
for product in zone.products:
if product.id not in product_zone_map:
product_zone_map[product.id] = []
product_zone_map[product.id].append(zone.id)
# Query order items in the requested range
q = db.query(OrderItem).join(Order)
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
q = q.filter(OrderItem.status.in_(["active", "paid"]))
items = q.all()
# Aggregate per zone
zone_stats: dict[int, dict] = {
zone.id: {
"id": zone.id,
"name": zone.name,
"notification_name": zone.notification_name,
"printers": [{"id": p.id, "name": p.name} for p in zone.printers],
"item_count": 0,
"total_value": 0.0,
"product_counts": {}, # product_name → {count, value}
"orders_map": {}, # order_id → {order_id, table, opened_at, items_count, value}
}
for zone in zones
}
for item in items:
zids = product_zone_map.get(item.product_id, [])
pname = item.product.name if item.product else f"#{item.product_id}"
ivalue = round(_item_val(item), 2)
order = item.order
table_label = None
if order and order.table:
t = order.table
table_label = t.label or str(t.number)
for zid in zids:
if zid not in zone_stats:
continue
zs = zone_stats[zid]
zs["item_count"] += item.quantity
zs["total_value"] += ivalue
if pname not in zs["product_counts"]:
zs["product_counts"][pname] = {"count": 0, "value": 0.0}
zs["product_counts"][pname]["count"] += item.quantity
zs["product_counts"][pname]["value"] += ivalue
if order:
oid = order.id
if oid not in zs["orders_map"]:
zs["orders_map"][oid] = {
"order_id": oid,
"table": table_label or f"#{oid}",
"opened_at": order.opened_at.isoformat() if order.opened_at else None,
"items_count": 0,
"value": 0.0,
}
zs["orders_map"][oid]["items_count"] += item.quantity
zs["orders_map"][oid]["value"] += ivalue
result = []
for zs in zone_stats.values():
all_products = sorted(
[
{"name": k, "count": v["count"], "value": round(v["value"], 2)}
for k, v in zs["product_counts"].items()
],
key=lambda x: -x["count"],
)
all_orders = sorted(
[
{**o, "value": round(o["value"], 2)}
for o in zs["orders_map"].values()
],
key=lambda x: x["opened_at"] or "",
)
result.append({
"id": zs["id"],
"name": zs["name"],
"notification_name": zs["notification_name"],
"printers": zs["printers"],
"item_count": zs["item_count"],
"total_value": round(zs["total_value"], 2),
"all_products": all_products,
"all_orders": all_orders,
})
return {"zones": result}
class PrintPrepZoneBody(BaseModel):
printer_id: int
zone_id: int
from_dt: Optional[str] = None
to_dt: Optional[str] = None
business_day_id: Optional[int] = None
@router.post("/print/prep-zone")
def print_prep_zone(
body: PrintPrepZoneBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_reports),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Prep zone not found")
# Resolve period label
if body.business_day_id:
from models.business_day import BusinessDay
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
period_label = bd.opened_at.strftime("%d/%m/%Y") if bd and bd.opened_at else f"#{body.business_day_id}"
elif body.from_dt and body.to_dt:
d1 = datetime.fromisoformat(body.from_dt).strftime("%d/%m/%Y")
d2 = datetime.fromisoformat(body.to_dt).strftime("%d/%m/%Y")
period_label = f"{d1} - {d2}"
else:
period_label = "Όλες"
# Build product→zone mapping for this zone
product_ids = {product.id for product in zone.products}
q = db.query(OrderItem).join(Order)
if body.from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(body.from_dt))
if body.to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(body.to_dt))
if body.business_day_id:
q = q.filter(Order.business_day_id == body.business_day_id)
q = q.filter(OrderItem.status.in_(["active", "paid"]))
items = q.all()
product_counts: dict[str, dict] = {}
for item in items:
if item.product_id not in product_ids:
continue
pname = item.product.name if item.product else f"#{item.product_id}"
if pname not in product_counts:
product_counts[pname] = {"count": 0, "value": 0.0}
product_counts[pname]["count"] += item.quantity
product_counts[pname]["value"] += _item_val(item)
report_items = sorted(
[{"name": k, "count": v["count"], "value": round(v["value"], 2)} for k, v in product_counts.items()],
key=lambda x: -x["count"],
)
report = {
"zone_name": zone.name,
"period_label": period_label,
"items": report_items,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_prep_zone_summary, printer.ip_address, printer.port, report, printer.codepage_n)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Phase 2L — Discount audit report
# ---------------------------------------------------------------------------
@@ -1800,7 +1998,7 @@ def discounts_report(
business_day_id: Optional[int] = None,
applied_by: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.order import OrderDiscount, OrderItem
@@ -1836,10 +2034,10 @@ def discounts_report(
if d.item_id:
item = db.query(OrderItem).filter(OrderItem.id == d.item_id).first()
if item:
base = item.unit_price * item.quantity
base = _item_val(item)
return round(base * d.discount_value / 100, 2)
elif order:
base = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled")
base = sum(_item_val(i) for i in order.items if i.status != "cancelled")
return round(base * d.discount_value / 100, 2)
return 0.0
@@ -1852,7 +2050,7 @@ def discounts_report(
applier = users_map.get(d.applied_by)
applier_name = (applier.full_name or applier.username) if applier else f"#{d.applied_by}"
table_name = tables_map.get(order.table_id) if order and order.table_id else None
order_total = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled") if order else None
order_total = sum(_item_val(i) for i in order.items if i.status != "cancelled") if order else None
discount_amount = _compute_discount_amount(d)
total_discount_value += discount_amount

View File

@@ -142,7 +142,7 @@ def week_schedule(
WaiterShift.started_at <= week_end_dt,
).all()
waiters = {u.id: u for u in db.query(User).filter(User.role == "waiter", User.is_active == True).all()}
waiters = {u.id: u for u in db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()}
def _dt(dt):
if not dt:

View File

@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from database import get_db
from models.settings import PosSettings
from schemas.settings import UpdateSettingRequest
from routers.deps import get_current_user, require_manager
from routers.deps import get_current_user, require_settings_manager
from models.user import User
router = APIRouter()
@@ -20,6 +20,7 @@ VALID_SETTINGS = {
"security.auto_logout_seconds":"Seconds of inactivity before logging out (0 = disabled)",
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
"shifts.hide_revenue_from_waiters": "Hide ΕΙΣΠΡΑΞΗ and ΠΑΡΑΔ. ΠΟΣΟ stats from waiters in Shift Overview: 'true' | 'false'",
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
@@ -43,6 +44,27 @@ VALID_SETTINGS = {
"print.beep_pattern": "Beep pattern: 'single' | 'double' | 'triple' | 'long' | 'custom:n1:n2:n3'",
# Phase 2 — cancellations
"orders.waiter_cancellations_allowed": "Allow waiters with per-account permission to cancel sent orders: 'true' | 'false'",
"orders.waiter_price_adjust_allowed": "Allow waiters to adjust item prices (before or after ordering): 'true' | 'false'",
# Payment options
"payments.card_enabled": "Allow waiters to record card payments (in addition to cash): 'true' | 'false'",
"payments.waiter_revert_allowed": "Allow waiters to revert individual paid items back to unpaid: 'true' | 'false'",
# Tables & orders behaviour
"orders.auto_close_on_full_payment": "Automatically close the table order when all items have been paid: 'true' | 'false'",
"orders.bypass_kds_serve": "Skip KDS pending/serve flow — new orders and items are immediately marked as served: 'true' | 'false'",
# Courses
"orders.courses_enabled": "Enable per-item course assignment when ordering: 'true' | 'false'",
"orders.courses": "JSON array of course objects [{id, name, color}] defining available courses",
# Quick notes
"orders.quick_notes": "JSON array of quick-note strings shown in the waiter note tab",
# Fiscal printer (ΦΗΜ)
"fiscal.enabled": "Master switch for fiscal printing: 'true' | 'false'",
"fiscal.type": "Fiscal driver type: 'txt_file' (dTEC100extra) — more types coming",
"fiscal.out_folder": "Path the backend writes fiscal command files TO (input for the fiscal driver)",
"fiscal.in_folder": "Path the backend reads fiscal reply files FROM (output of the fiscal driver)",
"fiscal.clerk_id": "Clerk ID sent in CR/CD commands (integer string, e.g. '2')",
"fiscal.eftpos_id": "EFTPOS terminal ID sent in CD commands (integer string, e.g. '1')",
"fiscal.end_message": "JSON array of up to 5 strings printed at end of receipt via FM commands",
"fiscal.vat_groups": "JSON array of VAT group definitions: [{machine_id: int, friendly_name: str}]",
}
DEFAULTS = {
@@ -54,9 +76,10 @@ DEFAULTS = {
"security.auto_logout_seconds": "1800",
"shifts.waiter_self_start": "true",
"shifts.waiter_self_end": "true",
"shifts.hide_revenue_from_waiters": "false",
"business_day.force_close_allowed": "true",
"system.timezone": "Europe/Athens",
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}}}',
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"},"kds_ready":{"cardBg":"#22c55e","cardBg2":"#ffffff","badgeBg":"rgba(255,255,255,0.92)","badgeBg2":"rgba(255,255,255,0.92)","nameText":"#ffffff","nameText2":"#15803d","badgeText":"#15803d","badgeText2":"#22c55e","flash":true}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"},"kds_ready":{"cardBg":"#16a34a","cardBg2":"#ffffff","badgeBg":"rgba(255,255,255,0.92)","badgeBg2":"rgba(255,255,255,0.92)","nameText":"#ffffff","nameText2":"#bbf7d0","badgeText":"#bbf7d0","badgeText2":"#16a34a","flash":true}}}',
"dev.spoof_printing": "false",
"print.ticket_mode": "detailed",
"print.divider_style": "dash",
@@ -72,6 +95,23 @@ DEFAULTS = {
"print.font_order_note": "0:1:0",
"print.beep_on_ticket": "true",
"print.beep_pattern": "double",
"payments.card_enabled": "false",
"payments.waiter_revert_allowed": "false",
"orders.auto_close_on_full_payment": "false",
"orders.bypass_kds_serve": "false",
"orders.waiter_price_adjust_allowed": "false",
"orders.courses_enabled": "false",
"orders.courses": "[]",
"orders.quick_notes": '["Χωρίς αλάτι","Βγάλτε γρήγορα","Αλλεργία!","Κόψτε σε μικρά κομμάτια","Έξτρα χαρτοπετσέτες"]',
# Fiscal printer (ΦΗΜ)
"fiscal.enabled": "false",
"fiscal.type": "txt_file",
"fiscal.out_folder": "",
"fiscal.in_folder": "",
"fiscal.clerk_id": "2",
"fiscal.eftpos_id": "1",
"fiscal.end_message": "[]",
"fiscal.vat_groups": "[]",
}
@@ -95,7 +135,7 @@ def update_setting(
key: str,
body: UpdateSettingRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_settings_manager),
):
if key not in VALID_SETTINGS:
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")

View File

@@ -6,6 +6,7 @@ import bcrypt
from database import get_db
from models.user import User
from services.chat_service import add_user_to_system_group
router = APIRouter()
@@ -49,7 +50,7 @@ def security_config(db: Session = Depends(get_db)):
@router.get("/status", response_model=SetupStatusResponse)
def setup_status(db: Session = Depends(get_db)):
has_manager = db.query(User).filter(
User.role.in_(["manager", "sysadmin"]),
User.perm_access_dashboard == True,
User.is_active == True,
).first()
return SetupStatusResponse(needs_setup=has_manager is None)
@@ -58,7 +59,7 @@ def setup_status(db: Session = Depends(get_db)):
@router.post("/init", response_model=SetupInitResponse)
def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
has_manager = db.query(User).filter(
User.role.in_(["manager", "sysadmin"]),
User.perm_access_dashboard == True,
User.is_active == True,
).first()
if has_manager:
@@ -87,9 +88,13 @@ def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
password_hash=password_hash,
email=body.email,
full_name=body.full_name,
role="manager",
role="store_manager",
is_active=True,
)
# Apply store_manager default permissions
from roles import get_default_permissions
for field, value in get_default_permissions("store_manager").items():
setattr(user, field, value)
db.add(user)
# Persist venue settings if provided
@@ -113,4 +118,6 @@ def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
db.add(PosSettings(key="venue.type", value=body.venue_type, updated_at=now))
db.commit()
db.refresh(user)
add_user_to_system_group(db, user.id)
return SetupInitResponse(ok=True)

View File

@@ -35,6 +35,24 @@ def compute_shift_total(shift_id: int, db: Session) -> float:
return round(sum(i.unit_price * i.quantity for i in items), 2)
def compute_shift_payment_split(shift_id: int, db: Session) -> dict:
"""Return cash_sales and card_sales for a shift (items paid in this shift)."""
items = db.query(OrderItem).filter(
OrderItem.paid_in_shift_id == shift_id,
OrderItem.status == "paid",
).all()
cash = 0.0
card = 0.0
for i in items:
amount = float(i.unit_price) * i.quantity
method = (i.payment_method or "cash").lower()
if method == "card":
card += amount
else:
cash += amount
return {"cash_sales": round(cash, 2), "card_sales": round(card, 2)}
def compute_shift_pay(shift: WaiterShift) -> dict:
"""Return duration_hours and shift_pay. shift_pay is None if no rate snapshot."""
now = datetime.now(timezone.utc)
@@ -74,6 +92,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
w = shift.waiter
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
split = compute_shift_payment_split(shift.id, db)
pay_data = compute_shift_pay(shift)
# Count cancelled items and their value attributed to this waiter during shift window
cancelled_q = db.query(OrderItem).filter(
@@ -96,7 +115,9 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"ended_at": _dt(shift.ended_at),
"starting_cash": shift.starting_cash,
"total_collected": total,
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"cash_sales": split["cash_sales"],
"card_sales": split["card_sales"],
"net_to_deliver": round(split["cash_sales"] + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
@@ -105,9 +126,11 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"cancellation_events": cancellation_events,
"cancellations": cancellations,
"cancellation_value": cancellation_value,
# Phase 2E
# Phase 2E — discrepancy computed live against cash-only expected (starting_cash + cash_sales)
"counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": shift.cash_discrepancy,
"cash_discrepancy": round(
shift.counted_cash_end - (split["cash_sales"] + (shift.starting_cash or 0.0)), 2
) if shift.counted_cash_end is not None else None,
"breaks": [
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
for b in shift.breaks
@@ -133,14 +156,14 @@ def start_shift(
target_id = body.waiter_id
if target_id and target_id != user.id:
if user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
if not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Only managers can start shifts for other staff")
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="Waiter not found")
raise HTTPException(status_code=404, detail="Staff member not found")
else:
target_id = user.id
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
if not user.perm_access_dashboard and user.role != "superadmin" and _get_setting(db, "shifts.waiter_self_start") != "true":
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
@@ -173,7 +196,7 @@ def end_shift(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
if not user.perm_access_dashboard and user.role != "superadmin" and _get_setting(db, "shifts.waiter_self_end") != "true":
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
shift = db.query(WaiterShift).filter(
@@ -189,11 +212,9 @@ def end_shift(
shift.ended_at = now
if body.notes:
shift.notes = body.notes
# Phase 2E: cash reconciliation
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
if body.counted_cash_end is not None:
shift.counted_cash_end = body.counted_cash_end
expected = (shift.starting_cash or 0.0) + total
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
@@ -262,11 +283,9 @@ def manager_end_shift(
shift.ended_at = now
if body.notes:
shift.notes = body.notes
# Phase 2E: cash reconciliation
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
if body.counted_cash_end is not None:
shift.counted_cash_end = body.counted_cash_end
expected = (shift.starting_cash or 0.0) + total
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
@@ -288,7 +307,7 @@ def start_break(
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Access denied")
if shift.ended_at:
raise HTTPException(status_code=400, detail="Shift already ended")
@@ -315,7 +334,7 @@ def end_break(
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Access denied")
open_break = db.query(ShiftBreak).filter(

View File

@@ -125,7 +125,7 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name)
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name, printer.codepage_n)
return {"success": success, "error": error}
@@ -134,7 +134,7 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width)
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width, printer.codepage_n)
return {"success": success, "error": error}
@@ -143,7 +143,7 @@ def test_beep(printer_id: int, n1: int = 2, n2: int = 2, n3: int = 1, db: Sessio
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3)
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3, printer.codepage_n)
return {"success": success, "error": error}
@@ -273,8 +273,8 @@ def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current
"products": db.query(Product).filter(Product.lifecycle_status == "active").count(),
"tables": db.query(Table).filter(Table.is_active == True).count(),
"table_groups": db.query(TableGroup).count(),
"managers": db.query(User).filter(User.role == "manager", User.is_active == True).count(),
"waiters": db.query(User).filter(User.role == "waiter", User.is_active == True).count(),
"managers": db.query(User).filter(User.perm_access_dashboard == True, User.is_active == True).count(),
"waiters": db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).count(),
}

View File

@@ -13,7 +13,7 @@ from schemas.table import (
TableGroupCreate, TableGroupUpdate, TableGroupOut,
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
)
from routers.deps import get_current_user, require_manager
from routers.deps import get_current_user, require_tables_manager
from services.sse_bus import broadcast_sync
# Tables with a pending reservation due within this many hours get the RESERVED badge
@@ -30,7 +30,7 @@ def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
raise HTTPException(status_code=400, detail="Group name already exists")
sort_order = db.query(TableGroup).count()
@@ -42,14 +42,14 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
@router.put("/groups/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
for idx, group_id in enumerate(body):
db.query(TableGroup).filter(TableGroup.id == group_id).update({"sort_order": idx})
db.commit()
@router.put("/groups/{group_id}", response_model=TableGroupOut)
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -61,7 +61,7 @@ def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(ge
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -84,7 +84,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
q = q.filter(Table.is_active == True)
# Zone-based filtering for waiters
if user.role not in ("manager", "sysadmin"):
if user.role != "superadmin" and not user.perm_access_dashboard:
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
# No zone rows → sees nothing
if not zones:
@@ -134,9 +134,9 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
number = _next_global_number(db)
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True, seat_count=body.seat_count)
db.add(table)
db.commit()
db.refresh(table)
@@ -145,7 +145,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
if body.count < 1 or body.count > 200:
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
@@ -194,7 +194,7 @@ def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), u
@router.put("/{table_id}", response_model=TableOut)
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")
@@ -206,7 +206,7 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")
@@ -250,7 +250,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
@router.put("/{table_id}/floorplan", response_model=TableOut)
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")

View File

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

View File

@@ -0,0 +1,87 @@
"""
WebSocket endpoint — one persistent connection per connected client (waiter PWA, KDS).
Authentication: JWT token passed as query param ?token=<jwt>
(Same pattern as SSE — browser WebSocket API also cannot set custom headers.)
Protocol:
1. Client connects: ws://host/api/ws?token=<jwt>
2. Client immediately sends: { "cursor": <last_seq_id> }
(0 = first connect / no history; any positive int = resume from that point)
3. Server replays all missed events (seq > cursor) as individual frames
4. Server sends { "type": "ready" } to signal end of replay / start of live stream
5. Live events arrive as: { "seq": 123, "type": "...", "data": { ... } }
6. Every 25s server sends: { "type": "ping" }
7. Client may respond: { "type": "pong" } (ignored if not sent)
8. On disconnect/error the client reconnects and repeats from step 2
"""
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from starlette.websockets import WebSocketState
from routers.deps import decode_token
from services.ws_bus import connect, disconnect, get_events_since
router = APIRouter()
logger = logging.getLogger(__name__)
KEEPALIVE_INTERVAL = 25 # seconds
@router.websocket("/connect")
async def ws_endpoint(websocket: WebSocket, token: str = Query(...)):
# ── Auth ──────────────────────────────────────────────────────────────────
try:
payload = decode_token(token)
user_id: int = int(payload["sub"])
except Exception:
await websocket.close(code=4001)
return
await websocket.accept()
# ── Wait for cursor frame ─────────────────────────────────────────────────
cursor = 0
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
msg = json.loads(raw)
cursor = int(msg.get("cursor", 0))
except (asyncio.TimeoutError, Exception):
pass # no cursor sent — treat as fresh connect (cursor=0)
# ── Register this connection ──────────────────────────────────────────────
q = await connect(user_id)
try:
# ── Replay missed events ──────────────────────────────────────────────
if cursor > 0:
missed = get_events_since(cursor, user_id)
for event in missed:
await websocket.send_text(json.dumps(event))
# Signal end of replay / start of live stream
await websocket.send_text(json.dumps({"type": "ready"}))
# ── Live stream loop ──────────────────────────────────────────────────
while True:
try:
frame = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.send_text(frame)
except asyncio.TimeoutError:
# Send keepalive ping
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.send_text(json.dumps({"type": "ping"}))
else:
break
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug("ws_endpoint: connection closed for user %s: %s", user_id, e)
finally:
await disconnect(user_id, q)