diff --git a/docs/advanced.md b/docs/advanced.md index 2aa7af29..a70079b7 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,6 +1,6 @@ # Advanced Topics -!!! info "First Of All" +!!! info "Optional Features" Take note that none of this section is required to run the base program. These are just some fun additions, which you can optionally use. If you are not interested in them, just skip this section. diff --git a/docs/payment.md b/docs/payment.md index fff66763..97d951fd 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -1,6 +1,6 @@ # Setting Up and Using the Payment Feature -!!! info "First Of All" +!!! info "Optional Feature" Take note that none of this section is required to run the base program. This is a way to run your CocktailBerry machines in a more commercial mode. If you are not interested in them, just skip this section. diff --git a/docs/setup.md b/docs/setup.md index 1e5e54fe..b9854917 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -77,13 +77,16 @@ They can be used at own risk of CocktailBerry not working 100% properly. ??? info "List Software Config Values" Software config values are used to configure additional connected software and its behavior. - | Value Name | Description | - | :---------------------- | :--------------------------------------------------------- | - | `MICROSERVICE_ACTIVE` | Post to microservice set up by docker | - | `MICROSERVICE_BASE_URL` | Base URL for microservice (default: http://127.0.0.1:5000) | - | `TEAMS_ACTIVE` | Use teams feature | - | `TEAM_BUTTON_NAMES` | List of format ["Team1", "Team2"] | - | `TEAM_API_URL` | Endpoint of teams API, default used port by API is 8080 | + | Value Name | Description | + | :----------------------------- | :--------------------------------------------------------- | + | `MICROSERVICE_ACTIVE` | Post to microservice set up by docker | + | `MICROSERVICE_BASE_URL` | Base URL for microservice (default: http://127.0.0.1:5000) | + | `TEAMS_ACTIVE` | Use teams feature | + | `TEAM_BUTTON_NAMES` | List of format ["Team1", "Team2"] | + | `TEAM_API_URL` | Endpoint of teams API, default used port by API is 8080 | + | `WAITER_MODE` | Enable or disable Service Personnel Mode | + | `WAITER_LOGOUT_AFTER_COCKTAIL` | Log out after cocktail preparation | + | `WAITER_AUTO_LOGOUT_S` | Log out after x seconds of inactivity (0 = disabled) | ??? info "Payment Related Config Values" Payment config values are used to configure the payment system and its behavior. diff --git a/docs/waiter.md b/docs/waiter.md new file mode 100644 index 00000000..e828a544 --- /dev/null +++ b/docs/waiter.md @@ -0,0 +1,100 @@ +# Service Personnel Mode + +!!! info "Optional Feature" + Service Personnel Mode is an optional feature for operators who want to track which staff member prepared each cocktail and control access to the machine. + If you don't need staff tracking or per-person access control, you can skip this section. + +!!! warning "NFC Reader Conflict" + Service Personnel Mode and CocktailBerry NFC Payment both require the NFC reader. + They cannot be enabled at the same time, nor is the initial intend that they would be used together. + If you need payment functionality, see the [Payment Feature](payment.md) instead. + +## Overview + +Service Personnel Mode lets you manage who is using your CocktailBerry machine. +Each staff member gets an NFC chip that they scan to log in before they can prepare cocktails. +This gives you two main benefits: + +- **Accountability**: Every cocktail is logged with the name of the person who made it, giving you full visibility over your operation. +- **Access Control**: Authorized staff can bypass the maker password for different specified tabs. So you do not need to share the password, but still control who can access what. + +## Enabling Service Personnel Mode + +To enable the feature, activate `WAITER_MODE` in the configuration, you can find it under the software section. +You will need an NFC reader connected to your machine (same hardware as for the [payment](payment.md) feature). +On startup, CocktailBerry validates that the NFC reader is available and disables the mode gracefully if it is not. + +??? info "Configuration Options" + + | Setting | Description | + | ------------------------------ | ---------------------------------------------------- | + | `WAITER_MODE` | Enable or disable Service Personnel Mode | + | `WAITER_LOGOUT_AFTER_COCKTAIL` | Log out after cocktail preparation | + | `WAITER_AUTO_LOGOUT_S` | Log out after x seconds of inactivity (0 = disabled) | + +## Registering Staff + +Before your staff can use the machine, you need to register their NFC chips. +Open the Service Personnel management window from the options menu. +When a staff member scans their NFC chip, the scanned ID appears in the management view. +You can then assign a name and select which permissions that person should have (Maker, Ingredients, Recipes, Bottles). +Each permission controls whether the person can access that tab, and whether they can bypass the maker password for it. + +## Login and Logout Flow + +The typical workflow looks like this: + +```mermaid +sequenceDiagram + actor s as Staff Member + participant nfc as NFC Reader + participant cb as CocktailBerry + participant db as Database + s->>nfc: Scans NFC Chip + nfc->>cb: Reads Chip ID + cb->>db: Looks Up Staff Member + db->>cb: Returns Name & Permissions + cb->>cb: Staff Member Logged In + s->>cb: Prepares Cocktail + cb->>db: Logs Cocktail with Staff Info + cb->>cb: Auto-Logout (if configured) +``` + +Once logged in, the staff member can then prepare cocktails, and each preparation is logged to their name. +Logout happens either manually, automatically after a cocktail, or after a configured timeout. + +### Difference in Appearance Between v1 and v2 + +Since we use distinct GUI technologies for v1 (Qt) and v2 (Web), there are some differences in how the logged-in staff member is displayed and how logout works. +See the according section for your version below. + +=== "v1" + + On the maker view, there is no dedicated staff indicator or logout button. + Logout happens through the configured auto-logout settings or by scanning a different chip. + +=== "v2" + + The currently logged-in staff member is displayed as an inline badge on the maker screen, showing their name. + Clicking the badge reveals a dedicated logout button, allowing the staff member to log out directly. + +### Password Bypass + +If a staff member has the appropriate permissions, they can bypass the maker password for the tabs they have access to. +See some specifics on implementation and differences between v1 and v2 below. + +=== "v1" + + Staff members need to be logged in (e.g. by scanning their NFC chip) before they can access a protected tab without entering the maker password. + Scanning the NFC chip while already be prompted for the password will not remove the password, so be sure to log in before navigating to the protected tab. + +=== "v2" + + Staff members can also scan the NFC when prompted for the maker password, which will bypass the password if they have the required permissions. + +## Statistics + +Every cocktail prepared while a staff member is logged in is recorded with their name, the recipe, volume, and timestamp. +You can view these logs in the Statistics tab of the Service Personnel management window. +Logs are grouped by date and staff member, showing the total number of cocktails and volume per person per day. +This helps you track performance and accountability across your team. diff --git a/justfile b/justfile index 8ca7dcf6..75408b8d 100644 --- a/justfile +++ b/justfile @@ -5,7 +5,7 @@ set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] # Global variables -qt_ui_files := "available bonusingredient bottlewindow cocktailmanager calibration calibration_real calibration_target customdialog datepicker keyboard optionwindow numpad progressbarwindow teamselection passworddialog customprompt logwindow rfidwriter wifiwindow customcolor addonwindow addonmanager datawindow cocktail_selection picture_window refill_prompt config_window resource_window news_window sumup_reader_window event_window" +qt_ui_files := "available bonusingredient bottlewindow cocktailmanager calibration calibration_real calibration_target customdialog datepicker keyboard optionwindow numpad progressbarwindow teamselection passworddialog customprompt logwindow rfidwriter wifiwindow customcolor addonwindow addonmanager datawindow cocktail_selection picture_window refill_prompt config_window resource_window news_window sumup_reader_window event_window waiter_window" # Install all Python dependencies (main + dev) [group('Python Environment & Dependencies')] diff --git a/src/api/api.py b/src/api/api.py index f3f4496d..492083b3 100644 --- a/src/api/api.py +++ b/src/api/api.py @@ -14,7 +14,7 @@ from src.api.internal.log_config import log_config from src.api.internal.validation import ValidationError from src.api.models import AboutInfo, ApiMessage -from src.api.routers import bottles, cocktails, ingredients, options +from src.api.routers import bottles, cocktails, ingredients, options, waiters from src.config.config_manager import CONFIG as cfg from src.config.config_manager import shared from src.config.errors import ConfigError @@ -26,7 +26,14 @@ from src.programs.addons import ADDONS, CouldNotInstallAddonError from src.resource_stats import start_resource_tracker from src.service.nfc_payment_service import NFCPaymentService -from src.startup_checks import can_update, check_payment_service, connection_okay, is_python_deprecated +from src.service.waiter_service import WaiterService +from src.startup_checks import ( + can_update, + check_payment_service, + check_waiter_mode, + connection_okay, + is_python_deprecated, +) from src.updater import UpdateInfo, Updater from src.utils import get_platform_data @@ -53,6 +60,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: _logger.warning(f"Payment service check failed: {payment_check.reason}. Disabling payment.") cfg.PAYMENT_TYPE = "Disabled" shared.startup_payment_issue.set_issue(message=payment_check.reason) + waiter_check = check_waiter_mode() + if not waiter_check.ok: + _logger.warning(f"Waiter mode check failed: {waiter_check.reason}. Disabling waiter mode.") + cfg.WAITER_MODE = False + shared.startup_waiter_issue.set_issue(message=waiter_check.reason) mc = MachineController() mc.init_machine() ADDONS.setup_addons() @@ -70,6 +82,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: ADDONS.start_trigger_loop() if cfg.cocktailberry_payment: NFCPaymentService().start_continuous_sensing() + if cfg.waiter_mode_active: + WaiterService().start_continuous_sensing() yield mc.cleanup() @@ -139,6 +153,8 @@ async def validation_error_handler(request: Request, exc: ValidationError) -> JS app.include_router(options.protected_router) app.include_router(ingredients.router) app.include_router(ingredients.protected_router) +app.include_router(waiters.router) +app.include_router(waiters.protected_router) @app.get("/", tags=[Tags.TESTING], summary="Test endpoint, check if api works") diff --git a/src/api/api_config.py b/src/api/api_config.py index 275fae42..680d31c2 100644 --- a/src/api/api_config.py +++ b/src/api/api_config.py @@ -45,6 +45,7 @@ class Tags(StrEnum): INGREDIENTS = "ingredients" OPTIONS = "options" PAYMENT = "payment" + WAITERS = "waiters" MAKER_PROTECTED = "maker protected" MASTER_PROTECTED = "master protected" TESTING = "testing" @@ -75,6 +76,10 @@ class Tags(StrEnum): "name": Tags.PAYMENT, "description": "Operation related to the payment service.", }, + { + "name": Tags.WAITERS, + "description": "Service Personnel mode management and tracking.", + }, { "name": Tags.MAKER_PROTECTED, "description": "Need x-maker-key header with the maker password, if this section is set to protected and password is set.", # noqa: E501 diff --git a/src/api/middleware.py b/src/api/middleware.py index b28099d9..405a76e1 100644 --- a/src/api/middleware.py +++ b/src/api/middleware.py @@ -4,7 +4,7 @@ from fastapi.security import APIKeyHeader from src.config.config_manager import CONFIG as cfg -from src.config.config_manager import Tab +from src.config.config_manager import Tab, shared master_password_header = APIKeyHeader(name="x-master-key", scheme_name="Master Password", auto_error=False) maker_password_header = APIKeyHeader(name="x-maker-key", scheme_name="Maker Password", auto_error=False) @@ -19,6 +19,20 @@ def _parse_password(password: str | None) -> int: raise HTTPException(status_code=403, detail="Invalid Password") +def _waiter_has_tab_permission(tab: Tab) -> bool: + waiter = shared.current_waiter + if waiter is None: + return False + + permission_by_tab = { + Tab.MAKER: waiter.permissions.maker, + Tab.INGREDIENTS: waiter.permissions.ingredients, + Tab.RECIPES: waiter.permissions.recipes, + Tab.BOTTLES: waiter.permissions.bottles, + } + return permission_by_tab.get(tab, False) + + def master_protected_dependency(master_password: str | None = Security(master_password_header)) -> None: if cfg.UI_MASTERPASSWORD == 0: return @@ -33,6 +47,8 @@ def dependency(maker_password: str | None = Security(maker_password_header)) -> return if not cfg.UI_LOCKED_TABS[tab]: return + if cfg.waiter_mode_active and _waiter_has_tab_permission(tab): + return password = _parse_password(maker_password) if password != cfg.UI_MAKER_PASSWORD: raise HTTPException(status_code=403, detail="Invalid Maker Password") diff --git a/src/api/models.py b/src/api/models.py index 3017b3f5..3d04807b 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -1,4 +1,6 @@ -from typing import Annotated, TypeVar +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, TypeVar from annotated_types import Len from pydantic import BaseModel, Field @@ -6,6 +8,9 @@ from src.config.config_manager import StartupIssue from src.models import Event, PrepareResult +if TYPE_CHECKING: + from src.db_models import DbWaiter + T = TypeVar("T") @@ -29,8 +34,8 @@ class CocktailIngredient(BaseModel): class CocktailsAndIngredients(BaseModel): - cocktails: list["Cocktail"] - ingredients: list["Ingredient"] + cocktails: list[Cocktail] + ingredients: list[Ingredient] class Cocktail(BaseModel): @@ -117,6 +122,7 @@ class IssueData(BaseModel): internet: StartupIssue config: StartupIssue payment: StartupIssue + waiter: StartupIssue class DateTimeInput(BaseModel): @@ -148,3 +154,54 @@ class SumupReaderResponse(BaseModel): class SumupReaderCreate(BaseModel): name: str pairing_code: str + + +class WaiterPermissions(BaseModel): + maker: bool = False + ingredients: bool = False + recipes: bool = False + bottles: bool = False + + +class WaiterResponse(BaseModel): + nfc_id: str + name: str + permissions: WaiterPermissions + + @classmethod + def from_db(cls, waiter: DbWaiter) -> WaiterResponse: + return cls( + nfc_id=waiter.nfc_id, + name=waiter.name, + permissions=WaiterPermissions( + maker=waiter.privilege_maker, + ingredients=waiter.privilege_ingredients, + recipes=waiter.privilege_recipes, + bottles=waiter.privilege_bottles, + ), + ) + + +class WaiterCreate(BaseModel): + nfc_id: str + name: str + permissions: WaiterPermissions | None = None + + +class WaiterUpdate(BaseModel): + name: str | None = None + permissions: WaiterPermissions | None = None + + +class WaiterLogEntry(BaseModel): + id: int + timestamp: str + waiter_name: str + recipe_name: str + volume: int + is_virgin: bool + + +class CurrentWaiterState(BaseModel): + nfc_id: str | None = None + waiter: WaiterResponse | None = None diff --git a/src/api/routers/options.py b/src/api/routers/options.py index f7da63bc..43e2177b 100644 --- a/src/api/routers/options.py +++ b/src/api/routers/options.py @@ -364,6 +364,7 @@ async def check_issues() -> IssueData: internet=shared.startup_need_time_adjustment, config=shared.startup_config_issue, payment=shared.startup_payment_issue, + waiter=shared.startup_waiter_issue, ) @@ -373,6 +374,7 @@ async def ignore_issues() -> ApiMessage: shared.startup_need_time_adjustment.set_ignored() shared.startup_config_issue.set_ignored() shared.startup_payment_issue.set_ignored() + shared.startup_waiter_issue.set_ignored() return ApiMessage(message="Issues ignored") diff --git a/src/api/routers/waiters.py b/src/api/routers/waiters.py new file mode 100644 index 00000000..ebeca75e --- /dev/null +++ b/src/api/routers/waiters.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import asyncio +import contextlib + +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect + +from src.api.api_config import Tags +from src.api.internal.utils import not_on_demo +from src.api.middleware import master_protected_dependency +from src.api.models import ApiMessage, CurrentWaiterState, WaiterCreate, WaiterLogEntry, WaiterResponse, WaiterUpdate +from src.config.config_manager import CONFIG as cfg +from src.config.config_manager import shared +from src.database_commander import DatabaseCommander +from src.logger_handler import LoggerHandler +from src.service.waiter_service import WaiterService + +_logger = LoggerHandler("waiter_router") + + +router = APIRouter(prefix="/waiters", tags=[Tags.WAITERS]) +protected_router = APIRouter( + prefix="/waiters", + tags=[Tags.WAITERS, Tags.MASTER_PROTECTED], + dependencies=[Depends(master_protected_dependency)], +) + + +@router.get("/current", summary="Get the current waiter state") +async def get_current_waiter() -> WaiterResponse | None: + """Get the current registered waiter, or null if none is logged in.""" + return shared.current_waiter # pyright: ignore[reportAttributeAccessIssue] + + +@router.post("/logout", summary="Log out the current waiter") +async def logout_current_waiter() -> ApiMessage: + """Log out the current waiter by clearing shared state and notifying clients.""" + WaiterService().logout_waiter() + _notify_waiter_callbacks() + return ApiMessage(message="Service Personnel logged out") + + +@protected_router.get("", summary="List all registered waiters") +async def get_waiters() -> list[WaiterResponse]: + """Get all registered waiters.""" + DBC = DatabaseCommander() + waiters = DBC.get_all_waiters() + return [WaiterResponse.from_db(w) for w in waiters] + + +@protected_router.post("", summary="Register a new waiter", dependencies=[not_on_demo]) +async def create_waiter(data: WaiterCreate) -> WaiterResponse: + """Register a new waiter with NFC ID and name.""" + DBC = DatabaseCommander() + permissions = data.permissions.model_dump() if data.permissions else None + waiter = DBC.create_waiter(data.nfc_id, data.name, permissions=permissions) + response = WaiterResponse.from_db(waiter) + # If the newly registered NFC ID is the currently scanned one, update shared state + if shared.current_waiter_nfc_id == data.nfc_id: + shared.current_waiter = response + _notify_waiter_callbacks() + return response + + +@protected_router.put("/{nfc_id}", summary="Update a waiter", dependencies=[not_on_demo]) +async def update_waiter(nfc_id: str, data: WaiterUpdate) -> WaiterResponse: + """Update a waiter's name and/or permissions.""" + DBC = DatabaseCommander() + permissions = data.permissions.model_dump() if data.permissions else None + waiter = DBC.update_waiter(nfc_id, name=data.name, permissions=permissions) + response = WaiterResponse.from_db(waiter) + # Update shared state if this is the current waiter + if shared.current_waiter_nfc_id == nfc_id: + shared.current_waiter = response + _notify_waiter_callbacks() + return response + + +@protected_router.delete("/{nfc_id}", summary="Delete a waiter", dependencies=[not_on_demo]) +async def delete_waiter(nfc_id: str) -> ApiMessage: + """Delete a waiter by NFC ID. Unsets current waiter if it matches.""" + DBC = DatabaseCommander() + DBC.delete_waiter(nfc_id) + # Unset current waiter if they were just deleted + if shared.current_waiter_nfc_id == nfc_id: + shared.current_waiter = None + _notify_waiter_callbacks() + return ApiMessage(message="Service Personnel deleted successfully") + + +@protected_router.get("/logs", summary="Get waiter cocktail logs") +async def get_waiter_logs() -> list[WaiterLogEntry]: + """Get all waiter cocktail logs with waiter and recipe names.""" + DBC = DatabaseCommander() + logs = DBC.get_waiter_logs() + return [ + WaiterLogEntry( + id=log.id, + timestamp=log.timestamp.strftime("%Y-%m-%d %H:%M:%S"), + waiter_name=log.waiter.name if log.waiter else "Deleted User", + recipe_name=log.recipe.name if log.recipe else "Deleted Recipe", + volume=log.volume, + is_virgin=log.is_virgin, + ) + for log in logs + ] + + +@router.websocket("/ws/current") +async def websocket_waiter_current(websocket: WebSocket) -> None: + """WebSocket endpoint for real-time waiter state updates. + + Pushes current waiter state when the waiter changes (NFC scan, logout, etc.). + """ + await websocket.accept() + + if not cfg.waiter_mode_active: + await websocket.close() + return + + loop = asyncio.get_running_loop() + + async def send_waiter_state() -> None: + """Send current waiter state to websocket.""" + try: + state = CurrentWaiterState(nfc_id=shared.current_waiter_nfc_id, waiter=shared.current_waiter) + await websocket.send_json(state.model_dump()) + except Exception as e: + _logger.debug(f"Error sending waiter update via websocket: {e}") + + callback_name = f"websocket_{id(websocket)}" + + def waiter_callback() -> None: + """Schedule async send from another thread.""" + asyncio.run_coroutine_threadsafe(send_waiter_state(), loop) + + waiter_service = WaiterService() + waiter_service.add_callback(callback_name, waiter_callback) + + # Send initial state + await send_waiter_state() + + try: + while True: + try: + await websocket.receive_text() + except WebSocketDisconnect: + break + finally: + waiter_service.remove_callback(callback_name) + with contextlib.suppress(Exception): + await websocket.close() + + +def _notify_waiter_callbacks() -> None: + """Notify waiter service callbacks of state changes (e.g. after CRUD operations).""" + if cfg.waiter_mode_active: + waiter_service = WaiterService() + waiter_service._run_callbacks() diff --git a/src/config/config_manager.py b/src/config/config_manager.py index 75914559..51b1b37a 100644 --- a/src/config/config_manager.py +++ b/src/config/config_manager.py @@ -4,13 +4,16 @@ import random from collections.abc import Callable from enum import IntEnum -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar import typer import yaml from pydantic.dataclasses import dataclass as api_dataclass from pyfiglet import Figlet +if TYPE_CHECKING: + from src.api.models import WaiterResponse + from src import ( MAX_SUPPORTED_BOTTLES, PROJECT_NAME, @@ -148,6 +151,10 @@ class ConfigManager: PAYMENT_SUMUP_TERMINAL_ID: str = "" PAYMENT_AUTO_LOGOUT_TIME_S: int = 60 PAYMENT_LOGOUT_AFTER_PREPARATION: bool = True + # Waiter mode settings + WAITER_MODE: bool = False + WAITER_LOGOUT_AFTER_COCKTAIL: bool = False + WAITER_AUTO_LOGOUT_S: int = 0 # Custom theme settings CUSTOM_COLOR_PRIMARY: str = "#007bff" CUSTOM_COLOR_SECONDARY: str = "#ef9700" @@ -275,6 +282,9 @@ def __init__(self) -> None: "PAYMENT_SUMUP_TERMINAL_ID": StringType(), "PAYMENT_AUTO_LOGOUT_TIME_S": IntType([build_number_limiter(0, 1000000000)], suffix="s"), "PAYMENT_LOGOUT_AFTER_PREPARATION": BoolType(check_name="Logout After Preparation"), + "WAITER_MODE": BoolType(check_name="Enable Service Personnel Mode"), + "WAITER_LOGOUT_AFTER_COCKTAIL": BoolType(check_name="Logout After Cocktail"), + "WAITER_AUTO_LOGOUT_S": IntType([build_number_limiter(0, 100000)], suffix="s"), "CUSTOM_COLOR_PRIMARY": StringType(), "CUSTOM_COLOR_SECONDARY": StringType(), "CUSTOM_COLOR_NEUTRAL": StringType(), @@ -300,6 +310,16 @@ def payment_enabled(self) -> bool: """Check if any payment option is selected.""" return self.PAYMENT_TYPE != "Disabled" + @property + def waiter_mode_active(self) -> bool: + """Check if waiter mode is enabled.""" + return self.WAITER_MODE + + @property + def nfc_enabled(self) -> bool: + """Check if any NFC reader is enabled.""" + return self.RFID_READER != "No" + def read_local_config(self, update_config: bool = False, validate: bool = True) -> None: """Read the local config file and set the values if they are valid. @@ -392,6 +412,8 @@ def _validate_cross_config(self, validate: bool) -> None: Currently implemented: - Checks that I2C pin types used in PUMP_CONFIG have corresponding enabled devices in I2C_CONFIG. + - Checks that WAITER_MODE is not enabled alongside CocktailBerry NFC payment. + - Check that some nfc is active when payment/waiter mode is active. """ # Collect all I2C pin types used in PUMP_CONFIG (excluding GPIO) required_i2c_types: set[str] = set() @@ -413,6 +435,19 @@ def _validate_cross_config(self, validate: bool) -> None: if validate: raise ConfigError(error_msg) + # Check that waiter mode is not enabled alongside CocktailBerry NFC payment + if self.WAITER_MODE and self.payment_enabled: + error_msg = "WAITER_MODE cannot be enabled when payment is also enabled" + _logger.error(f"Config Error: {error_msg}") + if validate: + raise ConfigError(error_msg) + + if (self.WAITER_MODE or self.payment_enabled) and not self.nfc_enabled: + error_msg = "NFC must be set when payment or waiter mode is active" + _logger.error(f"Config Error: {error_msg}") + if validate: + raise ConfigError(error_msg) + def choose_bottle_number(self, get_all: bool = False, ignore_limits: bool = False) -> int: """Select the number of Bottles, limits by max supported count.""" # for new app (no limits), this exists for legacy reason (QT) @@ -524,11 +559,15 @@ def __init__(self) -> None: self.is_v1 = False self.restricted_mode_active = False self.cocktail_status = CocktailStatus() + # Waiter mode state + self.current_waiter_nfc_id: str | None = None + self.current_waiter: WaiterResponse | None = None # those are used to display once the message after startup if there are some issues self.startup_need_time_adjustment = StartupIssue() self.startup_python_deprecated = StartupIssue() self.startup_config_issue = StartupIssue() self.startup_payment_issue = StartupIssue() + self.startup_waiter_issue = StartupIssue() def version_callback(value: bool) -> None: diff --git a/src/database_commander.py b/src/database_commander.py index 8ec2be68..3a23c2e4 100644 --- a/src/database_commander.py +++ b/src/database_commander.py @@ -9,7 +9,7 @@ import sqlalchemy from sqlalchemy import create_engine, func -from sqlalchemy.orm import Session, scoped_session, sessionmaker +from sqlalchemy.orm import Session, joinedload, scoped_session, sessionmaker from src.db_models import ( Base, @@ -24,6 +24,8 @@ DbRecipe, DbResourceUsage, DbTeamdata, + DbWaiter, + DbWaiterLog, ) from src.dialog_handler import DialogHandler from src.filepath import DATABASE_PATH, DEFAULT_DATABASE_PATH, HOME_PATH @@ -411,7 +413,7 @@ def increment_recipe_counter(self, recipe_name: str, virgin: bool) -> None: with self.session_scope() as session: recipe = session.query(DbRecipe).filter(DbRecipe.name == recipe_name).one_or_none() if recipe is None: - raise ElementNotFoundError(f"Recipe with name {recipe_name} not found") + raise ElementNotFoundError(f"Recipe with name {recipe_name}") if virgin: recipe.counter_lifetime_virgin += 1 @@ -467,7 +469,7 @@ def set_recipe( with self.session_scope() as session: recipe = session.query(DbRecipe).filter(DbRecipe.id == recipe_id).one_or_none() if recipe is None: - raise ElementNotFoundError(f"Recipe ID {recipe_id} not found") + raise ElementNotFoundError(f"Recipe ID {recipe_id}") recipe.name = name recipe.alcohol = alcohol_level @@ -598,7 +600,7 @@ def delete_ingredient(self, ingredient_id: int) -> None: with self.session_scope() as session: ingredient = session.query(DbIngredient).filter(DbIngredient.id == ingredient_id).one_or_none() if ingredient is None: - raise ElementNotFoundError(f"Ingredient ID {ingredient_id} not found") + raise ElementNotFoundError(f"Ingredient ID {ingredient_id}") session.delete(ingredient) def delete_recipe(self, recipe_name: str | int) -> None: @@ -609,7 +611,7 @@ def delete_recipe(self, recipe_name: str | int) -> None: else: recipe = session.query(DbRecipe).filter(DbRecipe.id == recipe_name).one_or_none() if recipe is None: - raise ElementNotFoundError(f"Recipe {recipe_name} not found") + raise ElementNotFoundError(f"Recipe {recipe_name}") session.delete(recipe) def delete_recipe_ingredient_data(self, recipe_id: int) -> None: @@ -943,5 +945,86 @@ def get_events( for e in db_events ] + # ---- Waiter Methods ---- + + def get_all_waiters(self) -> list[DbWaiter]: + """Get all registered waiters.""" + with self.session_scope() as session: + return list(session.query(DbWaiter).order_by(DbWaiter.name).all()) + + def get_waiter_by_nfc_id(self, nfc_id: str) -> DbWaiter | None: + """Get a waiter by NFC ID.""" + with self.session_scope() as session: + return session.query(DbWaiter).filter(DbWaiter.nfc_id == nfc_id).one_or_none() + + def create_waiter(self, nfc_id: str, name: str, permissions: dict[str, bool] | None = None) -> DbWaiter: + """Create a new waiter. Raises ElementAlreadyExistsError if name already exists.""" + with self.session_scope() as session: + existing = session.query(DbWaiter).filter(DbWaiter.name == name).one_or_none() + if existing is not None: + raise ElementAlreadyExistsError(name) + existing_nfc = session.query(DbWaiter).filter(DbWaiter.nfc_id == nfc_id).one_or_none() + if existing_nfc is not None: + raise ElementAlreadyExistsError(nfc_id) + waiter = DbWaiter(nfc_id=nfc_id, name=name) + if permissions is not None: + for key, value in permissions.items(): + setattr(waiter, f"privilege_{key}", value) + session.add(waiter) + session.commit() + return waiter + + def update_waiter( + self, nfc_id: str, name: str | None = None, permissions: dict[str, bool] | None = None + ) -> DbWaiter: + """Update a waiter's name and/or permissions.""" + with self.session_scope() as session: + waiter = session.query(DbWaiter).filter(DbWaiter.nfc_id == nfc_id).one_or_none() + if waiter is None: + raise ElementNotFoundError(nfc_id) + if name is not None: + existing = ( + session.query(DbWaiter).filter(DbWaiter.name == name, DbWaiter.nfc_id != nfc_id).one_or_none() + ) + if existing is not None: + raise ElementAlreadyExistsError(name) + waiter.name = name + if permissions is not None: + for key, value in permissions.items(): + setattr(waiter, f"privilege_{key}", value) + session.commit() + return waiter + + def delete_waiter(self, nfc_id: str) -> None: + """Delete a waiter by NFC ID.""" + with self.session_scope() as session: + waiter = session.query(DbWaiter).filter(DbWaiter.nfc_id == nfc_id).one_or_none() + if waiter is None: + raise ElementNotFoundError(nfc_id) + session.delete(waiter) + session.commit() + + def log_waiter_cocktail(self, waiter_nfc_id: str, recipe_id: int, volume: int, is_virgin: bool) -> None: + """Log a cocktail preparation for a waiter.""" + with self.session_scope() as session: + log = DbWaiterLog( + waiter_nfc_id=waiter_nfc_id, + recipe_id=recipe_id, + volume=volume, + is_virgin=is_virgin, + ) + session.add(log) + session.commit() + + def get_waiter_logs(self) -> list[DbWaiterLog]: + """Get all waiter logs with related waiter and recipe data.""" + with self.session_scope() as session: + return list( + session.query(DbWaiterLog) + .options(joinedload(DbWaiterLog.waiter), joinedload(DbWaiterLog.recipe)) + .order_by(DbWaiterLog.timestamp.desc()) + .all() + ) + DB_COMMANDER = DatabaseCommander() diff --git a/src/db_models.py b/src/db_models.py index 8eb6fcef..e9e6316c 100644 --- a/src/db_models.py +++ b/src/db_models.py @@ -243,3 +243,52 @@ def __init__( self.event_type = event_type self.timestamp = timestamp or datetime.datetime.now() self.additional_info = additional_info + + +class DbWaiter(Base): + __tablename__ = "Waiters" + nfc_id: Mapped[str] = mapped_column(primary_key=True, name="NFC_ID") + name: Mapped[str] = mapped_column(unique=True, nullable=False, name="Name") + privilege_maker: Mapped[bool] = mapped_column(default=False, name="Privilege_Maker") + privilege_ingredients: Mapped[bool] = mapped_column(default=False, name="Privilege_Ingredients") + privilege_recipes: Mapped[bool] = mapped_column(default=False, name="Privilege_Recipes") + privilege_bottles: Mapped[bool] = mapped_column(default=False, name="Privilege_Bottles") + privilege_options: Mapped[bool] = mapped_column(default=False, name="Privilege_Options") + + logs: Mapped[list["DbWaiterLog"]] = relationship("DbWaiterLog", back_populates="waiter") + + def __init__(self, nfc_id: str, name: str) -> None: + self.nfc_id = nfc_id + self.name = name + + +class DbWaiterLog(Base): + __tablename__ = "WaiterLog" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, name="ID") + timestamp: Mapped[datetime.datetime] = mapped_column( + nullable=False, name="Timestamp", default=datetime.datetime.now + ) + waiter_nfc_id: Mapped[str | None] = mapped_column( + ForeignKey("Waiters.NFC_ID", ondelete="SET NULL"), nullable=True, name="Waiter_NFC_ID" + ) + recipe_id: Mapped[int | None] = mapped_column( + ForeignKey("Recipes.ID", ondelete="SET NULL"), nullable=True, name="Recipe_ID" + ) + volume: Mapped[int] = mapped_column(nullable=False, name="Volume") + is_virgin: Mapped[bool] = mapped_column(nullable=False, name="Is_Virgin") + + waiter: Mapped[Optional["DbWaiter"]] = relationship("DbWaiter", back_populates="logs") + recipe: Mapped[Optional["DbRecipe"]] = relationship("DbRecipe") + + def __init__( + self, + waiter_nfc_id: str, + recipe_id: int | None, + volume: int, + is_virgin: bool, + ) -> None: + self.waiter_nfc_id = waiter_nfc_id + self.recipe_id = recipe_id + self.volume = volume + self.is_virgin = is_virgin + self.timestamp = datetime.datetime.now() diff --git a/src/dialog_handler.py b/src/dialog_handler.py index e81bcb9e..76f5de7d 100644 --- a/src/dialog_handler.py +++ b/src/dialog_handler.py @@ -55,6 +55,7 @@ Ui_RFIDWriterWindow, Ui_SumupWindow, Ui_Teamselection, + Ui_WaiterWindow, Ui_WiFiWindow, ) @@ -124,6 +125,7 @@ "news_v2_available", "no_button", "no_ingredient_selected", + "no_waiter_logged_in", "no_recipe_selected", "not_enough_ingredient_volume", "options_updated_and_restart", @@ -856,6 +858,7 @@ def adjust_option_window(self, w: Ui_Optionwindow) -> None: (w.button_rfid, "rfid"), (w.button_news, "news"), (w.button_sumup, "sumup"), + (w.button_waiter, "waiter"), (w.button_about, "about"), (w.button_back, "back"), ]: @@ -896,6 +899,13 @@ def adjust_news_window(self, w: Ui_NewsWindow) -> None: """Translate the elements from the news window.""" w.button_back.setText(self._choose_language("back")) + def adjust_waiter_window(self, w: Ui_WaiterWindow) -> None: + """Translate the elements from the waiter window.""" + w.button_back.setText(self._choose_language("back")) + window = "waiter_window" + w.waiter_tabs.setTabText(w.waiter_tabs.indexOf(w.tab_management), self._choose_language("management", window)) + w.waiter_tabs.setTabText(w.waiter_tabs.indexOf(w.tab_statistics), self._choose_language("statistics", window)) + def adjust_sumup_window(self, w: Ui_SumupWindow) -> None: """Translate the elements from the sumup window.""" w.button_back.setText(self._choose_language("back")) diff --git a/src/language.yaml b/src/language.yaml index 615cd548..209e387e 100644 --- a/src/language.yaml +++ b/src/language.yaml @@ -61,6 +61,9 @@ dialog: cocktail_canceled: en: 'The cocktail was cancelled!' de: 'Der Cocktail wurde abgebrochen!' + no_waiter_logged_in: + en: 'No service personnel is logged in. Please scan your NFC tag first.' + de: 'Kein Servicepersonal angemeldet. Bitte zuerst den NFC-Tag scannen.' cocktail_ready: en: 'The cocktail is ready. Please wait a moment, for the rest of the fluid to flow in.{full_comment}' de: 'Der Cocktail ist fertig! Bitte kurz warten, falls noch etwas nach tropft.{full_comment}' @@ -592,6 +595,9 @@ ui: sumup: en: 'SumUp Set Up' de: 'SumUp Einrichtung' + waiter: + en: 'Service Personnel' + de: 'Servicepersonal' about: en: 'About CocktailBerry' de: 'Info CocktailBerry' @@ -834,6 +840,15 @@ ui: PAYMENT_LOGOUT_AFTER_PREPARATION: en: 'Remove user information (used for cocktail display filter) after cocktail preparation, lock screen if enabled' de: 'Entferne Benutzerinformationen (für Cocktail Anzeigefilter verwendet) nach der Cocktail Zubereitung, Sperrbildschirm wenn aktiviert' + WAITER_MODE: + en: 'Enables the service personnel mode, requiring NFC scan before cocktail preparation. Needs an NFC/RFID reader to be configured.' + de: 'Aktiviert den Servicepersonal-Modus, der einen NFC-Scan vor der Cocktail Zubereitung erfordert. Ein NFC/RFID-Leser muss konfiguriert sein.' + WAITER_LOGOUT_AFTER_COCKTAIL: + en: 'Automatically log out the service personnel after a cocktail has been prepared' + de: 'Servicepersonal nach der Cocktail Zubereitung automatisch abmelden' + WAITER_AUTO_LOGOUT_S: + en: 'Time in seconds before the service personnel is automatically logged out, use 0 to disable' + de: 'Zeit in Sekunden bis das Servicepersonal automatisch abgemeldet wird, verwende 0 zum Deaktivieren' CUSTOM_COLOR_PRIMARY: en: 'Custom primary (most elements) color if custom theme is selected' de: 'Eigene primäre (meisten Elemente) Farbe, wenn custom Theme ausgewählt ist' @@ -973,6 +988,109 @@ ui: en: 'Do not show again' de: 'Nicht mehr anzeigen' + ##### WAITER WINDOW ##### + # all values for waiter window + waiter_window: + management: + en: 'Management' + de: 'Verwaltung' + statistics: + en: 'Statistics' + de: 'Statistik' + last_scanned: + en: 'Last scanned NFC' + de: 'Zuletzt gescannte NFC' + current_nfc: + en: 'NFC ID' + de: 'NFC ID' + current_waiter: + en: 'Service Personnel' + de: 'Servicepersonal' + no_waiter: + en: 'No Service Personnel' + de: 'Kein Servicepersonal' + use_scanned: + en: 'Use scanned NFC ID' + de: 'Gescannte NFC ID übernehmen' + register_waiter: + en: 'Register Service Personnel' + de: 'Servicepersonal registrieren' + nfc_id_placeholder: + en: 'NFC ID' + de: 'NFC ID' + name_placeholder: + en: 'Name' + de: 'Name' + permissions_label: + en: 'Permissions' + de: 'Berechtigungen' + permission_maker: + en: 'Maker' + de: 'Maker' + permission_ingredients: + en: 'Ingredients' + de: 'Zutaten' + permission_recipes: + en: 'Recipes' + de: 'Rezepte' + permission_bottles: + en: 'Bottles' + de: 'Flaschen' + create_waiter: + en: 'Create' + de: 'Erstellen' + registered_waiters: + en: 'Registered Service Personnel' + de: 'Registriertes Servicepersonal' + no_waiters: + en: 'No Service Personnel registered' + de: 'Kein Servicepersonal registriert' + active: + en: 'Active' + de: 'Aktiv' + edit: + en: 'Edit' + de: 'Bearbeiten' + delete: + en: 'Delete' + de: 'Löschen' + save: + en: 'Save' + de: 'Speichern' + cancel: + en: 'Cancel' + de: 'Abbrechen' + waiter_exists: + en: 'Service Personnel with this NFC ID or name already exists.' + de: 'Servicepersonal mit dieser NFC ID oder diesem Namen existiert bereits.' + waiter_not_found: + en: 'Service Personnel not found.' + de: 'Servicepersonal nicht gefunden.' + waiter_created: + en: 'Service Personnel created.' + de: 'Servicepersonal erstellt.' + waiter_updated: + en: 'Service Personnel updated.' + de: 'Servicepersonal aktualisiert.' + waiter_deleted: + en: 'Service Personnel deleted.' + de: 'Servicepersonal gelöscht.' + no_logs: + en: 'No statistics available.' + de: 'Keine Statistik verfügbar.' + stats_summary: + en: '{cocktails} cocktails, {volume} ml total' + de: '{cocktails} Cocktails, {volume} ml gesamt' + deleted_user: + en: 'Deleted User' + de: 'Gelöschter Nutzer' + deleted_recipe: + en: 'Deleted Recipe' + de: 'Gelöschtes Rezept' + virgin: + en: 'Virgin' + de: 'Virgin' + ##### SUMUP WINDOW ##### # all values for sumup window sumup_window: diff --git a/src/models.py b/src/models.py index e8565bd9..909aabfe 100644 --- a/src/models.py +++ b/src/models.py @@ -22,6 +22,7 @@ class PrepareResult(Enum): NOT_ENOUGH_INGREDIENTS = "NOT_ENOUGH_INGREDIENTS" ADDON_ERROR = "ADDON_ERROR" WAITING_FOR_PAYMENT = "WAITING_FOR_PAYMENT" + NO_WAITER_LOGGED_IN = "NO_WAITER_LOGGED_IN" class EventType(StrEnum): diff --git a/src/service/waiter_service.py b/src/service/waiter_service.py new file mode 100644 index 00000000..79863d7a --- /dev/null +++ b/src/service/waiter_service.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from threading import Timer +from typing import Self + +from src.api.models import WaiterResponse +from src.config.config_manager import CONFIG as cfg +from src.config.config_manager import shared +from src.database_commander import DatabaseCommander +from src.logger_handler import LoggerHandler +from src.machine.rfid import RFIDReader + +_logger = LoggerHandler("WaiterService") + + +class WaiterService: + """Singleton service for waiter NFC sensing and state management. + + Mirrors the NFCPaymentService pattern: starts a continuous NFC sensing loop, + manages callbacks for UI updates, and handles auto-logout timers. + """ + + _instance: Self | None = None + + def __new__(cls) -> Self: + if not isinstance(cls._instance, cls): + cls._instance = object.__new__(cls) + return cls._instance + + def __init__(self) -> None: + if getattr(self, "_initialized", False): + return + self.rfid_reader = RFIDReader() + self._callbacks: dict[str, Callable[[], None]] = {} + self._auto_logout_timer: Timer | None = None + self._pause_callbacks: bool = False + self._initialized = True + + def __del__(self) -> None: + self._cancel_auto_logout_timer() + self.rfid_reader.cancel_reading() + + def start_continuous_sensing(self) -> None: + """Start continuous NFC sensing for waiter identification. + + Should be called once at program start. + """ + if cfg.RFID_READER == "No": + _logger.error("No NFC reader type specified. Disabling waiter mode.") + cfg.WAITER_MODE = False + return + _logger.info("Starting continuous NFC sensing for WaiterService.") + self.rfid_reader.read_rfid(self._handle_nfc_read, read_delay_s=1.0) + + def _handle_nfc_read(self, _text: str, nfc_id: str) -> None: + """Handle NFC read events from the reader thread.""" + _logger.debug(f"NFC ID read for waiter: {nfc_id}") + self._cancel_auto_logout_timer() + shared.current_waiter_nfc_id = nfc_id + # Look up waiter in DB + waiter = DatabaseCommander().get_waiter_by_nfc_id(nfc_id) + shared.current_waiter = WaiterResponse.from_db(waiter) if waiter else None + if waiter: + _logger.debug(f"Service Personnel found: {waiter.name}") + else: + _logger.debug(f"No registered waiter for NFC ID: {nfc_id}") + # Start auto-logout timer if configured + if cfg.WAITER_AUTO_LOGOUT_S > 0: + self._start_auto_logout_timer() + self._run_callbacks() + + def logout_waiter(self) -> None: + """Log out the current waiter and notify callbacks.""" + _logger.debug("Logging out current waiter.") + shared.current_waiter_nfc_id = None + shared.current_waiter = None + self._cancel_auto_logout_timer() + self._run_callbacks() + + def add_callback(self, name: str, callback: Callable[[], None]) -> None: + """Add a named callback invoked when waiter state changes.""" + if name in self._callbacks: + return + _logger.debug(f"Adding callback: {name}") + self._callbacks[name] = callback + + def remove_callback(self, name: str) -> None: + """Remove a specific callback by name.""" + _logger.debug(f"Removing callback: {name}") + self._callbacks.pop(name, None) + + @contextmanager + def paused_callbacks(self) -> Iterator[None]: + """Context manager to temporarily pause callbacks.""" + self._pause_callbacks = True + try: + yield + finally: + self._pause_callbacks = False + + def _run_callbacks(self) -> None: + """Run all registered callbacks.""" + if self._pause_callbacks: + _logger.debug("Callbacks are paused; not running any callbacks.") + return + for callback in self._callbacks.values(): + callback() + + def _cancel_auto_logout_timer(self) -> None: + """Cancel the auto-logout timer if it exists.""" + if self._auto_logout_timer is not None: + _logger.debug("Cancelling auto-logout timer.") + self._auto_logout_timer.cancel() + self._auto_logout_timer = None + + def _start_auto_logout_timer(self) -> None: + """Start the auto-logout timer.""" + _logger.debug(f"Starting auto-logout timer ({cfg.WAITER_AUTO_LOGOUT_S}s).") + self._auto_logout_timer = Timer(cfg.WAITER_AUTO_LOGOUT_S, self.logout_waiter) + self._auto_logout_timer.daemon = True + self._auto_logout_timer.start() diff --git a/src/startup_checks.py b/src/startup_checks.py index 1a4c3513..aae87ab9 100644 --- a/src/startup_checks.py +++ b/src/startup_checks.py @@ -13,8 +13,8 @@ @dataclass -class PaymentCheckResult: - """Result of a payment startup check.""" +class CheckResult: + """Result of a startup check.""" ok: bool reason: str = "" @@ -42,18 +42,18 @@ def is_python_deprecated() -> bool: return sys_python < FUTURE_PYTHON_VERSION -def check_payment_service() -> PaymentCheckResult: +def check_payment_service() -> CheckResult: """Check if the configured payment service can start properly.""" if not cfg.payment_enabled: - return PaymentCheckResult(ok=True) + return CheckResult(ok=True) if cfg.sumup_payment: return _check_sumup() if cfg.cocktailberry_payment: return _check_cocktailberry_nfc() - return PaymentCheckResult(ok=True) + return CheckResult(ok=True) -def _check_sumup() -> PaymentCheckResult: +def _check_sumup() -> CheckResult: """Validate SumUp payment prerequisites.""" # Try to initialize the client and list readers to verify credentials try: @@ -63,17 +63,26 @@ def _check_sumup() -> PaymentCheckResult: ) result = service.get_all_readers_result() if isinstance(result, Err): - return PaymentCheckResult(ok=False, reason=f"SumUp API error: {result.error} (code: {result.code})") + return CheckResult(ok=False, reason=f"SumUp API error: {result.error} (code: {result.code})") _logger.info(f"SumUp startup check passed, {len(result.data)} reader(s) found.") except Exception as e: - return PaymentCheckResult(ok=False, reason=f"SumUp API error: {e}") + return CheckResult(ok=False, reason=f"SumUp API error: {e}") if not cfg.PAYMENT_SUMUP_TERMINAL_ID: _logger.warning("Reader is not set, but it is required for payments. Please set it up.") - return PaymentCheckResult(ok=True) + return CheckResult(ok=True) -def _check_cocktailberry_nfc() -> PaymentCheckResult: +def _check_cocktailberry_nfc() -> CheckResult: """Validate CocktailBerry NFC payment prerequisites.""" if RFIDReader().rfid is None: - return PaymentCheckResult(ok=False, reason=f"Could not set up or use {cfg.RFID_READER} reader, see logs.") - return PaymentCheckResult(ok=True) + return CheckResult(ok=False, reason=f"Could not set up or use '{cfg.RFID_READER}' reader, see logs.") + return CheckResult(ok=True) + + +def check_waiter_mode() -> CheckResult: + """Check if waiter mode can start properly (NFC reader available).""" + if not cfg.WAITER_MODE: + return CheckResult(ok=True) + if RFIDReader().rfid is None: + return CheckResult(ok=False, reason=f"Could not set up or use '{cfg.RFID_READER}' reader for waiter mode.") + return CheckResult(ok=True) diff --git a/src/tabs/maker.py b/src/tabs/maker.py index f5ad71d9..231d1c3d 100644 --- a/src/tabs/maker.py +++ b/src/tabs/maker.py @@ -15,6 +15,7 @@ from src.machine.controller import MachineController from src.models import Cocktail, CocktailStatus, EventType, Ingredient, PrepareResult from src.programs.addons import ADDONS +from src.service.waiter_service import WaiterService from src.service_handler import SERVICE_HANDLER if TYPE_CHECKING: @@ -50,6 +51,8 @@ def prepare_cocktail( additional_message: str = "", ) -> tuple[PrepareResult, str]: """Prepare a Cocktail, if not already another one is in production and enough ingredients are available.""" + # Capture current waiter at preparation start (immune to logout during prep) + waiter_nfc_id = shared.current_waiter_nfc_id shared.cocktail_status = CocktailStatus(status=PrepareResult.IN_PROGRESS) addon_data: dict[str, Any] = {"cocktail": cocktail} @@ -90,6 +93,10 @@ def prepare_cocktail( consumption_amount = consumption DBC.set_multiple_ingredient_consumption(consumption_names, consumption_amount) DBC.save_event(EventType.COCKTAIL_CANCELED, f"{display_name}") + if waiter_nfc_id is not None: + DBC.log_waiter_cocktail(waiter_nfc_id, cocktail.id, real_volume, cocktail.is_virgin) + if cfg.WAITER_LOGOUT_AFTER_COCKTAIL and cfg.waiter_mode_active: + WaiterService().logout_waiter() return PrepareResult.CANCELED, canceled_message shared.cocktail_status.status = PrepareResult.FINISHED @@ -97,6 +104,11 @@ def prepare_cocktail( consumption_amount = [x.amount for x in cocktail.adjusted_ingredients] DBC.set_multiple_ingredient_consumption(consumption_names, consumption_amount) DBC.save_event(EventType.COCKTAIL_PREPARATION, f"{display_name}") + # Log waiter cocktail on successful preparation + if waiter_nfc_id is not None: + DBC.log_waiter_cocktail(waiter_nfc_id, cocktail.id, real_volume, cocktail.is_virgin) + if cfg.WAITER_LOGOUT_AFTER_COCKTAIL and cfg.waiter_mode_active: + WaiterService().logout_waiter() return PrepareResult.FINISHED, add_message @@ -112,6 +124,9 @@ def validate_cocktail(cocktail: Cocktail) -> tuple[PrepareResult, str, Ingredien Returns the validator code | Error message (in case of addon). """ + # Check waiter mode: block if no registered waiter logged in + if cfg.waiter_mode_active and shared.current_waiter is None: + return PrepareResult.NO_WAITER_LOGGED_IN, DH.get_translation("no_waiter_logged_in"), None addon_data: dict[str, Any] = {"cocktail": cocktail} if shared.cocktail_status.status == PrepareResult.IN_PROGRESS: return PrepareResult.IN_PROGRESS, DH.cocktail_in_progress(), None diff --git a/src/ui/create_config_window.py b/src/ui/create_config_window.py index 526e8332..76124226 100644 --- a/src/ui/create_config_window.py +++ b/src/ui/create_config_window.py @@ -374,6 +374,7 @@ def _choose_tab_container(self, config_name: str) -> QVBoxLayout: self.vbox_software: ( "MICROSERVICE", "TEAM", + "WAITER", ), self.vbox_payment: ("PAYMENT",), } diff --git a/src/ui/setup_mainwindow.py b/src/ui/setup_mainwindow.py index 6fb1a3c2..e5dec11e 100644 --- a/src/ui/setup_mainwindow.py +++ b/src/ui/setup_mainwindow.py @@ -23,7 +23,14 @@ from src.models import Cocktail from src.programs.addons import ADDONS from src.service.nfc_payment_service import NFCPaymentService, UserLookup -from src.startup_checks import can_update, check_payment_service, connection_okay, is_python_deprecated +from src.service.waiter_service import WaiterService +from src.startup_checks import ( + can_update, + check_payment_service, + check_waiter_mode, + connection_okay, + is_python_deprecated, +) from src.tabs import bottles, ingredients, recipes from src.ui.cocktail_view import CocktailView from src.ui.icons import BUTTON_SIZE, IconSetter @@ -69,7 +76,7 @@ class TabIndex(IntEnum): class MainScreen(QMainWindow, Ui_MainWindow): """Creates the Mainscreen.""" - def __init__(self) -> None: # noqa: PLR0915 + def __init__(self) -> None: # noqa: C901, PLR0915 """Init the main window. Many of the button and List connects are in pass_setup.""" super().__init__() self.setupUi(self) @@ -101,12 +108,21 @@ def __init__(self) -> None: # noqa: PLR0915 # building the fist page as a stacked widget # this is quite similar to the tab widget, but we don't need the tabs self.cocktail_selection: CocktailSelection | None = None + # Run payment check before starting NFC so payment gets disabled if needed payment_result = check_payment_service() if not payment_result.ok: cfg.PAYMENT_TYPE = "Disabled" if cfg.cocktailberry_payment: NFCPaymentService().start_continuous_sensing() + + # Same for waiter mode + waiter_check = check_waiter_mode() + if not waiter_check.ok: + cfg.WAITER_MODE = False + if cfg.waiter_mode_active: + WaiterService().start_continuous_sensing() + self.cocktail_view.populate_cocktails() self.container_maker.addWidget(self.cocktail_view) @@ -475,12 +491,27 @@ def handle_tab_bar_clicked(self, index: int) -> None: if index in unprotected_tabs: self.previous_tab_index = index return + if self._waiter_can_access_locked_tab(index): + self.previous_tab_index = index + return if DP_CONTROLLER.password_prompt(cfg.UI_MAKER_PASSWORD, header_type="maker"): self.previous_tab_index = index return # Set back to the prev tab if password not right self.tabWidget.setCurrentIndex(old_index) + def _waiter_can_access_locked_tab(self, index: int) -> bool: + if not cfg.waiter_mode_active or shared.current_waiter is None: + return False + + permission_by_index = { + int(TabIndex.MAKER): shared.current_waiter.permissions.maker, + int(TabIndex.INGREDIENTS): shared.current_waiter.permissions.ingredients, + int(TabIndex.RECIPES): shared.current_waiter.permissions.recipes, + int(TabIndex.BOTTLES): shared.current_waiter.permissions.bottles, + } + return permission_by_index.get(index, False) + def _apply_search_to_list(self) -> None: """Apply the search to the list widget.""" search = self.input_search_cocktail.text() diff --git a/src/ui/setup_option_window.py b/src/ui/setup_option_window.py index f9ff3c60..5910e400 100644 --- a/src/ui/setup_option_window.py +++ b/src/ui/setup_option_window.py @@ -28,6 +28,7 @@ from src.ui.setup_resource_window import ResourceWindow from src.ui.setup_rfid_writer_window import RFIDWriterWindow from src.ui.setup_sumup_window import SumupWindow +from src.ui.setup_waiter_window import WaiterWindow from src.ui.setup_wifi_window import WiFiWindow from src.ui_elements import Ui_Optionwindow from src.updater import UpdateInfo, Updater @@ -69,6 +70,7 @@ def __init__(self, parent: MainScreen) -> None: self.button_about.clicked.connect(DP_CONTROLLER.say_welcome_message) self.button_news.clicked.connect(self._open_news_window) self.button_sumup.clicked.connect(self._open_sumup_window) + self.button_waiter.clicked.connect(self._open_waiter_window) self.button_events.clicked.connect(self._open_event_window) self.button_rfid.setEnabled(cfg.RFID_READER != "No") @@ -205,12 +207,16 @@ def _open_sumup_window(self) -> None: """Open the SumUp configuration window.""" self.sumup_window = SumupWindow(self.mainscreen) + def _open_waiter_window(self) -> None: + """Open the waiter configuration window.""" + self.waiter_window = WaiterWindow(self.mainscreen) + def _open_event_window(self) -> None: """Open the events window.""" self.event_window = EventWindow() def _check_internet_connection(self) -> None: - """Check if there is a active internet connection.""" + """Check if there is an active internet connection.""" is_connected = has_connection() DP_CONTROLLER.say_internet_connection_status(is_connected) diff --git a/src/ui/setup_waiter_window.py b/src/ui/setup_waiter_window.py new file mode 100644 index 00000000..573c2a8b --- /dev/null +++ b/src/ui/setup_waiter_window.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Any + +from PyQt6.QtCore import QTimer +from PyQt6.QtWidgets import QCheckBox, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget + +from src.config.config_manager import shared +from src.database_commander import DatabaseCommander, ElementAlreadyExistsError, ElementNotFoundError +from src.db_models import DbWaiterLog +from src.dialog_handler import UI_LANGUAGE +from src.display_controller import DP_CONTROLLER +from src.ui.creation_utils import MEDIUM_FONT, FontSize, adjust_font, create_button, create_label, create_spacer +from src.ui.setup_keyboard_widget import KeyboardWidget +from src.ui_elements import Ui_WaiterWindow +from src.ui_elements.clickablelineedit import ClickableLineEdit + +if TYPE_CHECKING: + from src.ui.setup_mainwindow import MainScreen + + +class WaiterWindow(QMainWindow, Ui_WaiterWindow): + """Creates the waiter window Widget.""" + + _PERMISSION_KEYS = ("maker", "ingredients", "recipes", "bottles") + + def __init__(self, mainscreen: MainScreen) -> None: + """Init. Connect all the buttons and set window policy.""" + super().__init__() + self.setupUi(self) + self.mainscreen = mainscreen + DP_CONTROLLER.initialize_window_object(self) + self.button_back.clicked.connect(self.close) + self.waiter_tabs.currentChanged.connect(self._on_tab_changed) + + self._last_seen_nfc_id: str | None = None + self._editing_nfc_id: str | None = None + + UI_LANGUAGE.adjust_waiter_window(self) + self._render_management() + self._render_statistics() + + self._scan_timer = QTimer(self) + self._scan_timer.timeout.connect(self._refresh_scan_state) + self._scan_timer.start(300) + self._refresh_scan_state() + + self.showFullScreen() + DP_CONTROLLER.set_display_settings(self) + + def closeEvent(self, a0: Any) -> None: + if hasattr(self, "_scan_timer"): + self._scan_timer.stop() + super().closeEvent(a0) + + def _on_tab_changed(self) -> None: + if self.waiter_tabs.currentWidget() == self.tab_statistics: + self._render_statistics() + + def _render_management(self) -> None: + DP_CONTROLLER.delete_items_of_layout(self.data_container_management) + + self._add_section_header( + self.data_container_management, UI_LANGUAGE.get_translation("last_scanned", "waiter_window") + ) + self._scan_id_label = create_label("", FontSize.MEDIUM) + self._scan_name_label = create_label("", FontSize.MEDIUM) + self.data_container_management.addWidget(self._scan_id_label) + self.data_container_management.addWidget(self._scan_name_label) + self.data_container_management.addItem(create_spacer(10)) + + use_scanned_btn = create_button( + UI_LANGUAGE.get_translation("use_scanned", "waiter_window"), + font_size=MEDIUM_FONT, + min_h=50, + css_class="btn-inverted", + ) + use_scanned_btn.clicked.connect(self._use_scanned_nfc) + self.data_container_management.addWidget(use_scanned_btn) + + self.data_container_management.addItem(create_spacer(10)) + self._add_section_header( + self.data_container_management, + UI_LANGUAGE.get_translation("register_waiter", "waiter_window"), + ) + + self._create_nfc_input = ClickableLineEdit() + self._create_nfc_input.setPlaceholderText(UI_LANGUAGE.get_translation("nfc_id_placeholder", "waiter_window")) + self._create_nfc_input.clicked.connect(self._open_create_nfc_keyboard) + adjust_font(self._create_nfc_input, MEDIUM_FONT) + self.data_container_management.addWidget(self._create_nfc_input) + + self._create_name_input = ClickableLineEdit() + self._create_name_input.setPlaceholderText(UI_LANGUAGE.get_translation("name_placeholder", "waiter_window")) + self._create_name_input.clicked.connect(self._open_create_name_keyboard) + adjust_font(self._create_name_input, MEDIUM_FONT) + self.data_container_management.addWidget(self._create_name_input) + + self.data_container_management.addWidget( + create_label(UI_LANGUAGE.get_translation("permissions_label", "waiter_window"), FontSize.MEDIUM, min_h=30) + ) + create_permissions_layout = QHBoxLayout() + self._create_permission_boxes = self._build_permission_checkboxes(create_permissions_layout, default_maker=True) + self.data_container_management.addLayout(create_permissions_layout) + + create_btn = create_button( + UI_LANGUAGE.get_translation("create_waiter", "waiter_window"), + font_size=MEDIUM_FONT, + min_h=50, + css_class="btn-inverted", + ) + create_btn.clicked.connect(self._create_waiter) + self.data_container_management.addItem(create_spacer(10)) + self.data_container_management.addWidget(create_btn) + + self._edit_section_widget = QWidget(self.scrollAreaWidgetContents_3) + self._edit_section_widget.setVisible(False) + self._render_edit_section() + + self.data_container_management.addItem(create_spacer(10)) + self._add_section_header( + self.data_container_management, + UI_LANGUAGE.get_translation("registered_waiters", "waiter_window"), + ) + self._waiter_list_layout = QVBoxLayout() + self.data_container_management.addLayout(self._waiter_list_layout) + self.data_container_management.addItem(create_spacer(1, expand=True)) + + self._refresh_waiters_list() + + def _render_edit_section(self) -> None: + layout = QVBoxLayout(self._edit_section_widget) + + self._edit_name_input = ClickableLineEdit() + self._edit_name_input.setPlaceholderText(UI_LANGUAGE.get_translation("name_placeholder", "waiter_window")) + self._edit_name_input.clicked.connect(self._open_edit_name_keyboard) + adjust_font(self._edit_name_input, MEDIUM_FONT) + layout.addWidget(self._edit_name_input) + + layout.addWidget( + create_label( + UI_LANGUAGE.get_translation("permissions_label", "waiter_window"), + FontSize.MEDIUM, + min_h=30, + ) + ) + permission_layout = QHBoxLayout() + self._edit_permission_boxes = self._build_permission_checkboxes(permission_layout, default_maker=True) + layout.addLayout(permission_layout) + layout.addItem(create_spacer(10)) + + actions = QHBoxLayout() + save_btn = create_button( + UI_LANGUAGE.get_translation("save", "waiter_window"), + font_size=MEDIUM_FONT, + min_h=45, + css_class="btn-inverted", + ) + save_btn.clicked.connect(self._save_waiter) + cancel_btn = create_button( + UI_LANGUAGE.get_translation("cancel", "waiter_window"), + font_size=MEDIUM_FONT, + min_h=45, + ) + cancel_btn.clicked.connect(self._cancel_edit) + actions.addWidget(save_btn) + actions.addWidget(cancel_btn) + layout.addLayout(actions) + + def _build_permission_checkboxes(self, layout: QHBoxLayout, default_maker: bool) -> dict[str, QCheckBox]: + boxes: dict[str, QCheckBox] = {} + for key in self._PERMISSION_KEYS: + checkbox = QCheckBox(UI_LANGUAGE.get_translation(f"permission_{key}", "waiter_window")) + adjust_font(checkbox, MEDIUM_FONT) + checkbox.setChecked(default_maker and key == "maker") + boxes[key] = checkbox + layout.addWidget(checkbox) + return boxes + + def _refresh_scan_state(self) -> None: + current_nfc_id = shared.current_waiter_nfc_id + current_name = ( + shared.current_waiter.name + if shared.current_waiter is not None + else UI_LANGUAGE.get_translation("no_waiter", "waiter_window") + ) + self._scan_id_label.setText( + f"{UI_LANGUAGE.get_translation('current_nfc', 'waiter_window')}: {current_nfc_id or '-'}" + ) + current_waiter_label = UI_LANGUAGE.get_translation("current_waiter", "waiter_window") + self._scan_name_label.setText(f"{current_waiter_label}: {current_name}") + + if current_nfc_id != self._last_seen_nfc_id: + self._last_seen_nfc_id = current_nfc_id + self._refresh_waiters_list() + + def _use_scanned_nfc(self) -> None: + if shared.current_waiter_nfc_id is not None: + self._create_nfc_input.setText(shared.current_waiter_nfc_id) + + def _create_waiter(self) -> None: + nfc_id = self._create_nfc_input.text().strip() + name = self._create_name_input.text().strip() + if not nfc_id or not name: + DP_CONTROLLER.say_some_value_missing() + return + permissions = self._collect_permissions(self._create_permission_boxes) + try: + DatabaseCommander().create_waiter(nfc_id=nfc_id, name=name, permissions=permissions) + except ElementAlreadyExistsError: + DP_CONTROLLER.standard_box(UI_LANGUAGE.get_translation("waiter_exists", "waiter_window")) + return + + self._create_nfc_input.clear() + self._create_name_input.clear() + for key, checkbox in self._create_permission_boxes.items(): + checkbox.setChecked(key == "maker") + + DP_CONTROLLER.standard_box(UI_LANGUAGE.get_translation("waiter_created", "waiter_window"), close_time=5) + self._refresh_waiters_list() + + def _start_edit_waiter(self, nfc_id: str) -> None: + waiter = DatabaseCommander().get_waiter_by_nfc_id(nfc_id) + if waiter is None: + return + self._editing_nfc_id = nfc_id + self._edit_name_input.setText(waiter.name) + self._edit_permission_boxes["maker"].setChecked(waiter.privilege_maker) + self._edit_permission_boxes["ingredients"].setChecked(waiter.privilege_ingredients) + self._edit_permission_boxes["recipes"].setChecked(waiter.privilege_recipes) + self._edit_permission_boxes["bottles"].setChecked(waiter.privilege_bottles) + self._refresh_waiters_list() + + def _save_waiter(self) -> None: + if self._editing_nfc_id is None: + return + edit_name = self._edit_name_input.text().strip() + if not edit_name: + DP_CONTROLLER.say_some_value_missing() + return + permissions = self._collect_permissions(self._edit_permission_boxes) + try: + DatabaseCommander().update_waiter(self._editing_nfc_id, name=edit_name, permissions=permissions) + except ElementAlreadyExistsError: + DP_CONTROLLER.standard_box(UI_LANGUAGE.get_translation("waiter_exists", "waiter_window")) + return + except ElementNotFoundError: + DP_CONTROLLER.standard_box(UI_LANGUAGE.get_translation("waiter_not_found", "waiter_window")) + self._cancel_edit() + return + DP_CONTROLLER.standard_box(UI_LANGUAGE.get_translation("waiter_updated", "waiter_window"), close_time=5) + self._cancel_edit() + self._refresh_waiters_list() + + def _cancel_edit(self) -> None: + self._editing_nfc_id = None + self._edit_section_widget.setVisible(False) + self._refresh_waiters_list() + + def _delete_waiter(self, nfc_id: str, waiter_name: str) -> None: + if not DP_CONTROLLER.ask_to_delete_x(waiter_name): + return + try: + DatabaseCommander().delete_waiter(nfc_id) + except ElementNotFoundError: + DP_CONTROLLER.standard_box(UI_LANGUAGE.get_translation("waiter_not_found", "waiter_window")) + return + DP_CONTROLLER.standard_box(UI_LANGUAGE.get_translation("waiter_deleted", "waiter_window"), close_time=5) + if self._editing_nfc_id == nfc_id: + self._cancel_edit() + self._refresh_waiters_list() + self._render_statistics() + + def _refresh_waiters_list(self) -> None: + self._edit_section_widget.setVisible(False) + DP_CONTROLLER.delete_items_of_layout(self._waiter_list_layout) + + waiters = DatabaseCommander().get_all_waiters() + if not waiters: + self._waiter_list_layout.addWidget( + create_label(UI_LANGUAGE.get_translation("no_waiters", "waiter_window"), FontSize.MEDIUM, centered=True) + ) + return + + for waiter in waiters: + if self._editing_nfc_id == waiter.nfc_id: + self._waiter_list_layout.addWidget(self._edit_section_widget) + self._edit_section_widget.setVisible(True) + self._waiter_list_layout.addItem(create_spacer(10)) + + card = QWidget() + card_layout = QVBoxLayout(card) + active_suffix = ( + f" ({UI_LANGUAGE.get_translation('active', 'waiter_window')})" + if shared.current_waiter_nfc_id == waiter.nfc_id + else "" + ) + card_layout.addWidget(create_label(f"{waiter.name}{active_suffix}", FontSize.MEDIUM, bold=True)) + card_layout.addWidget(create_label(waiter.nfc_id, FontSize.SMALL)) + + permission_names = [ + UI_LANGUAGE.get_translation(f"permission_{key}", "waiter_window") + for key in self._PERMISSION_KEYS + if bool(getattr(waiter, f"privilege_{key}")) + ] + if permission_names: + card_layout.addWidget(create_label(", ".join(permission_names), FontSize.SMALL, css_class="secondary")) + card_layout.addItem(create_spacer(4)) + + action_layout = QHBoxLayout() + edit_btn = create_button( + UI_LANGUAGE.get_translation("edit", "waiter_window"), + font_size=MEDIUM_FONT, + min_h=45, + css_class="btn-inverted", + ) + edit_btn.clicked.connect(lambda _, nfc=waiter.nfc_id: self._start_edit_waiter(nfc)) + delete_btn = create_button( + UI_LANGUAGE.get_translation("delete", "waiter_window"), + font_size=MEDIUM_FONT, + min_h=45, + css_class="destructive", + ) + delete_btn.clicked.connect(lambda _, nfc=waiter.nfc_id, name=waiter.name: self._delete_waiter(nfc, name)) + action_layout.addWidget(edit_btn) + action_layout.addWidget(delete_btn) + card_layout.addLayout(action_layout) + self._waiter_list_layout.addWidget(card) + + def _render_statistics(self) -> None: + DP_CONTROLLER.delete_items_of_layout(self.data_container_statistics) + logs = DatabaseCommander().get_waiter_logs() + if not logs: + self.data_container_statistics.addWidget( + create_label(UI_LANGUAGE.get_translation("no_logs", "waiter_window"), FontSize.MEDIUM, centered=True) + ) + self.data_container_statistics.addItem(create_spacer(1, expand=True)) + return + + grouped: dict[str, dict[str, list[DbWaiterLog]]] = defaultdict(lambda: defaultdict(list)) + for log in logs: + date_key = log.timestamp.strftime("%Y-%m-%d") + waiter_name = ( + log.waiter.name + if log.waiter is not None + else UI_LANGUAGE.get_translation("deleted_user", "waiter_window") + ) + grouped[date_key][waiter_name].append(log) + + for date_key, waiters in grouped.items(): + date_logs = [entry for waiter_logs in waiters.values() for entry in waiter_logs] + date_summary = self._build_stats_summary(len(date_logs), sum(log.volume for log in date_logs)) + self._add_section_header(self.data_container_statistics, f"{date_key} - {date_summary}") + for waiter_name, waiter_logs in waiters.items(): + self.data_container_statistics.addWidget(self._build_waiter_log_group(waiter_name, waiter_logs)) + + self.data_container_statistics.addItem(create_spacer(1, expand=True)) + + def _build_waiter_log_group(self, waiter_name: str, logs: list[DbWaiterLog]) -> QWidget: + wrapper = QWidget() + wrapper_layout = QVBoxLayout(wrapper) + wrapper_layout.setContentsMargins(0, 0, 0, 0) + wrapper_layout.setSpacing(4) + summary = self._build_stats_summary(len(logs), sum(log.volume for log in logs)) + toggle = create_button( + f"{waiter_name} ({summary})", + font_size=MEDIUM_FONT, + min_h=45, + css_class="btn-inverted", + ) + content = QWidget() + content_layout = QVBoxLayout(content) + content.setVisible(False) + + for log in logs: + time_str = log.timestamp.strftime("%H:%M") + recipe_name = ( + log.recipe.name + if log.recipe is not None + else UI_LANGUAGE.get_translation("deleted_recipe", "waiter_window") + ) + virgin_suffix = f" ({UI_LANGUAGE.get_translation('virgin', 'waiter_window')})" if log.is_virgin else "" + content_layout.addWidget( + create_label( + f"{time_str} - {recipe_name} - {log.volume} ml{virgin_suffix}", + FontSize.SMALL, + ) + ) + + toggle.clicked.connect(lambda: content.setVisible(not content.isVisible())) + wrapper_layout.addWidget(toggle) + wrapper_layout.addWidget(content) + return wrapper + + def _collect_permissions(self, checkboxes: dict[str, QCheckBox]) -> dict[str, bool]: + return {key: box.isChecked() for key, box in checkboxes.items()} + + def _build_stats_summary(self, cocktails_count: int, total_volume_ml: int) -> str: + return UI_LANGUAGE.get_translation( + "stats_summary", + "waiter_window", + cocktails=cocktails_count, + volume=total_volume_ml, + ) + + def _add_section_header(self, layout: QVBoxLayout, text: str) -> None: + layout.addWidget(create_label(text, FontSize.LARGE, bold=True, min_h=36, css_class="secondary")) + layout.addItem(create_spacer(6)) + + def _open_create_nfc_keyboard(self) -> None: + KeyboardWidget(self.mainscreen, self._create_nfc_input) + + def _open_create_name_keyboard(self) -> None: + KeyboardWidget(self.mainscreen, self._create_name_input) + + def _open_edit_name_keyboard(self) -> None: + KeyboardWidget(self.mainscreen, self._edit_name_input) diff --git a/src/ui_elements/__init__.py b/src/ui_elements/__init__.py index fea0885e..6c09670e 100644 --- a/src/ui_elements/__init__.py +++ b/src/ui_elements/__init__.py @@ -30,6 +30,7 @@ from .refill_prompt import Ui_RefillPrompt from .resource_window import Ui_ResourceWindow from .sumup_reader_window import Ui_SumupWindow +from .waiter_window import Ui_WaiterWindow __all__ = [ "Ui_addingredient", @@ -62,5 +63,6 @@ "Ui_RFIDWriterWindow", "Ui_SumupWindow", "Ui_Teamselection", + "Ui_WaiterWindow", "Ui_WiFiWindow", ] diff --git a/src/ui_elements/optionwindow.py b/src/ui_elements/optionwindow.py index 03fe1489..748760d8 100644 --- a/src/ui_elements/optionwindow.py +++ b/src/ui_elements/optionwindow.py @@ -42,40 +42,31 @@ def setupUi(self, Optionwindow): self.scrollArea.setWidgetResizable(True) self.scrollArea.setObjectName("scrollArea") self.scrollAreaWidgetContents = QtWidgets.QWidget() - self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 765, 942)) + self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 765, 1028)) self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents") self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.scrollAreaWidgetContents) self.verticalLayout_3.setContentsMargins(0, 0, 4, 0) self.verticalLayout_3.setObjectName("verticalLayout_3") self.gridLayout_2 = QtWidgets.QGridLayout() self.gridLayout_2.setObjectName("gridLayout_2") - self.button_calibration = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_calibration.setMinimumSize(QtCore.QSize(0, 80)) - self.button_calibration.setMaximumSize(QtCore.QSize(5000, 300)) - font = QtGui.QFont() - font.setPointSize(28) - font.setBold(True) - self.button_calibration.setFont(font) - self.button_calibration.setObjectName("button_calibration") - self.gridLayout_2.addWidget(self.button_calibration, 0, 1, 1, 1) - self.button_logs = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_logs.setMinimumSize(QtCore.QSize(0, 80)) - self.button_logs.setMaximumSize(QtCore.QSize(5000, 300)) + self.button_backup = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_backup.setMinimumSize(QtCore.QSize(0, 80)) + self.button_backup.setMaximumSize(QtCore.QSize(5000, 300)) font = QtGui.QFont() font.setPointSize(28) font.setBold(True) - self.button_logs.setFont(font) - self.button_logs.setObjectName("button_logs") - self.gridLayout_2.addWidget(self.button_logs, 4, 0, 1, 1) - self.button_restore = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_restore.setMinimumSize(QtCore.QSize(0, 80)) - self.button_restore.setMaximumSize(QtCore.QSize(5000, 300)) + self.button_backup.setFont(font) + self.button_backup.setObjectName("button_backup") + self.gridLayout_2.addWidget(self.button_backup, 2, 0, 1, 1) + self.button_update_software = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_update_software.setMinimumSize(QtCore.QSize(0, 80)) + self.button_update_software.setMaximumSize(QtCore.QSize(5000, 300)) font = QtGui.QFont() font.setPointSize(28) font.setBold(True) - self.button_restore.setFont(font) - self.button_restore.setObjectName("button_restore") - self.gridLayout_2.addWidget(self.button_restore, 2, 1, 1, 1) + self.button_update_software.setFont(font) + self.button_update_software.setObjectName("button_update_software") + self.gridLayout_2.addWidget(self.button_update_software, 7, 0, 1, 2) self.button_addons = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_addons.setMinimumSize(QtCore.QSize(0, 80)) self.button_addons.setMaximumSize(QtCore.QSize(5000, 300)) @@ -85,33 +76,15 @@ def setupUi(self, Optionwindow): self.button_addons.setFont(font) self.button_addons.setObjectName("button_addons") self.gridLayout_2.addWidget(self.button_addons, 9, 0, 1, 1) - self.button_config = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_config.setMinimumSize(QtCore.QSize(0, 80)) - self.button_config.setMaximumSize(QtCore.QSize(5000, 300)) - font = QtGui.QFont() - font.setPointSize(28) - font.setBold(True) - self.button_config.setFont(font) - self.button_config.setObjectName("button_config") - self.gridLayout_2.addWidget(self.button_config, 1, 0, 1, 1) - self.button_events = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_events.setMinimumSize(QtCore.QSize(0, 80)) - self.button_events.setMaximumSize(QtCore.QSize(5000, 300)) - font = QtGui.QFont() - font.setPointSize(28) - font.setBold(True) - self.button_events.setFont(font) - self.button_events.setObjectName("button_events") - self.gridLayout_2.addWidget(self.button_events, 6, 0, 1, 1) - self.button_clean = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_clean.setMinimumSize(QtCore.QSize(0, 80)) - self.button_clean.setMaximumSize(QtCore.QSize(5000, 300)) + self.button_check_internet = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_check_internet.setMinimumSize(QtCore.QSize(0, 80)) + self.button_check_internet.setMaximumSize(QtCore.QSize(5000, 300)) font = QtGui.QFont() font.setPointSize(28) font.setBold(True) - self.button_clean.setFont(font) - self.button_clean.setObjectName("button_clean") - self.gridLayout_2.addWidget(self.button_clean, 0, 0, 1, 1) + self.button_check_internet.setFont(font) + self.button_check_internet.setObjectName("button_check_internet") + self.gridLayout_2.addWidget(self.button_check_internet, 8, 1, 1, 1) self.button_export = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_export.setMinimumSize(QtCore.QSize(0, 80)) self.button_export.setMaximumSize(QtCore.QSize(5000, 300)) @@ -121,15 +94,6 @@ def setupUi(self, Optionwindow): self.button_export.setFont(font) self.button_export.setObjectName("button_export") self.gridLayout_2.addWidget(self.button_export, 1, 1, 1, 1) - self.button_update_software = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_update_software.setMinimumSize(QtCore.QSize(0, 80)) - self.button_update_software.setMaximumSize(QtCore.QSize(5000, 300)) - font = QtGui.QFont() - font.setPointSize(28) - font.setBold(True) - self.button_update_software.setFont(font) - self.button_update_software.setObjectName("button_update_software") - self.gridLayout_2.addWidget(self.button_update_software, 7, 0, 1, 2) self.button_rfid = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_rfid.setEnabled(False) self.button_rfid.setMinimumSize(QtCore.QSize(0, 80)) @@ -140,15 +104,15 @@ def setupUi(self, Optionwindow): self.button_rfid.setFont(font) self.button_rfid.setObjectName("button_rfid") self.gridLayout_2.addWidget(self.button_rfid, 9, 1, 1, 1) - self.button_about = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_about.setMinimumSize(QtCore.QSize(0, 80)) - self.button_about.setMaximumSize(QtCore.QSize(5000, 300)) + self.button_clean = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_clean.setMinimumSize(QtCore.QSize(0, 80)) + self.button_clean.setMaximumSize(QtCore.QSize(5000, 300)) font = QtGui.QFont() font.setPointSize(28) font.setBold(True) - self.button_about.setFont(font) - self.button_about.setObjectName("button_about") - self.gridLayout_2.addWidget(self.button_about, 11, 0, 1, 2) + self.button_clean.setFont(font) + self.button_clean.setObjectName("button_clean") + self.gridLayout_2.addWidget(self.button_clean, 0, 0, 1, 1) self.button_sumup = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_sumup.setMinimumSize(QtCore.QSize(0, 80)) self.button_sumup.setMaximumSize(QtCore.QSize(5000, 300)) @@ -158,6 +122,42 @@ def setupUi(self, Optionwindow): self.button_sumup.setFont(font) self.button_sumup.setObjectName("button_sumup") self.gridLayout_2.addWidget(self.button_sumup, 10, 1, 1, 1) + self.button_config = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_config.setMinimumSize(QtCore.QSize(0, 80)) + self.button_config.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_config.setFont(font) + self.button_config.setObjectName("button_config") + self.gridLayout_2.addWidget(self.button_config, 1, 0, 1, 1) + self.button_news = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_news.setMinimumSize(QtCore.QSize(0, 80)) + self.button_news.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_news.setFont(font) + self.button_news.setObjectName("button_news") + self.gridLayout_2.addWidget(self.button_news, 10, 0, 1, 1) + self.button_logs = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_logs.setMinimumSize(QtCore.QSize(0, 80)) + self.button_logs.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_logs.setFont(font) + self.button_logs.setObjectName("button_logs") + self.gridLayout_2.addWidget(self.button_logs, 4, 0, 1, 1) + self.button_calibration = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_calibration.setMinimumSize(QtCore.QSize(0, 80)) + self.button_calibration.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_calibration.setFont(font) + self.button_calibration.setObjectName("button_calibration") + self.gridLayout_2.addWidget(self.button_calibration, 0, 1, 1, 1) self.button_shutdown = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_shutdown.setMinimumSize(QtCore.QSize(0, 80)) self.button_shutdown.setMaximumSize(QtCore.QSize(5000, 300)) @@ -167,15 +167,6 @@ def setupUi(self, Optionwindow): self.button_shutdown.setFont(font) self.button_shutdown.setObjectName("button_shutdown") self.gridLayout_2.addWidget(self.button_shutdown, 3, 1, 1, 1) - self.button_backup = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_backup.setMinimumSize(QtCore.QSize(0, 80)) - self.button_backup.setMaximumSize(QtCore.QSize(5000, 300)) - font = QtGui.QFont() - font.setPointSize(28) - font.setBold(True) - self.button_backup.setFont(font) - self.button_backup.setObjectName("button_backup") - self.gridLayout_2.addWidget(self.button_backup, 2, 0, 1, 1) self.button_reboot = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_reboot.setMinimumSize(QtCore.QSize(0, 80)) self.button_reboot.setMaximumSize(QtCore.QSize(5000, 300)) @@ -185,6 +176,15 @@ def setupUi(self, Optionwindow): self.button_reboot.setFont(font) self.button_reboot.setObjectName("button_reboot") self.gridLayout_2.addWidget(self.button_reboot, 3, 0, 1, 1) + self.button_restore = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_restore.setMinimumSize(QtCore.QSize(0, 80)) + self.button_restore.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_restore.setFont(font) + self.button_restore.setObjectName("button_restore") + self.gridLayout_2.addWidget(self.button_restore, 2, 1, 1, 1) self.button_wifi = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_wifi.setMinimumSize(QtCore.QSize(0, 80)) self.button_wifi.setMaximumSize(QtCore.QSize(5000, 300)) @@ -194,24 +194,6 @@ def setupUi(self, Optionwindow): self.button_wifi.setFont(font) self.button_wifi.setObjectName("button_wifi") self.gridLayout_2.addWidget(self.button_wifi, 8, 0, 1, 1) - self.button_check_internet = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_check_internet.setMinimumSize(QtCore.QSize(0, 80)) - self.button_check_internet.setMaximumSize(QtCore.QSize(5000, 300)) - font = QtGui.QFont() - font.setPointSize(28) - font.setBold(True) - self.button_check_internet.setFont(font) - self.button_check_internet.setObjectName("button_check_internet") - self.gridLayout_2.addWidget(self.button_check_internet, 8, 1, 1, 1) - self.button_news = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) - self.button_news.setMinimumSize(QtCore.QSize(0, 80)) - self.button_news.setMaximumSize(QtCore.QSize(5000, 300)) - font = QtGui.QFont() - font.setPointSize(28) - font.setBold(True) - self.button_news.setFont(font) - self.button_news.setObjectName("button_news") - self.gridLayout_2.addWidget(self.button_news, 10, 0, 1, 1) self.button_resources = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_resources.setMinimumSize(QtCore.QSize(0, 80)) self.button_resources.setMaximumSize(QtCore.QSize(5000, 300)) @@ -221,6 +203,24 @@ def setupUi(self, Optionwindow): self.button_resources.setFont(font) self.button_resources.setObjectName("button_resources") self.gridLayout_2.addWidget(self.button_resources, 4, 1, 1, 1) + self.button_about = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_about.setMinimumSize(QtCore.QSize(0, 80)) + self.button_about.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_about.setFont(font) + self.button_about.setObjectName("button_about") + self.gridLayout_2.addWidget(self.button_about, 12, 0, 1, 2) + self.button_events = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_events.setMinimumSize(QtCore.QSize(0, 80)) + self.button_events.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_events.setFont(font) + self.button_events.setObjectName("button_events") + self.gridLayout_2.addWidget(self.button_events, 6, 0, 1, 1) self.button_update_system = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) self.button_update_system.setMinimumSize(QtCore.QSize(0, 80)) self.button_update_system.setMaximumSize(QtCore.QSize(5000, 300)) @@ -230,6 +230,15 @@ def setupUi(self, Optionwindow): self.button_update_system.setFont(font) self.button_update_system.setObjectName("button_update_system") self.gridLayout_2.addWidget(self.button_update_system, 6, 1, 1, 1) + self.button_waiter = QtWidgets.QPushButton(parent=self.scrollAreaWidgetContents) + self.button_waiter.setMinimumSize(QtCore.QSize(0, 80)) + self.button_waiter.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_waiter.setFont(font) + self.button_waiter.setObjectName("button_waiter") + self.gridLayout_2.addWidget(self.button_waiter, 11, 0, 1, 1) self.verticalLayout_3.addLayout(self.gridLayout_2) self.scrollArea.setWidget(self.scrollAreaWidgetContents) self.gridLayout.addWidget(self.scrollArea, 4, 0, 1, 1) @@ -243,30 +252,31 @@ def retranslateUi(self, Optionwindow): Optionwindow.setWindowTitle(_translate("Optionwindow", "Options")) self.button_back.setText(_translate("Optionwindow", "< Back")) self.button_back.setProperty("cssClass", _translate("Optionwindow", "btn-inverted")) - self.button_calibration.setText(_translate("Optionwindow", "Calibration")) - self.button_calibration.setProperty("cssClass", _translate("Optionwindow", "btn-inverted")) - self.button_logs.setText(_translate("Optionwindow", "Logs")) - self.button_restore.setText(_translate("Optionwindow", "Restore")) - self.button_addons.setText(_translate("Optionwindow", "Addons")) - self.button_config.setText(_translate("Optionwindow", "Change Config")) - self.button_config.setProperty("cssClass", _translate("Optionwindow", "btn-inverted")) - self.button_events.setText(_translate("Optionwindow", "Events")) - self.button_clean.setText(_translate("Optionwindow", "Cleaning")) - self.button_clean.setProperty("cssClass", _translate("Optionwindow", "btn-inverted")) - self.button_export.setText(_translate("Optionwindow", "Export")) + self.button_backup.setText(_translate("Optionwindow", "Backup")) self.button_update_software.setText(_translate("Optionwindow", "Update CocktailBerry Software")) self.button_update_software.setProperty("cssClass", _translate("Optionwindow", "btn-inverted")) + self.button_addons.setText(_translate("Optionwindow", "Addons")) + self.button_check_internet.setText(_translate("Optionwindow", "Check Internet")) + self.button_export.setText(_translate("Optionwindow", "Export")) self.button_rfid.setText(_translate("Optionwindow", "Write RFID")) - self.button_about.setText(_translate("Optionwindow", "About CocktailBerry")) + self.button_clean.setText(_translate("Optionwindow", "Cleaning")) + self.button_clean.setProperty("cssClass", _translate("Optionwindow", "btn-inverted")) self.button_sumup.setText(_translate("Optionwindow", "SumUp Setup")) + self.button_config.setText(_translate("Optionwindow", "Change Config")) + self.button_config.setProperty("cssClass", _translate("Optionwindow", "btn-inverted")) + self.button_news.setText(_translate("Optionwindow", "News")) + self.button_logs.setText(_translate("Optionwindow", "Logs")) + self.button_calibration.setText(_translate("Optionwindow", "Calibration")) + self.button_calibration.setProperty("cssClass", _translate("Optionwindow", "btn-inverted")) self.button_shutdown.setText(_translate("Optionwindow", "Shutdown")) - self.button_backup.setText(_translate("Optionwindow", "Backup")) self.button_reboot.setText(_translate("Optionwindow", "Reboot")) + self.button_restore.setText(_translate("Optionwindow", "Restore")) self.button_wifi.setText(_translate("Optionwindow", "WiFi")) - self.button_check_internet.setText(_translate("Optionwindow", "Check Internet")) - self.button_news.setText(_translate("Optionwindow", "News")) self.button_resources.setText(_translate("Optionwindow", "Resource Usage")) + self.button_about.setText(_translate("Optionwindow", "About CocktailBerry")) + self.button_events.setText(_translate("Optionwindow", "Events")) self.button_update_system.setText(_translate("Optionwindow", "Update System")) + self.button_waiter.setText(_translate("Optionwindow", "Service Personnel")) if __name__ == "__main__": diff --git a/src/ui_elements/optionwindow.ui b/src/ui_elements/optionwindow.ui index 0236caec..e53f250f 100644 --- a/src/ui_elements/optionwindow.ui +++ b/src/ui_elements/optionwindow.ui @@ -87,7 +87,7 @@ 0 0 765 - 942 + 1028 @@ -105,8 +105,8 @@ - - + + 0 @@ -126,15 +126,12 @@ - Calibration - - - btn-inverted + Backup - - + + 0 @@ -154,12 +151,15 @@ - Logs + Update CocktailBerry Software + + + btn-inverted - - + + 0 @@ -179,12 +179,12 @@ - Restore + Addons - - + + 0 @@ -204,12 +204,12 @@ - Addons + Check Internet - - + + 0 @@ -229,15 +229,15 @@ - Change Config - - - btn-inverted + Export - - + + + + false + 0 @@ -257,7 +257,7 @@ - Events + Write RFID @@ -289,8 +289,8 @@ - - + + 0 @@ -310,12 +310,12 @@ - Export + SumUp Setup - - + + 0 @@ -335,18 +335,15 @@ - Update CocktailBerry Software + Change Config btn-inverted - - - - false - + + 0 @@ -366,12 +363,12 @@ - Write RFID + News - - + + 0 @@ -391,12 +388,12 @@ - About CocktailBerry + Logs - - + + 0 @@ -416,7 +413,10 @@ - SumUp Setup + Calibration + + + btn-inverted @@ -445,8 +445,8 @@ - - + + 0 @@ -466,12 +466,12 @@ - Backup + Reboot - - + + 0 @@ -491,7 +491,7 @@ - Reboot + Restore @@ -520,8 +520,8 @@ - - + + 0 @@ -541,12 +541,12 @@ - Check Internet + Resource Usage - - + + 0 @@ -566,12 +566,12 @@ - News + About CocktailBerry - - + + 0 @@ -591,7 +591,7 @@ - Resource Usage + Events @@ -620,6 +620,31 @@ + + + + + 0 + 80 + + + + + 5000 + 300 + + + + + 28 + true + + + + Service Personnel + + + diff --git a/src/ui_elements/waiter_window.py b/src/ui_elements/waiter_window.py new file mode 100644 index 00000000..c4c09eec --- /dev/null +++ b/src/ui_elements/waiter_window.py @@ -0,0 +1,121 @@ +# Form implementation generated from reading ui file 'waiter_window.ui' +# +# Created by: PyQt6 UI code generator 6.10.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_WaiterWindow(object): + def setupUi(self, WaiterWindow): + WaiterWindow.setObjectName("WaiterWindow") + WaiterWindow.resize(800, 480) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(WaiterWindow.sizePolicy().hasHeightForWidth()) + WaiterWindow.setSizePolicy(sizePolicy) + WaiterWindow.setMinimumSize(QtCore.QSize(800, 480)) + WaiterWindow.setMaximumSize(QtCore.QSize(800, 480)) + WaiterWindow.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.ArrowCursor)) + WaiterWindow.setStyleSheet("") + self.centralwidget = QtWidgets.QWidget(parent=WaiterWindow) + self.centralwidget.setObjectName("centralwidget") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.centralwidget) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.waiter_tabs = QtWidgets.QTabWidget(parent=self.centralwidget) + font = QtGui.QFont() + font.setPointSize(14) + font.setBold(True) + self.waiter_tabs.setFont(font) + self.waiter_tabs.setObjectName("waiter_tabs") + self.tab_management = QtWidgets.QWidget() + self.tab_management.setObjectName("tab_management") + self.verticalLayout = QtWidgets.QVBoxLayout(self.tab_management) + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setSpacing(0) + self.verticalLayout.setObjectName("verticalLayout") + self.scroll_area_2 = QtWidgets.QScrollArea(parent=self.tab_management) + self.scroll_area_2.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.scroll_area_2.setFrameShadow(QtWidgets.QFrame.Shadow.Plain) + self.scroll_area_2.setLineWidth(1) + self.scroll_area_2.setWidgetResizable(True) + self.scroll_area_2.setObjectName("scroll_area_2") + self.scrollAreaWidgetContents_3 = QtWidgets.QWidget() + self.scrollAreaWidgetContents_3.setGeometry(QtCore.QRect(0, 0, 776, 358)) + self.scrollAreaWidgetContents_3.setObjectName("scrollAreaWidgetContents_3") + self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.scrollAreaWidgetContents_3) + self.verticalLayout_6.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_6.setObjectName("verticalLayout_6") + self.data_container_management = QtWidgets.QVBoxLayout() + self.data_container_management.setContentsMargins(9, 3, 6, 15) + self.data_container_management.setSpacing(6) + self.data_container_management.setObjectName("data_container_management") + self.verticalLayout_6.addLayout(self.data_container_management) + self.scroll_area_2.setWidget(self.scrollAreaWidgetContents_3) + self.verticalLayout.addWidget(self.scroll_area_2) + self.waiter_tabs.addTab(self.tab_management, "") + self.tab_statistics = QtWidgets.QWidget() + self.tab_statistics.setObjectName("tab_statistics") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.tab_statistics) + self.verticalLayout_3.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_3.setSpacing(0) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.scroll_area = QtWidgets.QScrollArea(parent=self.tab_statistics) + self.scroll_area.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.scroll_area.setFrameShadow(QtWidgets.QFrame.Shadow.Plain) + self.scroll_area.setLineWidth(1) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setObjectName("scroll_area") + self.scrollAreaWidgetContents_2 = QtWidgets.QWidget() + self.scrollAreaWidgetContents_2.setGeometry(QtCore.QRect(0, 0, 776, 358)) + self.scrollAreaWidgetContents_2.setObjectName("scrollAreaWidgetContents_2") + self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.scrollAreaWidgetContents_2) + self.verticalLayout_5.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_5.setObjectName("verticalLayout_5") + self.data_container_statistics = QtWidgets.QVBoxLayout() + self.data_container_statistics.setContentsMargins(9, 3, 6, 15) + self.data_container_statistics.setSpacing(6) + self.data_container_statistics.setObjectName("data_container_statistics") + self.verticalLayout_5.addLayout(self.data_container_statistics) + self.scroll_area.setWidget(self.scrollAreaWidgetContents_2) + self.verticalLayout_3.addWidget(self.scroll_area) + self.waiter_tabs.addTab(self.tab_statistics, "") + self.verticalLayout_2.addWidget(self.waiter_tabs) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.button_back = QtWidgets.QPushButton(parent=self.centralwidget) + self.button_back.setMaximumSize(QtCore.QSize(5000, 300)) + font = QtGui.QFont() + font.setPointSize(28) + font.setBold(True) + self.button_back.setFont(font) + self.button_back.setObjectName("button_back") + self.horizontalLayout.addWidget(self.button_back) + self.verticalLayout_2.addLayout(self.horizontalLayout) + WaiterWindow.setCentralWidget(self.centralwidget) + + self.retranslateUi(WaiterWindow) + self.waiter_tabs.setCurrentIndex(0) + QtCore.QMetaObject.connectSlotsByName(WaiterWindow) + + def retranslateUi(self, WaiterWindow): + _translate = QtCore.QCoreApplication.translate + WaiterWindow.setWindowTitle(_translate("WaiterWindow", "Service Personnel")) + self.waiter_tabs.setTabText(self.waiter_tabs.indexOf(self.tab_management), _translate("WaiterWindow", "Management")) + self.waiter_tabs.setTabText(self.waiter_tabs.indexOf(self.tab_statistics), _translate("WaiterWindow", "Statistics")) + self.button_back.setText(_translate("WaiterWindow", "< Back")) + self.button_back.setProperty("cssClass", _translate("WaiterWindow", "btn-inverted")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + WaiterWindow = QtWidgets.QMainWindow() + ui = Ui_WaiterWindow() + ui.setupUi(WaiterWindow) + WaiterWindow.show() + sys.exit(app.exec()) diff --git a/src/ui_elements/waiter_window.ui b/src/ui_elements/waiter_window.ui new file mode 100644 index 00000000..43151488 --- /dev/null +++ b/src/ui_elements/waiter_window.ui @@ -0,0 +1,248 @@ + + + WaiterWindow + + + + 0 + 0 + 800 + 480 + + + + + 0 + 0 + + + + + 800 + 480 + + + + + 800 + 480 + + + + ArrowCursor + + + Service Personnel + + + + + + + + + + + 14 + true + + + + 0 + + + + Management + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 1 + + + true + + + + + 0 + 0 + 776 + 358 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 6 + + + 9 + + + 3 + + + 6 + + + 15 + + + + + + + + + + + + Statistics + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 1 + + + true + + + + + 0 + 0 + 776 + 358 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 6 + + + 9 + + + 3 + + + 6 + + + 15 + + + + + + + + + + + + + + + + + + 5000 + 300 + + + + + 28 + true + + + + < Back + + + btn-inverted + + + + + + + + + + + diff --git a/tests/db/__init__.py b/tests/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/db/test_available.py b/tests/db/test_available.py new file mode 100644 index 00000000..5ec28c2a --- /dev/null +++ b/tests/db/test_available.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from src.database_commander import DatabaseCommander + + +class TestAvailable: + def test_get_available_ingredient_names(self, db_commander: DatabaseCommander): + """Test the get_available_ingredient_names method.""" + names = db_commander.get_available_ingredient_names() + assert len(names) == 1 + assert names[0] == "Blue Curacao" + + def test_get_available_ids(self, db_commander: DatabaseCommander): + """Test the get_available_ids method.""" + ids = db_commander.get_available_ids() + assert len(ids) == 1 + assert ids[0] == 4 + + def test_insert_empty_silently_skip(self, db_commander: DatabaseCommander): + db_commander.delete_existing_handadd_ingredient() + db_commander.insert_multiple_existing_handadd_ingredients([]) + assert db_commander.get_available_ingredient_names() == [] diff --git a/tests/db/test_backup.py b/tests/db/test_backup.py new file mode 100644 index 00000000..a0607909 --- /dev/null +++ b/tests/db/test_backup.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from unittest.mock import patch + +from src.database_commander import DatabaseCommander + + +class TestBackup: + def test_create_backup(self, db_commander: DatabaseCommander): + """Test the create_backup method.""" + with patch("shutil.copy") as mock_shutil_copy: + db_commander.create_backup() + mock_shutil_copy.assert_called_once() diff --git a/tests/db/test_bottle.py b/tests/db/test_bottle.py new file mode 100644 index 00000000..32970d47 --- /dev/null +++ b/tests/db/test_bottle.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest + +from src.database_commander import DatabaseCommander + + +class TestBottle: + def test_get_bottle_fill_levels(self, db_commander: DatabaseCommander): + """Test the get_bottle_fill_levels method.""" + fill_levels = db_commander.get_bottle_fill_levels() + assert len(fill_levels) == 24 + assert fill_levels[0] == 100 + assert fill_levels[1] == 0 + + def test_get_bottle_data(self, db_commander: DatabaseCommander): + ingredients = db_commander.get_ingredients_at_bottles() + assert len(ingredients) == 24 + assert ingredients[0].name == "White Rum" + assert ingredients[1].name == "Cola" + + @pytest.mark.parametrize( + "ingredient_id, expected_usage", + [ + (1, True), + (4, False), + ], + ) + def test_get_bottle_usage(self, db_commander: DatabaseCommander, ingredient_id: int, expected_usage: bool): + """Test the get_bottle_usage method.""" + usage = db_commander.get_bottle_usage(ingredient_id) + assert usage is expected_usage + + def test_set_bottle_order(self, db_commander: DatabaseCommander): + """Test the set_bottle_order method.""" + db_commander.set_bottle_order(["White Rum", "Cola"]) + data = db_commander.get_ingredients_at_bottles() + assert data[0].name == "White Rum" + + def test_get_ingredient_at_bottle(self, db_commander: DatabaseCommander): + """Test the get_ingredient_at_bottle method.""" + ingredient = db_commander.get_ingredient_at_bottle(1) + assert ingredient is not None + assert ingredient.name == "White Rum" + + def test_get_ingredient_at_bottle_not_set(self, db_commander: DatabaseCommander): + """Test the get_ingredient_at_bottle method when no ingredient is set.""" + ingredient = db_commander.get_ingredient_at_bottle(10) + assert ingredient is None + + @pytest.mark.parametrize("ing", [6, "Fanta"]) + def test_set_bottle_at_slot(self, db_commander: DatabaseCommander, ing: int | str): + """Test the set_bottle_at_slot method.""" + db_commander.set_bottle_at_slot(ing, 1) + ingredient = db_commander.get_ingredient_at_bottle(1) + assert ingredient is not None + assert ingredient.name == "Fanta" + + def test_set_bottle_volumelevel_to_max(self, db_commander: DatabaseCommander): + """Test the set_bottle_volumelevel_to_max method.""" + db_commander.set_bottle_volumelevel_to_max([1]) + fill_levels = db_commander.get_bottle_fill_levels() + assert fill_levels[0] == 100 + + def test_get_ingredient_names_at_bottles_with_empty(self, db_commander: DatabaseCommander): + """Test the get_ingredient_names_at_bottles method with empty bottles.""" + ingredient_names = db_commander.get_ingredient_names_at_bottles() + assert len(ingredient_names) == 24 # Includes the empty bottle + assert ingredient_names[3] == "" # Bottle 4 is empty + # we have 4 bottles with ingredients + assert len([name for name in ingredient_names if name != ""]) == 4 diff --git a/tests/db/test_cocktail.py b/tests/db/test_cocktail.py new file mode 100644 index 00000000..ed1e6ee1 --- /dev/null +++ b/tests/db/test_cocktail.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import pytest +from sqlalchemy.orm import Session + +from src.database_commander import ( + DatabaseCommander, + DatabaseTransactionError, + ElementAlreadyExistsError, + ElementNotFoundError, +) +from src.db_models import DbRecipe + + +class TestCocktail: + def test_get_cocktail(self, db_commander: DatabaseCommander): + """Test the get_cocktail method.""" + cocktail = db_commander.get_cocktail(1) + assert cocktail is not None + assert cocktail.name == "Cuba Libre" + assert cocktail.alcohol == 11 + assert cocktail.amount == 290 + assert cocktail.enabled is True + assert cocktail.virgin_available is False + assert len(cocktail.ingredients) == 2 + + ingredient = cocktail.ingredients[0] + assert ingredient.name == "Cola" + assert ingredient.alcohol == 0 + assert ingredient.amount == 210 + + def test_get_all_cocktails(self, db_commander: DatabaseCommander): + """Test the get_all_cocktails method.""" + cocktails = db_commander.get_all_cocktails() + assert len(cocktails) == 5 + + cuba_libre = next((c for c in cocktails if c.name == "Cuba Libre"), None) + assert cuba_libre is not None + assert cuba_libre.alcohol == 11 + assert cuba_libre.amount == 290 + assert cuba_libre.enabled is True + assert cuba_libre.virgin_available is False + assert len(cuba_libre.ingredients) == 2 + + def test_get_possible_cocktails(self, db_commander: DatabaseCommander): + """Test the get_possible_cocktails method.""" + possible_cocktails = db_commander.get_possible_cocktails(max_hand_ingredients=1) + # Now we should have 3 possible cocktails: Cuba Libre, With Handadd, and Virgin Only Possible + assert len(possible_cocktails) == 3 + # Check that Cuba Libre is in the list + cuba_libre = next((c for c in possible_cocktails if c.name == "Cuba Libre"), None) + assert cuba_libre is not None + assert cuba_libre.only_virgin is False + + def test_get_possible_cocktail_virgin_only(self, db_commander: DatabaseCommander): + """Test that a cocktail that can only be made in virgin form is properly flagged.""" + possible_cocktails = db_commander.get_possible_cocktails(max_hand_ingredients=1) + + virgin_only = next((c for c in possible_cocktails if c.name == "Virgin Only Possible"), None) + assert virgin_only is not None + assert virgin_only.only_virgin is True + + # Verify that this cocktail has an alcoholic ingredient that's not available + alcoholic_ingredients = [ing for ing in virgin_only.ingredients if ing.alcohol > 0] + assert len(alcoholic_ingredients) > 0 + + # Check that the virgin ingredients are available either via machine or hand + virgin_ingredients = [ing for ing in virgin_only.ingredients if ing.alcohol == 0] + assert len(virgin_ingredients) > 0 + + for ing in virgin_ingredients: + # Either the ingredient is connected to a bottle or it's in the available handadd list + assert ing.bottle is not None or ing.id in db_commander.get_available_ids() + + def test_get_disabled_cocktails(self, db_commander: DatabaseCommander): + """Test that we can get only not enabled cocktails.""" + disabled_cocktails = db_commander.get_all_cocktails(status="disabled") + assert len(disabled_cocktails) == 1 + tequila_sunrise = next((c for c in disabled_cocktails if c.name == "Tequila Sunrise"), None) + assert tequila_sunrise is not None + + def test_increment_recipe_counter(self, db_commander: DatabaseCommander): + """Test the increment_recipe_counter method.""" + db_commander.increment_recipe_counter("Cuba Libre", virgin=False) + session = Session(db_commander.engine) + recipe = session.query(DbRecipe).filter_by(name="Cuba Libre").first() + session.close() + assert recipe is not None + assert recipe.counter == 1 + assert recipe.counter_virgin == 0 + + def test_increment_recipe_counter_virgin(self, db_commander: DatabaseCommander): + """Test the increment_recipe_counter method.""" + db_commander.increment_recipe_counter("Cuba Libre", virgin=True) + session = Session(db_commander.engine) + recipe = session.query(DbRecipe).filter_by(name="Cuba Libre").first() + session.close() + assert recipe is not None + assert recipe.counter == 0 + assert recipe.counter_virgin == 1 + + def test_set_recipe(self, db_commander: DatabaseCommander): + """Test the set_recipe method.""" + db_commander.set_recipe(1, "Cuba Libre 2", 11, 290, 1.0, True, False, [(1, 80, 1), (2, 210, 2)]) + cocktail = db_commander.get_cocktail(1) + assert cocktail is not None + assert cocktail.name == "Cuba Libre 2" + assert cocktail.price_per_100_ml == pytest.approx(1.0) + + def test_insert_new_recipe(self, db_commander: DatabaseCommander): + """Test the insert_new_recipe method.""" + db_commander.insert_new_recipe("New Recipe", 0, 1000, 8.5, True, False, [(1, 80, 1)]) + cocktail = db_commander.get_cocktail("New Recipe") + assert cocktail is not None + assert cocktail.name == "New Recipe" + assert cocktail.price_per_100_ml == pytest.approx(8.5) + + def test_insert_recipe_data(self, db_commander: DatabaseCommander): + """Test the insert_recipe_data method.""" + ingredient_data = [(1, 80, 1), (2, 100, 2), (3, 50, 3)] + cocktail = db_commander.insert_new_recipe("New Recipe", 0, 250, 4.0, True, False, ingredient_data) + assert cocktail is not None + cocktail = db_commander.get_cocktail(cocktail.id) + assert cocktail is not None + assert len(cocktail.ingredients) == 3 + # test that each ingredient is existing (first integer is id) + # sort ingredients by id + cocktail.ingredients.sort(key=lambda x: x.id) + for ingredient, data in zip(cocktail.ingredients, ingredient_data): + assert ingredient.id is data[0] + assert ingredient.amount is data[1] + assert ingredient.recipe_order is data[2] + + def test_delete_recipe(self, db_commander: DatabaseCommander): + """Test the delete_recipe method.""" + db_commander.delete_recipe("Cuba Libre") + cocktail = db_commander.get_cocktail("Cuba Libre") + assert cocktail is None + + def test_delete_recipe_ingredient_data(self, db_commander: DatabaseCommander): + """Test the delete_recipe_ingredient_data method.""" + db_commander.delete_recipe_ingredient_data(1) + cocktail = db_commander.get_cocktail(1) + assert cocktail is not None + assert len(cocktail.ingredients) == 0 + + def test_get_nonexistent_cocktail(self, db_commander: DatabaseCommander): + """Test getting a cocktail that does not exist.""" + cocktail = db_commander.get_cocktail(999) + assert cocktail is None + + def test_insert_duplicate_recipe(self, db_commander: DatabaseCommander): + """Test inserting a recipe with a duplicate name.""" + with pytest.raises(ElementAlreadyExistsError): + db_commander.insert_new_recipe("Cuba Libre", 11, 290, 5.0, True, False, [(1, 80, 1), (2, 210, 2)]) + + def test_delete_nonexistent_recipe(self, db_commander: DatabaseCommander): + """Test deleting a recipe that does not exist.""" + with pytest.raises(ElementNotFoundError): + db_commander.delete_recipe(999) + + def test_set_nonexistent_recipe(self, db_commander: DatabaseCommander): + """Test setting a recipe that does not exist.""" + with pytest.raises(ElementNotFoundError): + db_commander.set_recipe(999, "Nonexistent", 0, 0, 1.0, False, False, []) + + def test_increment_nonexistent_recipe_counter(self, db_commander: DatabaseCommander): + """Test incrementing the counter of a recipe that does not exist.""" + with pytest.raises(ElementNotFoundError): + db_commander.increment_recipe_counter("Nonexistent", virgin=False) + + def test_delete_recipe_still_in_use(self, db_commander: DatabaseCommander): + """Test deleting a recipe that is still in use.""" + db_commander.insert_new_recipe("Test Recipe", 0, 100, 2.5, True, False, [(1, 50, 1)]) + with pytest.raises(DatabaseTransactionError): + db_commander.delete_ingredient(1) + + def test_enable_all_recipes(self, db_commander: DatabaseCommander): + """Test enabling all recipes.""" + db_commander.set_all_recipes_enabled() + cocktails = db_commander.get_all_cocktails() + assert all(cocktail.enabled for cocktail in cocktails) + + def test_optimal_ingredient_selection(self, db_commander: DatabaseCommander): + """Test the optimal_ingredient_selection method.""" + for i in range(5): + db_commander.insert_new_recipe(f"rumAndCola {i}", 10, 250, 5.0, True, False, [(1, 50, 1), (2, 200, 2)]) + top_ing_ids = db_commander.get_most_used_ingredient_ids(k=2) + assert top_ing_ids == {1, 2} diff --git a/tests/db/test_data.py b/tests/db/test_data.py new file mode 100644 index 00000000..eeade7c3 --- /dev/null +++ b/tests/db/test_data.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from src.database_commander import DatabaseCommander + + +class TestData: + def test_get_consumption_data_lists_recipes(self, db_commander: DatabaseCommander): + """Test the get_consumption_data_lists_recipes method.""" + data = db_commander.get_consumption_data_lists_recipes() + assert len(data) == 3 + assert data[0][1] == "Cuba Libre" + + def test_get_consumption_data_lists_ingredients(self, db_commander: DatabaseCommander): + """Test the get_consumption_data_lists_ingredients method.""" + data = db_commander.get_consumption_data_lists_ingredients() + assert len(data) == 3 + assert data[0][1] == "White Rum" + + def test_get_cost_data_lists_ingredients(self, db_commander: DatabaseCommander): + """Test the get_cost_data_lists_ingredients method.""" + data = db_commander.get_cost_data_lists_ingredients() + assert len(data) == 3 + assert data[0][1] == "White Rum" + + def test_delete_database_data(self, db_commander: DatabaseCommander): + """Test the delete_database_data method.""" + db_commander.delete_database_data() + cocktails = db_commander.get_all_cocktails() + ingredients = db_commander.get_all_ingredients() + assert len(cocktails) == 0 + assert len(ingredients) == 0 diff --git a/tests/db/test_events.py b/tests/db/test_events.py new file mode 100644 index 00000000..50af8571 --- /dev/null +++ b/tests/db/test_events.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import datetime + +from sqlalchemy.orm import Session + +from src.database_commander import DatabaseCommander +from src.db_models import DbEvent +from src.models import EventType + + +class TestEvents: + def test_save_event_basic(self, db_commander: DatabaseCommander): + """Test saving a basic event without additional info.""" + db_commander.save_event(EventType.CLEANING) + events = db_commander.get_events() + assert len(events) == 1 + assert events[0].event_type == EventType.CLEANING + assert events[0].additional_info is None + + def test_save_event_with_additional_info(self, db_commander: DatabaseCommander): + """Test saving an event with additional info.""" + db_commander.save_event(EventType.COCKTAIL_PREPARATION, additional_info='{"cocktail": "Cuba Libre"}') + events = db_commander.get_events() + assert len(events) == 1 + assert events[0].event_type == EventType.COCKTAIL_PREPARATION + assert events[0].additional_info == '{"cocktail": "Cuba Libre"}' + + def test_save_multiple_events(self, db_commander: DatabaseCommander): + """Test saving multiple events of different types.""" + db_commander.save_event(EventType.CLEANING) + db_commander.save_event(EventType.COCKTAIL_PREPARATION, additional_info="Cuba Libre") + db_commander.save_event(EventType.SHUTDOWN) + + events = db_commander.get_events() + assert len(events) == 3 + # Events are ordered by timestamp descending + event_types = [e.event_type for e in events] + assert EventType.CLEANING in event_types + assert EventType.COCKTAIL_PREPARATION in event_types + assert EventType.SHUTDOWN in event_types + + def test_get_events_filter_by_type(self, db_commander: DatabaseCommander): + """Test filtering events by type.""" + db_commander.save_event(EventType.CLEANING) + db_commander.save_event(EventType.COCKTAIL_PREPARATION) + db_commander.save_event(EventType.CLEANING) + db_commander.save_event(EventType.SHUTDOWN) + + # Filter for CLEANING only + cleaning_events = db_commander.get_events(event_types=[EventType.CLEANING]) + assert len(cleaning_events) == 2 + assert all(e.event_type == EventType.CLEANING for e in cleaning_events) + + # Filter for multiple types + filtered_events = db_commander.get_events(event_types=[EventType.CLEANING, EventType.SHUTDOWN]) + assert len(filtered_events) == 3 + + def test_get_events_filter_by_date(self, db_commander: DatabaseCommander): + """Test filtering events by date range.""" + db_commander.save_event(EventType.CLEANING) + + # Get events with future start_date should return empty + future_date = datetime.datetime.now() + datetime.timedelta(days=1) + events = db_commander.get_events(start_date=future_date) + assert len(events) == 0 + + # Get events with past start_date should return all + past_date = datetime.datetime.now() - datetime.timedelta(days=1) + events = db_commander.get_events(start_date=past_date) + assert len(events) == 1 + + def test_all_event_types_can_be_saved(self, db_commander: DatabaseCommander): + """Test that all EventType values can be saved and retrieved.""" + for event_type in EventType: + db_commander.save_event(event_type) + + events = db_commander.get_events() + assert len(events) == len(EventType) + saved_types = {e.event_type for e in events} + assert saved_types == set(EventType) + + def test_event_timestamp_auto_generated(self, db_commander: DatabaseCommander): + """Test that timestamp is automatically generated.""" + before = datetime.datetime.now() + db_commander.save_event(EventType.CLEANING) + after = datetime.datetime.now() + + events = db_commander.get_events() + assert len(events) == 1 + event_timestamp = datetime.datetime.fromisoformat(events[0].timestamp) + assert before.replace(microsecond=0) <= event_timestamp <= after.replace(microsecond=0) + + def test_get_events_handles_legacy_renamed_event_type(self, db_commander: DatabaseCommander): + """Test that legacy renamed event values can still be loaded from DB.""" + session = Session(db_commander.engine) + try: + session.add(DbEvent(event_type="COCKTAIL_CANCELLATION", additional_info="legacy")) + session.commit() + finally: + session.close() + + events = db_commander.get_events() + assert len(events) == 1 + assert events[0].event_type == EventType.COCKTAIL_CANCELED + assert events[0].additional_info == "legacy" + + def test_get_events_handles_removed_or_unknown_event_type(self, db_commander: DatabaseCommander): + """Test that removed/unknown historic event values do not crash loading.""" + session = Session(db_commander.engine) + try: + session.add(DbEvent(event_type="SOME_REMOVED_EVENT", additional_info="historic")) + session.commit() + finally: + session.close() + + events = db_commander.get_events() + assert len(events) == 1 + assert events[0].event_type == EventType.UNKNOWN + assert events[0].additional_info == "historic" diff --git a/tests/db/test_exports.py b/tests/db/test_exports.py new file mode 100644 index 00000000..c29dc098 --- /dev/null +++ b/tests/db/test_exports.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import datetime + +from sqlalchemy.orm import Session + +from src.database_commander import VIRGIN_NAME_TEMPLATE, DatabaseCommander +from src.db_models import DbIngredient, DbRecipe + + +class TestExports: + def test_export_recipe_data(self, db_commander: DatabaseCommander): + """Test that recipe counters are exported correctly.""" + number_cocktails = 3 + for _ in range(number_cocktails): + db_commander.increment_recipe_counter("Cuba Libre", virgin=False) + + db_commander.export_recipe_data() + export_data = db_commander.get_export_data() + today = datetime.date.today().strftime("%Y-%m-%d") + assert today in export_data + + # Check if the recipe counter was exported correctly + assert "Cuba Libre" in export_data[today].recipes + assert export_data[today].recipes["Cuba Libre"] == number_cocktails + + # Check that the counter was reset in the database (need to check directly in DB) + session = Session(db_commander.engine) + recipe = session.query(DbRecipe).filter_by(name="Cuba Libre").first() + session.close() + assert recipe is not None + assert recipe.counter == 0 + + def test_export_ingredient_data(self, db_commander: DatabaseCommander): + """Test that ingredient consumption and cost are exported correctly.""" + consumption = 100 + db_commander.increment_ingredient_consumption("White Rum", consumption) + db_commander.export_ingredient_data() + export_data = db_commander.get_export_data() + today = datetime.date.today().strftime("%Y-%m-%d") + assert today in export_data + + # Check if the ingredient consumption was exported correctly + assert "White Rum" in export_data[today].ingredients + assert export_data[today].ingredients["White Rum"] == consumption + today_cost = export_data[today].cost + assert today_cost is not None + assert "White Rum" in today_cost + + # Get the ingredient to calculate the expected cost + ingredient = db_commander.get_ingredient("White Rum") + assert ingredient is not None + expected_cost = int(round(ingredient.cost / ingredient.bottle_volume * consumption, 0)) + assert today_cost["White Rum"] == expected_cost + + # Check that the consumption was reset (directly in DB) + session = Session(db_commander.engine) + db_ingredient = session.query(DbIngredient).filter_by(name="White Rum").first() + assert db_ingredient is not None + session.close() + assert db_ingredient.consumption == 0 + assert db_ingredient.cost_consumption == 0 + + def test_export_zero_cost_ingredient(self, db_commander: DatabaseCommander): + """Test that ingredients with zero cost are not included in cost exports.""" + # Set the cost of an ingredient to 0 + ingredient = db_commander.get_ingredient("Cola") + assert ingredient is not None + db_commander.set_ingredient_data( + ingredient.name, + ingredient.alcohol, + ingredient.bottle_volume, + ingredient.fill_level, + ingredient.hand, + ingredient.pump_speed, + ingredient.id, + 0, + ingredient.unit, + ) + + consumption = 200 + db_commander.increment_ingredient_consumption("Cola", consumption) + db_commander.export_ingredient_data() + export_data = db_commander.get_export_data() + today = datetime.date.today().strftime("%Y-%m-%d") + + assert "Cola" in export_data[today].ingredients + assert export_data[today].ingredients["Cola"] == consumption + today_cost = export_data[today].cost + assert today_cost is not None + assert "Cola" not in today_cost + + def test_export_only_if_consumption_greater_than_zero(self, db_commander: DatabaseCommander): + """Test that only ingredients with consumption > 0 are exported.""" + ingredient_name = "Fanta" + db_commander.export_ingredient_data() + export_data = db_commander.get_export_data() + today = datetime.date.today().strftime("%Y-%m-%d") + + # Check that the ingredient without consumption is not in the export + if today in export_data: + assert ingredient_name not in export_data[today].ingredients + + def test_export_dates(self, db_commander: DatabaseCommander): + """Test that export dates are returned correctly.""" + db_commander.increment_recipe_counter("Cuba Libre", virgin=False) + db_commander.export_recipe_data() + export_dates = db_commander.get_export_dates() + + # Today's date should be in the list + today = datetime.date.today().strftime("%Y-%m-%d") + assert today in export_dates + + def test_multiple_exports_same_day(self, db_commander: DatabaseCommander): + """Test that multiple exports on the same day are combined correctly.""" + today = datetime.date.today() + + # First export + db_commander.increment_recipe_counter("Cuba Libre", virgin=False) + db_commander.increment_ingredient_consumption("White Rum", 50) + db_commander.export_recipe_data() + db_commander.export_ingredient_data() + + # Second export on the same day + db_commander.increment_recipe_counter("Cuba Libre", virgin=False) + db_commander.increment_recipe_counter("Cuba Libre", virgin=True) + db_commander.increment_ingredient_consumption("White Rum", 100) + db_commander.export_recipe_data() + db_commander.export_ingredient_data() + + # Verify exports were combined + export_data = db_commander.get_export_data() + today_str = today.strftime("%Y-%m-%d") + + # Check that the recipe counter was combined (1 + 2 = 3) + assert today_str in export_data + assert "Cuba Libre" in export_data[today_str].recipes + assert export_data[today_str].recipes["Cuba Libre"] == 2 + assert export_data[today_str].recipes.get(VIRGIN_NAME_TEMPLATE.format("Cuba Libre"), 0) == 1 + + # Check that the ingredient consumption was combined (50 + 100 = 150) + assert "White Rum" in export_data[today_str].ingredients + assert export_data[today_str].ingredients["White Rum"] == 150 + + # Check that the cost consumption was also combined correctly + ingredient = db_commander.get_ingredient("White Rum") + assert ingredient is not None + expected_cost = int(round(ingredient.cost / ingredient.bottle_volume * 150, 0)) + today_cost = export_data[today_str].cost + assert today_cost is not None + assert "White Rum" in today_cost + assert today_cost["White Rum"] == expected_cost + + def test_same_cocktail_counter_only_if_greater_zero(self, db_commander: DatabaseCommander): + """Test that virgin and normal cocktail are only exported if consumption > 0.""" + db_commander.increment_recipe_counter("Cuba Libre", virgin=False) + db_commander.increment_recipe_counter("Tequila Sunrise", virgin=True) + db_commander.export_recipe_data() + export_data = db_commander.get_export_data() + today = datetime.date.today().strftime("%Y-%m-%d") + + assert today in export_data + assert "Cuba Libre" in export_data[today].recipes + assert VIRGIN_NAME_TEMPLATE.format("Cuba Libre") not in export_data[today].recipes + assert "Tequila Sunrise" not in export_data[today].recipes + assert VIRGIN_NAME_TEMPLATE.format("Tequila Sunrise") in export_data[today].recipes diff --git a/tests/db/test_failed_team_data.py b/tests/db/test_failed_team_data.py new file mode 100644 index 00000000..aac8fcac --- /dev/null +++ b/tests/db/test_failed_team_data.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pytest + +from src.database_commander import DatabaseCommander, ElementNotFoundError + + +class TestFailedTeamData: + def test_save_failed_teamdata(self, db_commander: DatabaseCommander): + """Test the save_failed_teamdata method.""" + db_commander.save_failed_teamdata("test_payload") + data = db_commander.get_failed_teamdata() + assert data is not None + assert data[1] == "test_payload" + + def test_get_failed_teamdata(self, db_commander: DatabaseCommander): + """Test the get_failed_teamdata method.""" + db_commander.save_failed_teamdata("test_payload") + data = db_commander.get_failed_teamdata() + assert data is not None + assert data[1] == "test_payload" + + def test_get_nonexistent_failed_teamdata(self, db_commander: DatabaseCommander): + """Test getting failed teamdata that does not exist.""" + data = db_commander.get_failed_teamdata() + assert data is None + + def test_delete_failed_teamdata(self, db_commander: DatabaseCommander): + """Test the delete_failed_teamdata method.""" + db_commander.save_failed_teamdata("test_payload") + data = db_commander.get_failed_teamdata() + assert data is not None + db_commander.delete_failed_teamdata(data[0]) + data = db_commander.get_failed_teamdata() + assert data is None + + def test_delete_nonexistent_failed_teamdata(self, db_commander: DatabaseCommander): + """Test deleting failed teamdata that does not exist.""" + with pytest.raises(ElementNotFoundError): + db_commander.delete_failed_teamdata(999) diff --git a/tests/db/test_ingredient.py b/tests/db/test_ingredient.py new file mode 100644 index 00000000..0ca3083d --- /dev/null +++ b/tests/db/test_ingredient.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import pytest +from sqlalchemy.orm import Session + +from src.database_commander import ( + DatabaseCommander, + DatabaseTransactionError, + ElementAlreadyExistsError, + ElementNotFoundError, +) +from src.db_models import DbIngredient + + +class TestIngredient: + def test_get_ingredient(self, db_commander: DatabaseCommander): + """Test the get_ingredient method.""" + ingredient = db_commander.get_ingredient(1) + assert ingredient is not None + assert ingredient.name == "White Rum" + assert ingredient.alcohol == 40 + assert ingredient.bottle_volume == 1000 + assert ingredient.fill_level == 1000 + assert ingredient.hand is False + assert ingredient.pump_speed == 100 + + def test_get_all_ingredients(self, db_commander: DatabaseCommander): + """Test the get_all_ingredients method.""" + ingredients = db_commander.get_all_ingredients() + assert len(ingredients) == 7 + + cola = next((i for i in ingredients if i.name == "Cola"), None) + assert cola is not None + assert cola.alcohol == 0 + assert cola.bottle_volume == 1000 + assert cola.fill_level == 0 + assert cola.hand is False + assert cola.pump_speed == 100 + + def test_get_all_machine_ingredients(self, db_commander: DatabaseCommander): + """Test the get_all_machine_ingredients method.""" + ingredients = db_commander.get_all_ingredients(get_hand=False) + assert len(ingredients) == 6 + + cola = next((i for i in ingredients if i.name == "Cola"), None) + assert cola is not None + + def test_get_all_hand_ingredients(self, db_commander: DatabaseCommander): + """Test the get_all_hand_ingredients method.""" + ingredients = db_commander.get_all_ingredients(get_machine=False) + assert len(ingredients) == 1 + + blue_curacao = next((i for i in ingredients if i.name == "Blue Curacao"), None) + assert blue_curacao is not None + + def test_get_all_ingredients_return_empty_if_both_false(self, db_commander: DatabaseCommander): + """Test the get_all_ingredients method.""" + ingredients = db_commander.get_all_ingredients(get_hand=False, get_machine=False) + assert len(ingredients) == 0 + + def test_get_ingredient_at_bottle(self, db_commander: DatabaseCommander): + """Test the get_ingredient_at_bottle method.""" + ingredient = db_commander.get_ingredient_at_bottle(1) + assert ingredient is not None + assert ingredient.name == "White Rum" + + def test_get_ingredient_names_at_bottles(self, db_commander: DatabaseCommander): + """Test the get_ingredient_names_at_bottles method.""" + ingredient_names = db_commander.get_ingredient_names_at_bottles() + assert len(ingredient_names) == 24 + assert ingredient_names[1] == "Cola" + + def test_set_ingredient_data(self, db_commander: DatabaseCommander): + """Test the set_ingredient_data method.""" + db_commander.set_ingredient_data("New Unique Name", 40, 1000, 800, False, 10, 1, 100, "cl") + ingredient = db_commander.get_ingredient(1) + assert ingredient is not None + assert ingredient.name == "New Unique Name" + assert ingredient.alcohol == 40 + assert ingredient.bottle_volume == 1000 + assert ingredient.fill_level == 800 + assert ingredient.hand is False + assert ingredient.pump_speed == 10 + assert ingredient.id == 1 + assert ingredient.cost == 100 + assert ingredient.unit == "cl" + + def test_set_ingredient_level_to_value(self, db_commander: DatabaseCommander): + """Test the set_ingredient_level_to_value method.""" + db_commander.set_ingredient_level_to_value(1, 500) + ingredient = db_commander.get_ingredient(1) + assert ingredient is not None + assert ingredient.fill_level == 500 + + def test_raise_not_found_on_set_ingredient_level_to_value(self, db_commander: DatabaseCommander): + """Test the set_ingredient_level_to_value method.""" + with pytest.raises(ElementNotFoundError): + db_commander.set_ingredient_level_to_value(999, 500) + + def test_insert_new_ingredient(self, db_commander: DatabaseCommander): + """Test the insert_new_ingredient method.""" + db_commander.insert_new_ingredient("New Ingredient", 0, 1000, False, 10, 100, "ml") + ingredient = db_commander.get_ingredient("New Ingredient") + assert ingredient is not None + assert ingredient.name == "New Ingredient" + + def test_increment_ingredient_consumption(self, db_commander: DatabaseCommander): + """Test the increment_ingredient_consumption method.""" + consumption = 100 + db_commander.increment_ingredient_consumption("White Rum", consumption) + session = Session(db_commander.engine) + ingredient = session.query(DbIngredient).filter_by(name="White Rum").first() + session.close() + assert ingredient is not None + assert ingredient.consumption == consumption + assert ingredient.consumption_lifetime == consumption + cost = int(round(ingredient.cost / ingredient.volume * consumption, 0)) + assert ingredient.cost_consumption == cost + assert ingredient.cost_consumption_lifetime == cost + # check multiple times works as well + db_commander.increment_ingredient_consumption("White Rum", consumption) + ingredient = session.query(DbIngredient).filter_by(name="White Rum").first() + session.close() + assert ingredient is not None + assert ingredient.consumption == 2 * consumption + assert ingredient.consumption_lifetime == 2 * consumption + assert ingredient.cost_consumption == 2 * cost + assert ingredient.cost_consumption_lifetime == 2 * cost + + def test_set_multiple_ingredient_consumption(self, db_commander: DatabaseCommander): + """Test the set_multiple_ingredient_consumption method.""" + ingredients = ["White Rum", "Cola"] + amount_1 = 100 + amount_2 = 50 + amounts = [amount_1, amount_2] + db_commander.set_multiple_ingredient_consumption(ingredients, amounts) + session = Session(db_commander.engine) + ingredient_1 = session.query(DbIngredient).filter_by(name=ingredients[0]).first() + ingredient_2 = session.query(DbIngredient).filter_by(name=ingredients[1]).first() + session.close() + assert ingredient_1 is not None + assert ingredient_1.consumption == amount_1 + assert ingredient_1.consumption_lifetime == amount_1 + cost_1 = int(round(ingredient_1.cost / ingredient_1.volume * amount_1, 0)) + assert ingredient_1.cost_consumption == cost_1 + assert ingredient_1.cost_consumption_lifetime == cost_1 + assert ingredient_2 is not None + assert ingredient_2.consumption == amount_2 + assert ingredient_2.consumption_lifetime == amount_2 + cost_2 = int(round(ingredient_2.cost / ingredient_2.volume * amount_2, 0)) + assert ingredient_2.cost_consumption == cost_2 + assert ingredient_2.cost_consumption_lifetime == cost_2 + + @pytest.mark.parametrize("ingredient_id", (1, 4, 6)) + def test_delete_ingredient_fails(self, db_commander: DatabaseCommander, ingredient_id: int): + """Test the delete_ingredient method.""" + with pytest.raises(DatabaseTransactionError): + db_commander.delete_ingredient(ingredient_id) + ingredient = db_commander.get_ingredient(ingredient_id) + assert ingredient is not None + + def test_delete_ingredient(self, db_commander: DatabaseCommander): + """Test the delete_ingredient method.""" + db_commander.insert_new_ingredient("New Ingredient", 0, 1000, False, 10, 100, "ml") + ingredient = db_commander.get_ingredient("New Ingredient") + assert ingredient is not None + db_commander.delete_ingredient(ingredient.id) + ingredient = db_commander.get_ingredient(ingredient.id) + assert ingredient is None + + @pytest.mark.parametrize("ing", ([1, 2], ["White Rum", "Cola"])) + def test_insert_multiple_existing_handadd_ingredients( + self, db_commander: DatabaseCommander, ing: list[str] | list[int] + ): + """Test the insert_multiple_existing_handadd_ingredients method.""" + db_commander.insert_multiple_existing_handadd_ingredients(ing) + names = db_commander.get_available_ingredient_names() + assert "White Rum" in names + assert "Cola" in names + + def test_delete_existing_handadd_ingredient(self, db_commander: DatabaseCommander): + """Test the delete_existing_handadd_ingredient method.""" + db_commander.delete_existing_handadd_ingredient() + names = db_commander.get_available_ingredient_names() + assert len(names) == 0 + + def test_get_nonexistent_ingredient(self, db_commander: DatabaseCommander): + """Test getting an ingredient that does not exist.""" + ingredient = db_commander.get_ingredient(999) + assert ingredient is None + + def test_insert_duplicate_ingredient(self, db_commander: DatabaseCommander): + """Test inserting an ingredient with a duplicate name.""" + with pytest.raises(ElementAlreadyExistsError): + db_commander.insert_new_ingredient("White Rum", 40, 1000, False, 100, 100, "ml") + + def test_delete_nonexistent_ingredient(self, db_commander: DatabaseCommander): + """Test deleting an ingredient that does not exist.""" + with pytest.raises(ElementNotFoundError): + db_commander.delete_ingredient(999) + + def test_set_nonexistent_ingredient(self, db_commander: DatabaseCommander): + """Test setting an ingredient that does not exist.""" + with pytest.raises(ElementNotFoundError): + db_commander.set_ingredient_data("Nonexistent", 0, 0, 0, False, 0, 999, 0, "ml") + + def test_increment_nonexistent_ingredient_consumption(self, db_commander: DatabaseCommander): + """Test incrementing the consumption of an ingredient that does not exist.""" + with pytest.raises(ElementNotFoundError): + db_commander.increment_ingredient_consumption("Nonexistent", 100) + + def test_delete_ingredient_still_in_use(self, db_commander: DatabaseCommander): + """Test deleting an ingredient that is still in use.""" + with pytest.raises(DatabaseTransactionError): + db_commander.delete_ingredient(1) diff --git a/tests/db/test_news.py b/tests/db/test_news.py new file mode 100644 index 00000000..8c8c1b4b --- /dev/null +++ b/tests/db/test_news.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pytest + +from src.database_commander import DatabaseCommander, ElementNotFoundError + + +class TestNews: + def test_get_unacknowledged_news_keys_empty_list(self, db_commander: DatabaseCommander): + """Test that empty list returns empty list.""" + result = db_commander.get_unacknowledged_news_keys([]) + assert result == [] + + def test_get_unacknowledged_news_keys_new_keys(self, db_commander: DatabaseCommander): + """Test that new news keys are returned as unacknowledged.""" + news_keys = ["news_v2_available", "news_feature_update"] + result = db_commander.get_unacknowledged_news_keys(news_keys) + assert len(result) == 2 + assert "news_v2_available" in result + assert "news_feature_update" in result + + def test_acknowledge_news(self, db_commander: DatabaseCommander): + """Test that news can be acknowledged and won't be returned again.""" + news_keys = ["news_v2_available", "news_feature_update"] + + # First call - should return all keys + result = db_commander.get_unacknowledged_news_keys(news_keys) + assert len(result) == 2 + + # Acknowledge one news + db_commander.acknowledge_news("news_v2_available") + + # Second call - should only return unacknowledged + result = db_commander.get_unacknowledged_news_keys(news_keys) + assert len(result) == 1 + assert "news_feature_update" in result + assert "news_v2_available" not in result + + def test_acknowledge_nonexistent_news(self, db_commander: DatabaseCommander): + """Test that acknowledging non-existent news doesn't raise an error.""" + with pytest.raises(ElementNotFoundError): + db_commander.acknowledge_news("nonexistent_news_key") + + def test_news_persistence_across_calls(self, db_commander: DatabaseCommander): + """Test that news state persists across multiple calls.""" + news_keys = ["news_v2_available"] + + # Get unacknowledged news + result1 = db_commander.get_unacknowledged_news_keys(news_keys) + assert len(result1) == 1 + + # Call again without acknowledging - should still return same + result2 = db_commander.get_unacknowledged_news_keys(news_keys) + assert len(result2) == 1 + assert result1 == result2 + + # Acknowledge + db_commander.acknowledge_news("news_v2_available") + + # Call again - should return empty + result3 = db_commander.get_unacknowledged_news_keys(news_keys) + assert len(result3) == 0 diff --git a/tests/db/test_resource_usage.py b/tests/db/test_resource_usage.py new file mode 100644 index 00000000..188b5c66 --- /dev/null +++ b/tests/db/test_resource_usage.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import datetime + +import pytest + +from src.database_commander import DatabaseCommander + + +class TestResourceUsage: + def test_save_and_get_resource_stats(self, db_commander: DatabaseCommander): + """Test saving resource usage and retrieving resource stats.""" + # Prepare test data + cpu_values = [10.5, 20.0, 15.0] + ram_values = [10.0, 20.0, 15.0] + session_number = 33 + now = datetime.datetime.now() + for cpu, ram in zip(cpu_values, ram_values): + db_commander.save_resource_usage(cpu, ram, session_number, timestamp=now) + + # Retrieve resource stats for session 1 + stats = db_commander.get_resource_stats(session_number) + assert stats is not None + assert stats.samples == len(cpu_values) + assert stats.min_cpu == min(cpu_values) + assert stats.max_cpu == max(cpu_values) + assert pytest.approx(stats.mean_cpu) == round(sum(cpu_values) / len(cpu_values), 1) + assert stats.min_ram == min(ram_values) + assert stats.max_ram == max(ram_values) + assert pytest.approx(stats.mean_ram) == round(sum(ram_values) / len(ram_values), 1) + # When below max_raw_points, all values should be returned + assert len(stats.raw_cpu) == len(cpu_values) + assert len(stats.raw_ram) == len(ram_values) + assert set(stats.raw_cpu) == set(cpu_values) + assert set(stats.raw_ram) == set(ram_values) + + def test_save_and_get_resource_stats_returns_empty_data(self, db_commander: DatabaseCommander): + """Test saving resource usage and retrieving empty resource stats.""" + stats = db_commander.get_resource_stats(1) + assert stats is not None + assert stats.samples == 0 + assert stats.min_cpu == pytest.approx(0.0) + assert stats.max_cpu == pytest.approx(0.0) + assert stats.mean_cpu == pytest.approx(0.0) + assert stats.min_ram == pytest.approx(0.0) + assert stats.max_ram == pytest.approx(0.0) + assert stats.mean_ram == pytest.approx(0.0) + assert len(stats.raw_cpu) == 0 + assert len(stats.raw_ram) == 0 + + def test_get_resource_session_numbers(self, db_commander: DatabaseCommander): + """Test getting resource session numbers.""" + cpu_values = [10.5, 20.0, 15.0] + ram_values = [10.0, 20.0, 15.0] + session_numbers = [2, 1, 3] + now = datetime.datetime.now() + for cpu, ram, session_number in zip(cpu_values, ram_values, session_numbers): + db_commander.save_resource_usage(cpu, ram, session_number, timestamp=now) + + inserted_numbers = db_commander.get_resource_session_numbers() + assert len(inserted_numbers) == 3 + assert [x.session_id for x in inserted_numbers] == sorted(session_numbers) + # check that the date string second element is "%Y-%m-%d %H:%M" + assert all(now.strftime("%Y-%m-%d %H:%M") == x.start_time for x in inserted_numbers) + + def test_get_highest_session_number(self, db_commander: DatabaseCommander): + """Test getting the highest session number.""" + cpu_values = [10.5, 20.0, 15.0] + ram_values = [10.0, 20.0, 15.0] + session_numbers = [1, 2, 3] + now = datetime.datetime.now() + for cpu, ram, session_number in zip(cpu_values, ram_values, session_numbers): + db_commander.save_resource_usage(cpu, ram, session_number, timestamp=now) + + highest_session_number = db_commander.get_highest_session_number() + assert highest_session_number == max(session_numbers) + + def test_get_resource_stats_samples_large_datasets(self, db_commander: DatabaseCommander): + """Test that large datasets are sampled to prevent OOM.""" + session_number = 99 + now = datetime.datetime.now() + # Create more data points than max_raw_points + total_points = 100 + max_raw_points = 20 + cpu_values = [float(i % 100) for i in range(total_points)] + ram_values = [float((i + 10) % 100) for i in range(total_points)] + + for cpu, ram in zip(cpu_values, ram_values): + db_commander.save_resource_usage(cpu, ram, session_number, timestamp=now) + + stats = db_commander.get_resource_stats(session_number, max_raw_points=max_raw_points) + + # Samples should reflect total count + assert stats.samples == total_points + # Raw data should be limited to max_raw_points + assert len(stats.raw_cpu) <= max_raw_points + assert len(stats.raw_ram) <= max_raw_points + # Aggregated stats should still be accurate (computed from all data in SQL) + assert stats.min_cpu == min(cpu_values) + assert stats.max_cpu == max(cpu_values) + assert stats.min_ram == min(ram_values) + assert stats.max_ram == max(ram_values) + assert pytest.approx(stats.mean_cpu, rel=0.1) == sum(cpu_values) / len(cpu_values) + assert pytest.approx(stats.mean_ram, rel=0.1) == sum(ram_values) / len(ram_values) + + def test_cleanup_resource_stats_does_not_remove_if_50_or_less(self, db_commander: DatabaseCommander): + """Test that cleanup does not remove sessions if we have 50 or fewer.""" + now = datetime.datetime.now() + # Create exactly 50 sessions + for session_number in range(1, 51): + db_commander.save_resource_usage(10.0, 20.0, session_number, timestamp=now) + + deleted_count = db_commander.cleanup_resource_stats(keep_sessions=50) + assert deleted_count == 0 + + # All sessions should still exist + session_numbers = db_commander.get_resource_session_numbers() + assert len(session_numbers) == 50 + + def test_cleanup_resource_stats_removes_older_sessions(self, db_commander: DatabaseCommander): + """Test that cleanup removes sessions older than the latest 50.""" + now = datetime.datetime.now() + # Create 55 sessions (should remove the 5 oldest) + for session_number in range(1, 56): + db_commander.save_resource_usage(10.0, 20.0, session_number, timestamp=now) + db_commander.save_resource_usage(15.0, 25.0, session_number, timestamp=now) # 2 records per session + + deleted_count = db_commander.cleanup_resource_stats(keep_sessions=50) + # 5 sessions with 2 records each = 10 deleted records + assert deleted_count == 10 + + # Only 50 sessions should remain + session_numbers = db_commander.get_resource_session_numbers() + assert len(session_numbers) == 50 + + # The oldest sessions (1-5) should be removed, keeping 6-55 + session_ids = [s.session_id for s in session_numbers] + assert min(session_ids) == 6 + assert max(session_ids) == 55 + + def test_cleanup_resource_stats_with_no_data(self, db_commander: DatabaseCommander): + """Test that cleanup works correctly when there's no data.""" + deleted_count = db_commander.cleanup_resource_stats(keep_sessions=50) + assert deleted_count == 0 + + def test_cleanup_resource_stats_with_custom_keep_sessions(self, db_commander: DatabaseCommander): + """Test cleanup with a custom number of sessions to keep.""" + now = datetime.datetime.now() + # Create 10 sessions + for session_number in range(1, 11): + db_commander.save_resource_usage(10.0, 20.0, session_number, timestamp=now) + + # Keep only 5 sessions + deleted_count = db_commander.cleanup_resource_stats(keep_sessions=5) + assert deleted_count == 5 # 5 sessions with 1 record each + + # Only 5 sessions should remain + session_numbers = db_commander.get_resource_session_numbers() + assert len(session_numbers) == 5 + + # Sessions 6-10 should remain (the latest 5) + session_ids = [s.session_id for s in session_numbers] + assert session_ids == [6, 7, 8, 9, 10] diff --git a/tests/db/test_waiter.py b/tests/db/test_waiter.py new file mode 100644 index 00000000..6add67ee --- /dev/null +++ b/tests/db/test_waiter.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import pytest + +from src.database_commander import DatabaseCommander, ElementAlreadyExistsError, ElementNotFoundError + + +class TestWaiter: + def test_create_waiter(self, db_commander: DatabaseCommander): + waiter = db_commander.create_waiter("nfc_001", "Alice") + assert waiter.nfc_id == "nfc_001" + assert waiter.name == "Alice" + assert waiter.privilege_maker is False + assert waiter.privilege_ingredients is False + assert waiter.privilege_recipes is False + assert waiter.privilege_bottles is False + assert waiter.privilege_options is False + + def test_create_waiter_duplicate_name_raises(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + with pytest.raises(ElementAlreadyExistsError, match="already exists"): + db_commander.create_waiter("nfc_002", "Alice") + + def test_create_waiter_duplicate_nfc_id_raises(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + with pytest.raises(ElementAlreadyExistsError, match="already exists"): + db_commander.create_waiter("nfc_001", "Bob") + + def test_get_all_waiters_empty(self, db_commander: DatabaseCommander): + waiters = db_commander.get_all_waiters() + assert waiters == [] + + def test_get_all_waiters_returns_sorted_by_name(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_003", "Charlie") + db_commander.create_waiter("nfc_001", "Alice") + db_commander.create_waiter("nfc_002", "Bob") + waiters = db_commander.get_all_waiters() + assert len(waiters) == 3 + assert [w.name for w in waiters] == ["Alice", "Bob", "Charlie"] + + def test_get_waiter_by_nfc_id(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + waiter = db_commander.get_waiter_by_nfc_id("nfc_001") + assert waiter is not None + assert waiter.name == "Alice" + + def test_get_waiter_by_nfc_id_not_found(self, db_commander: DatabaseCommander): + waiter = db_commander.get_waiter_by_nfc_id("nonexistent") + assert waiter is None + + def test_update_waiter(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + updated = db_commander.update_waiter("nfc_001", "Alice Updated") + assert updated.name == "Alice Updated" + assert updated.nfc_id == "nfc_001" + # Verify persisted + fetched = db_commander.get_waiter_by_nfc_id("nfc_001") + assert fetched is not None + assert fetched.name == "Alice Updated" + + def test_update_waiter_not_found_raises(self, db_commander: DatabaseCommander): + with pytest.raises(ElementNotFoundError, match="not found"): + db_commander.update_waiter("nonexistent", "Name") + + def test_update_waiter_duplicate_name_raises(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + db_commander.create_waiter("nfc_002", "Bob") + with pytest.raises(ElementAlreadyExistsError, match="already exists"): + db_commander.update_waiter("nfc_002", "Alice") + + def test_update_waiter_same_name_allowed(self, db_commander: DatabaseCommander): + """Updating a waiter to its own current name should succeed.""" + db_commander.create_waiter("nfc_001", "Alice") + updated = db_commander.update_waiter("nfc_001", "Alice") + assert updated.name == "Alice" + + def test_delete_waiter(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + db_commander.delete_waiter("nfc_001") + assert db_commander.get_waiter_by_nfc_id("nfc_001") is None + assert db_commander.get_all_waiters() == [] + + def test_delete_waiter_not_found_raises(self, db_commander: DatabaseCommander): + with pytest.raises(ElementNotFoundError, match="not found"): + db_commander.delete_waiter("nonexistent") + + def test_delete_waiter_nullifies_logs(self, db_commander: DatabaseCommander): + """Deleting a waiter should preserve logs but set their waiter_nfc_id to null.""" + db_commander.create_waiter("nfc_001", "Alice") + db_commander.log_waiter_cocktail("nfc_001", 1, 300, False) + db_commander.log_waiter_cocktail("nfc_001", 2, 250, True) + assert len(db_commander.get_waiter_logs()) == 2 + db_commander.delete_waiter("nfc_001") + logs = db_commander.get_waiter_logs() + assert len(logs) == 2 + for log in logs: + assert log.waiter_nfc_id is None + assert log.waiter is None + + def test_log_waiter_cocktail(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + db_commander.log_waiter_cocktail("nfc_001", 1, 300, False) + logs = db_commander.get_waiter_logs() + assert len(logs) == 1 + log = logs[0] + assert log.waiter_nfc_id == "nfc_001" + assert log.recipe_id == 1 + assert log.volume == 300 + assert log.is_virgin is False + assert log.timestamp is not None + + def test_log_waiter_cocktail_relationships_eagerly_loaded(self, db_commander: DatabaseCommander): + """Logs returned by get_waiter_logs must have waiter/recipe accessible outside the session.""" + db_commander.create_waiter("nfc_001", "Alice") + db_commander.log_waiter_cocktail("nfc_001", 1, 300, False) + logs = db_commander.get_waiter_logs() + assert len(logs) == 1 + # These would raise DetachedInstanceError if not eagerly loaded + first_log = logs[0] + assert first_log.waiter is not None + assert first_log.recipe is not None + assert first_log.waiter.name == "Alice" + assert first_log.recipe.name == "Cuba Libre" + + def test_log_waiter_cocktail_relationship_none_recipe(self, db_commander: DatabaseCommander): + """Log with no recipe should have recipe as None without error.""" + db_commander.create_waiter("nfc_001", "Alice") + db_commander.log_waiter_cocktail("nfc_001", None, 200, False) # type: ignore + logs = db_commander.get_waiter_logs() + assert len(logs) == 1 + assert logs[0].waiter.name == "Alice" # type: ignore + assert logs[0].recipe is None + + def test_log_waiter_cocktail_virgin(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + db_commander.log_waiter_cocktail("nfc_001", 1, 250, True) + logs = db_commander.get_waiter_logs() + assert len(logs) == 1 + assert logs[0].is_virgin is True + + def test_log_waiter_cocktail_with_none_recipe(self, db_commander: DatabaseCommander): + """Log entry with no recipe (e.g. recipe was deleted).""" + db_commander.create_waiter("nfc_001", "Alice") + db_commander.log_waiter_cocktail("nfc_001", None, 200, False) # type: ignore + logs = db_commander.get_waiter_logs() + assert len(logs) == 1 + assert logs[0].recipe_id is None + + def test_get_waiter_logs_empty(self, db_commander: DatabaseCommander): + logs = db_commander.get_waiter_logs() + assert logs == [] + + def test_get_waiter_logs_ordered_by_timestamp_desc(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + db_commander.log_waiter_cocktail("nfc_001", 1, 300, False) + db_commander.log_waiter_cocktail("nfc_001", 2, 250, True) + db_commander.log_waiter_cocktail("nfc_001", 1, 200, False) + logs = db_commander.get_waiter_logs() + assert len(logs) == 3 + # Most recent first + for i in range(len(logs) - 1): + assert logs[i].timestamp >= logs[i + 1].timestamp + + def test_get_waiter_logs_multiple_waiters(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + db_commander.create_waiter("nfc_002", "Bob") + db_commander.log_waiter_cocktail("nfc_001", 1, 300, False) + db_commander.log_waiter_cocktail("nfc_002", 2, 250, True) + db_commander.log_waiter_cocktail("nfc_001", 1, 200, False) + logs = db_commander.get_waiter_logs() + assert len(logs) == 3 + alice_logs = [log for log in logs if log.waiter_nfc_id == "nfc_001"] + bob_logs = [log for log in logs if log.waiter_nfc_id == "nfc_002"] + assert len(alice_logs) == 2 + assert len(bob_logs) == 1 + + def test_multiple_create_and_delete(self, db_commander: DatabaseCommander): + """Test creating multiple waiters and deleting one doesn't affect others.""" + db_commander.create_waiter("nfc_001", "Alice") + db_commander.create_waiter("nfc_002", "Bob") + db_commander.create_waiter("nfc_003", "Charlie") + db_commander.log_waiter_cocktail("nfc_001", 1, 300, False) + db_commander.log_waiter_cocktail("nfc_002", 2, 250, True) + db_commander.delete_waiter("nfc_001") + waiters = db_commander.get_all_waiters() + assert len(waiters) == 2 + assert {w.name for w in waiters} == {"Bob", "Charlie"} + # All logs preserved, Alice's log now has null waiter_nfc_id + logs = db_commander.get_waiter_logs() + assert len(logs) == 2 + alice_logs = [log for log in logs if log.waiter_nfc_id is None] + bob_logs = [log for log in logs if log.waiter_nfc_id == "nfc_002"] + assert len(alice_logs) == 1 + assert len(bob_logs) == 1 + + def test_default_permissions_all_false(self, db_commander: DatabaseCommander): + waiter = db_commander.create_waiter("nfc_001", "Alice") + assert waiter.privilege_maker is False + assert waiter.privilege_ingredients is False + assert waiter.privilege_recipes is False + assert waiter.privilege_bottles is False + assert waiter.privilege_options is False + + def test_update_single_permission(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + updated = db_commander.update_waiter("nfc_001", permissions={"maker": True}) + assert updated.privilege_maker is True + assert updated.privilege_ingredients is False + fetched = db_commander.get_waiter_by_nfc_id("nfc_001") + assert fetched is not None + assert fetched.privilege_maker is True + + def test_update_multiple_permissions(self, db_commander: DatabaseCommander): + db_commander.create_waiter("nfc_001", "Alice") + updated = db_commander.update_waiter( + "nfc_001", permissions={"maker": True, "ingredients": True, "bottles": True} + ) + assert updated.privilege_maker is True + assert updated.privilege_ingredients is True + assert updated.privilege_recipes is False + assert updated.privilege_bottles is True + + def test_update_permissions_without_name(self, db_commander: DatabaseCommander): + """Updating only permissions should not change the name.""" + db_commander.create_waiter("nfc_001", "Alice") + updated = db_commander.update_waiter("nfc_001", permissions={"recipes": True}) + assert updated.name == "Alice" + assert updated.privilege_recipes is True + + def test_update_name_without_permissions(self, db_commander: DatabaseCommander): + """Updating only name should not change permissions.""" + db_commander.create_waiter("nfc_001", "Alice") + db_commander.update_waiter("nfc_001", permissions={"maker": True}) + updated = db_commander.update_waiter("nfc_001", name="Alice Updated") + assert updated.name == "Alice Updated" + assert updated.privilege_maker is True diff --git a/tests/test_database_commander.py b/tests/test_database_commander.py deleted file mode 100644 index 936b605f..00000000 --- a/tests/test_database_commander.py +++ /dev/null @@ -1,1035 +0,0 @@ -from __future__ import annotations - -import datetime -from unittest.mock import patch - -import pytest -from sqlalchemy.orm import Session - -from src.database_commander import ( - VIRGIN_NAME_TEMPLATE, - DatabaseCommander, - DatabaseTransactionError, - ElementAlreadyExistsError, - ElementNotFoundError, -) -from src.db_models import DbEvent, DbIngredient, DbRecipe -from src.models import EventType - - -class TestCocktail: - def test_get_cocktail(self, db_commander: DatabaseCommander): - """Test the get_cocktail method.""" - cocktail = db_commander.get_cocktail(1) - assert cocktail is not None - assert cocktail.name == "Cuba Libre" - assert cocktail.alcohol == 11 - assert cocktail.amount == 290 - assert cocktail.enabled is True - assert cocktail.virgin_available is False - assert len(cocktail.ingredients) == 2 - - ingredient = cocktail.ingredients[0] - assert ingredient.name == "Cola" - assert ingredient.alcohol == 0 - assert ingredient.amount == 210 - - def test_get_all_cocktails(self, db_commander: DatabaseCommander): - """Test the get_all_cocktails method.""" - cocktails = db_commander.get_all_cocktails() - assert len(cocktails) == 5 - - cuba_libre = next((c for c in cocktails if c.name == "Cuba Libre"), None) - assert cuba_libre is not None - assert cuba_libre.alcohol == 11 - assert cuba_libre.amount == 290 - assert cuba_libre.enabled is True - assert cuba_libre.virgin_available is False - assert len(cuba_libre.ingredients) == 2 - - def test_get_possible_cocktails(self, db_commander: DatabaseCommander): - """Test the get_possible_cocktails method.""" - possible_cocktails = db_commander.get_possible_cocktails(max_hand_ingredients=1) - # Now we should have 3 possible cocktails: Cuba Libre, With Handadd, and Virgin Only Possible - assert len(possible_cocktails) == 3 - # Check that Cuba Libre is in the list - cuba_libre = next((c for c in possible_cocktails if c.name == "Cuba Libre"), None) - assert cuba_libre is not None - assert cuba_libre.only_virgin is False - - def test_get_possible_cocktail_virgin_only(self, db_commander: DatabaseCommander): - """Test that a cocktail that can only be made in virgin form is properly flagged.""" - possible_cocktails = db_commander.get_possible_cocktails(max_hand_ingredients=1) - - virgin_only = next((c for c in possible_cocktails if c.name == "Virgin Only Possible"), None) - assert virgin_only is not None - assert virgin_only.only_virgin is True - - # Verify that this cocktail has an alcoholic ingredient that's not available - alcoholic_ingredients = [ing for ing in virgin_only.ingredients if ing.alcohol > 0] - assert len(alcoholic_ingredients) > 0 - - # Check that the virgin ingredients are available either via machine or hand - virgin_ingredients = [ing for ing in virgin_only.ingredients if ing.alcohol == 0] - assert len(virgin_ingredients) > 0 - - for ing in virgin_ingredients: - # Either the ingredient is connected to a bottle or it's in the available handadd list - assert ing.bottle is not None or ing.id in db_commander.get_available_ids() - - def test_get_disabled_cocktails(self, db_commander: DatabaseCommander): - """Test that we can get only not enabled cocktails.""" - disabled_cocktails = db_commander.get_all_cocktails(status="disabled") - assert len(disabled_cocktails) == 1 - tequila_sunrise = next((c for c in disabled_cocktails if c.name == "Tequila Sunrise"), None) - assert tequila_sunrise is not None - - def test_increment_recipe_counter(self, db_commander: DatabaseCommander): - """Test the increment_recipe_counter method.""" - db_commander.increment_recipe_counter("Cuba Libre", virgin=False) - session = Session(db_commander.engine) - recipe = session.query(DbRecipe).filter_by(name="Cuba Libre").first() - session.close() - assert recipe is not None - assert recipe.counter == 1 - assert recipe.counter_virgin == 0 - - def test_increment_recipe_counter_virgin(self, db_commander: DatabaseCommander): - """Test the increment_recipe_counter method.""" - db_commander.increment_recipe_counter("Cuba Libre", virgin=True) - session = Session(db_commander.engine) - recipe = session.query(DbRecipe).filter_by(name="Cuba Libre").first() - session.close() - assert recipe is not None - assert recipe.counter == 0 - assert recipe.counter_virgin == 1 - - def test_set_recipe(self, db_commander: DatabaseCommander): - """Test the set_recipe method.""" - db_commander.set_recipe(1, "Cuba Libre 2", 11, 290, 1.0, True, False, [(1, 80, 1), (2, 210, 2)]) - cocktail = db_commander.get_cocktail(1) - assert cocktail is not None - assert cocktail.name == "Cuba Libre 2" - assert cocktail.price_per_100_ml == pytest.approx(1.0) - - def test_insert_new_recipe(self, db_commander: DatabaseCommander): - """Test the insert_new_recipe method.""" - db_commander.insert_new_recipe("New Recipe", 0, 1000, 8.5, True, False, [(1, 80, 1)]) - cocktail = db_commander.get_cocktail("New Recipe") - assert cocktail is not None - assert cocktail.name == "New Recipe" - assert cocktail.price_per_100_ml == pytest.approx(8.5) - - def test_insert_recipe_data(self, db_commander: DatabaseCommander): - """Test the insert_recipe_data method.""" - ingredient_data = [(1, 80, 1), (2, 100, 2), (3, 50, 3)] - cocktail = db_commander.insert_new_recipe("New Recipe", 0, 250, 4.0, True, False, ingredient_data) - assert cocktail is not None - cocktail = db_commander.get_cocktail(cocktail.id) - assert cocktail is not None - assert len(cocktail.ingredients) == 3 - # test that each ingredient is existing (first integer is id) - # sort ingredients by id - cocktail.ingredients.sort(key=lambda x: x.id) - for ingredient, data in zip(cocktail.ingredients, ingredient_data): - assert ingredient.id is data[0] - assert ingredient.amount is data[1] - assert ingredient.recipe_order is data[2] - - def test_delete_recipe(self, db_commander: DatabaseCommander): - """Test the delete_recipe method.""" - db_commander.delete_recipe("Cuba Libre") - cocktail = db_commander.get_cocktail("Cuba Libre") - assert cocktail is None - - def test_delete_recipe_ingredient_data(self, db_commander: DatabaseCommander): - """Test the delete_recipe_ingredient_data method.""" - db_commander.delete_recipe_ingredient_data(1) - cocktail = db_commander.get_cocktail(1) - assert cocktail is not None - assert len(cocktail.ingredients) == 0 - - def test_get_nonexistent_cocktail(self, db_commander: DatabaseCommander): - """Test getting a cocktail that does not exist.""" - cocktail = db_commander.get_cocktail(999) - assert cocktail is None - - def test_insert_duplicate_recipe(self, db_commander: DatabaseCommander): - """Test inserting a recipe with a duplicate name.""" - with pytest.raises(ElementAlreadyExistsError): - db_commander.insert_new_recipe("Cuba Libre", 11, 290, 5.0, True, False, [(1, 80, 1), (2, 210, 2)]) - - def test_delete_nonexistent_recipe(self, db_commander: DatabaseCommander): - """Test deleting a recipe that does not exist.""" - with pytest.raises(ElementNotFoundError): - db_commander.delete_recipe(999) - - def test_set_nonexistent_recipe(self, db_commander: DatabaseCommander): - """Test setting a recipe that does not exist.""" - with pytest.raises(ElementNotFoundError): - db_commander.set_recipe(999, "Nonexistent", 0, 0, 1.0, False, False, []) - - def test_increment_nonexistent_recipe_counter(self, db_commander: DatabaseCommander): - """Test incrementing the counter of a recipe that does not exist.""" - with pytest.raises(ElementNotFoundError): - db_commander.increment_recipe_counter("Nonexistent", virgin=False) - - def test_delete_recipe_still_in_use(self, db_commander: DatabaseCommander): - """Test deleting a recipe that is still in use.""" - db_commander.insert_new_recipe("Test Recipe", 0, 100, 2.5, True, False, [(1, 50, 1)]) - with pytest.raises(DatabaseTransactionError): - db_commander.delete_ingredient(1) - - def test_enable_all_recipes(self, db_commander: DatabaseCommander): - """Test enabling all recipes.""" - db_commander.set_all_recipes_enabled() - cocktails = db_commander.get_all_cocktails() - assert all(cocktail.enabled for cocktail in cocktails) - - def test_optimal_ingredient_selection(self, db_commander: DatabaseCommander): - """Test the optimal_ingredient_selection method.""" - for i in range(5): - db_commander.insert_new_recipe(f"rumAndCola {i}", 10, 250, 5.0, True, False, [(1, 50, 1), (2, 200, 2)]) - top_ing_ids = db_commander.get_most_used_ingredient_ids(k=2) - assert top_ing_ids == {1, 2} - - -class TestIngredient: - def test_get_ingredient(self, db_commander: DatabaseCommander): - """Test the get_ingredient method.""" - ingredient = db_commander.get_ingredient(1) - assert ingredient is not None - assert ingredient.name == "White Rum" - assert ingredient.alcohol == 40 - assert ingredient.bottle_volume == 1000 - assert ingredient.fill_level == 1000 - assert ingredient.hand is False - assert ingredient.pump_speed == 100 - - def test_get_all_ingredients(self, db_commander: DatabaseCommander): - """Test the get_all_ingredients method.""" - ingredients = db_commander.get_all_ingredients() - assert len(ingredients) == 7 - - cola = next((i for i in ingredients if i.name == "Cola"), None) - assert cola is not None - assert cola.alcohol == 0 - assert cola.bottle_volume == 1000 - assert cola.fill_level == 0 - assert cola.hand is False - assert cola.pump_speed == 100 - - def test_get_all_machine_ingredients(self, db_commander: DatabaseCommander): - """Test the get_all_machine_ingredients method.""" - ingredients = db_commander.get_all_ingredients(get_hand=False) - assert len(ingredients) == 6 - - cola = next((i for i in ingredients if i.name == "Cola"), None) - assert cola is not None - - def test_get_all_hand_ingredients(self, db_commander: DatabaseCommander): - """Test the get_all_hand_ingredients method.""" - ingredients = db_commander.get_all_ingredients(get_machine=False) - assert len(ingredients) == 1 - - blue_curacao = next((i for i in ingredients if i.name == "Blue Curacao"), None) - assert blue_curacao is not None - - def test_get_all_ingredients_return_empty_if_both_false(self, db_commander: DatabaseCommander): - """Test the get_all_ingredients method.""" - ingredients = db_commander.get_all_ingredients(get_hand=False, get_machine=False) - assert len(ingredients) == 0 - - def test_get_ingredient_at_bottle(self, db_commander: DatabaseCommander): - """Test the get_ingredient_at_bottle method.""" - ingredient = db_commander.get_ingredient_at_bottle(1) - assert ingredient is not None - assert ingredient.name == "White Rum" - - def test_get_ingredient_names_at_bottles(self, db_commander: DatabaseCommander): - """Test the get_ingredient_names_at_bottles method.""" - ingredient_names = db_commander.get_ingredient_names_at_bottles() - assert len(ingredient_names) == 24 - assert ingredient_names[1] == "Cola" - - def test_set_ingredient_data(self, db_commander: DatabaseCommander): - """Test the set_ingredient_data method.""" - db_commander.set_ingredient_data("New Unique Name", 40, 1000, 800, False, 10, 1, 100, "cl") - ingredient = db_commander.get_ingredient(1) - assert ingredient is not None - assert ingredient.name == "New Unique Name" - assert ingredient.alcohol == 40 - assert ingredient.bottle_volume == 1000 - assert ingredient.fill_level == 800 - assert ingredient.hand is False - assert ingredient.pump_speed == 10 - assert ingredient.id == 1 - assert ingredient.cost == 100 - assert ingredient.unit == "cl" - - def test_set_ingredient_level_to_value(self, db_commander: DatabaseCommander): - """Test the set_ingredient_level_to_value method.""" - db_commander.set_ingredient_level_to_value(1, 500) - ingredient = db_commander.get_ingredient(1) - assert ingredient is not None - assert ingredient.fill_level == 500 - - def test_raise_not_found_on_set_ingredient_level_to_value(self, db_commander: DatabaseCommander): - """Test the set_ingredient_level_to_value method.""" - with pytest.raises(ElementNotFoundError): - db_commander.set_ingredient_level_to_value(999, 500) - - def test_insert_new_ingredient(self, db_commander: DatabaseCommander): - """Test the insert_new_ingredient method.""" - db_commander.insert_new_ingredient("New Ingredient", 0, 1000, False, 10, 100, "ml") - ingredient = db_commander.get_ingredient("New Ingredient") - assert ingredient is not None - assert ingredient.name == "New Ingredient" - - def test_increment_ingredient_consumption(self, db_commander: DatabaseCommander): - """Test the increment_ingredient_consumption method.""" - consumption = 100 - db_commander.increment_ingredient_consumption("White Rum", consumption) - session = Session(db_commander.engine) - ingredient = session.query(DbIngredient).filter_by(name="White Rum").first() - session.close() - assert ingredient is not None - assert ingredient.consumption == consumption - assert ingredient.consumption_lifetime == consumption - cost = int(round(ingredient.cost / ingredient.volume * consumption, 0)) - assert ingredient.cost_consumption == cost - assert ingredient.cost_consumption_lifetime == cost - # check multiple times works as well - db_commander.increment_ingredient_consumption("White Rum", consumption) - ingredient = session.query(DbIngredient).filter_by(name="White Rum").first() - session.close() - assert ingredient is not None - assert ingredient.consumption == 2 * consumption - assert ingredient.consumption_lifetime == 2 * consumption - assert ingredient.cost_consumption == 2 * cost - assert ingredient.cost_consumption_lifetime == 2 * cost - - def test_set_multiple_ingredient_consumption(self, db_commander: DatabaseCommander): - """Test the set_multiple_ingredient_consumption method.""" - ingredients = ["White Rum", "Cola"] - amount_1 = 100 - amount_2 = 50 - amounts = [amount_1, amount_2] - db_commander.set_multiple_ingredient_consumption(ingredients, amounts) - session = Session(db_commander.engine) - ingredient_1 = session.query(DbIngredient).filter_by(name=ingredients[0]).first() - ingredient_2 = session.query(DbIngredient).filter_by(name=ingredients[1]).first() - session.close() - assert ingredient_1 is not None - assert ingredient_1.consumption == amount_1 - assert ingredient_1.consumption_lifetime == amount_1 - cost_1 = int(round(ingredient_1.cost / ingredient_1.volume * amount_1, 0)) - assert ingredient_1.cost_consumption == cost_1 - assert ingredient_1.cost_consumption_lifetime == cost_1 - assert ingredient_2 is not None - assert ingredient_2.consumption == amount_2 - assert ingredient_2.consumption_lifetime == amount_2 - cost_2 = int(round(ingredient_2.cost / ingredient_2.volume * amount_2, 0)) - assert ingredient_2.cost_consumption == cost_2 - assert ingredient_2.cost_consumption_lifetime == cost_2 - - @pytest.mark.parametrize("ingredient_id", (1, 4, 6)) - def test_delete_ingredient_fails(self, db_commander: DatabaseCommander, ingredient_id: int): - """Test the delete_ingredient method.""" - with pytest.raises(DatabaseTransactionError): - db_commander.delete_ingredient(ingredient_id) - ingredient = db_commander.get_ingredient(ingredient_id) - assert ingredient is not None - - def test_delete_ingredient(self, db_commander: DatabaseCommander): - """Test the delete_ingredient method.""" - db_commander.insert_new_ingredient("New Ingredient", 0, 1000, False, 10, 100, "ml") - ingredient = db_commander.get_ingredient("New Ingredient") - assert ingredient is not None - db_commander.delete_ingredient(ingredient.id) - ingredient = db_commander.get_ingredient(ingredient.id) - assert ingredient is None - - @pytest.mark.parametrize("ing", ([1, 2], ["White Rum", "Cola"])) - def test_insert_multiple_existing_handadd_ingredients( - self, db_commander: DatabaseCommander, ing: list[str] | list[int] - ): - """Test the insert_multiple_existing_handadd_ingredients method.""" - db_commander.insert_multiple_existing_handadd_ingredients(ing) - names = db_commander.get_available_ingredient_names() - assert "White Rum" in names - assert "Cola" in names - - def test_delete_existing_handadd_ingredient(self, db_commander: DatabaseCommander): - """Test the delete_existing_handadd_ingredient method.""" - db_commander.delete_existing_handadd_ingredient() - names = db_commander.get_available_ingredient_names() - assert len(names) == 0 - - def test_get_nonexistent_ingredient(self, db_commander: DatabaseCommander): - """Test getting an ingredient that does not exist.""" - ingredient = db_commander.get_ingredient(999) - assert ingredient is None - - def test_insert_duplicate_ingredient(self, db_commander: DatabaseCommander): - """Test inserting an ingredient with a duplicate name.""" - with pytest.raises(ElementAlreadyExistsError): - db_commander.insert_new_ingredient("White Rum", 40, 1000, False, 100, 100, "ml") - - def test_delete_nonexistent_ingredient(self, db_commander: DatabaseCommander): - """Test deleting an ingredient that does not exist.""" - with pytest.raises(ElementNotFoundError): - db_commander.delete_ingredient(999) - - def test_set_nonexistent_ingredient(self, db_commander: DatabaseCommander): - """Test setting an ingredient that does not exist.""" - with pytest.raises(ElementNotFoundError): - db_commander.set_ingredient_data("Nonexistent", 0, 0, 0, False, 0, 999, 0, "ml") - - def test_increment_nonexistent_ingredient_consumption(self, db_commander: DatabaseCommander): - """Test incrementing the consumption of an ingredient that does not exist.""" - with pytest.raises(ElementNotFoundError): - db_commander.increment_ingredient_consumption("Nonexistent", 100) - - def test_delete_ingredient_still_in_use(self, db_commander: DatabaseCommander): - """Test deleting an ingredient that is still in use.""" - with pytest.raises(DatabaseTransactionError): - db_commander.delete_ingredient(1) - - -class TestBottle: - def test_get_bottle_fill_levels(self, db_commander: DatabaseCommander): - """Test the get_bottle_fill_levels method.""" - fill_levels = db_commander.get_bottle_fill_levels() - assert len(fill_levels) == 24 - assert fill_levels[0] == 100 - assert fill_levels[1] == 0 - - def test_get_bottle_data(self, db_commander: DatabaseCommander): - ingredients = db_commander.get_ingredients_at_bottles() - assert len(ingredients) == 24 - assert ingredients[0].name == "White Rum" - assert ingredients[1].name == "Cola" - - @pytest.mark.parametrize( - "ingredient_id, expected_usage", - [ - (1, True), - (4, False), - ], - ) - def test_get_bottle_usage(self, db_commander: DatabaseCommander, ingredient_id: int, expected_usage: bool): - """Test the get_bottle_usage method.""" - usage = db_commander.get_bottle_usage(ingredient_id) - assert usage is expected_usage - - def test_set_bottle_order(self, db_commander: DatabaseCommander): - """Test the set_bottle_order method.""" - db_commander.set_bottle_order(["White Rum", "Cola"]) - data = db_commander.get_ingredients_at_bottles() - assert data[0].name == "White Rum" - - def test_get_ingredient_at_bottle(self, db_commander: DatabaseCommander): - """Test the get_ingredient_at_bottle method.""" - ingredient = db_commander.get_ingredient_at_bottle(1) - assert ingredient is not None - assert ingredient.name == "White Rum" - - def test_get_ingredient_at_bottle_not_set(self, db_commander: DatabaseCommander): - """Test the get_ingredient_at_bottle method when no ingredient is set.""" - ingredient = db_commander.get_ingredient_at_bottle(10) - assert ingredient is None - - @pytest.mark.parametrize("ing", [6, "Fanta"]) - def test_set_bottle_at_slot(self, db_commander: DatabaseCommander, ing: int | str): - """Test the set_bottle_at_slot method.""" - db_commander.set_bottle_at_slot(ing, 1) - ingredient = db_commander.get_ingredient_at_bottle(1) - assert ingredient is not None - assert ingredient.name == "Fanta" - - def test_set_bottle_volumelevel_to_max(self, db_commander: DatabaseCommander): - """Test the set_bottle_volumelevel_to_max method.""" - db_commander.set_bottle_volumelevel_to_max([1]) - fill_levels = db_commander.get_bottle_fill_levels() - assert fill_levels[0] == 100 - - def test_get_ingredient_names_at_bottles_with_empty(self, db_commander: DatabaseCommander): - """Test the get_ingredient_names_at_bottles method with empty bottles.""" - ingredient_names = db_commander.get_ingredient_names_at_bottles() - assert len(ingredient_names) == 24 # Includes the empty bottle - assert ingredient_names[3] == "" # Bottle 4 is empty - # we have 4 bottles with ingredients - assert len([name for name in ingredient_names if name != ""]) == 4 - - -class TestBackup: - def test_create_backup(self, db_commander: DatabaseCommander): - """Test the create_backup method.""" - with patch("shutil.copy") as mock_shutil_copy: - db_commander.create_backup() - mock_shutil_copy.assert_called_once() - - -class TestAvailable: - def test_get_available_ingredient_names(self, db_commander: DatabaseCommander): - """Test the get_available_ingredient_names method.""" - names = db_commander.get_available_ingredient_names() - assert len(names) == 1 - assert names[0] == "Blue Curacao" - - def test_get_available_ids(self, db_commander: DatabaseCommander): - """Test the get_available_ids method.""" - ids = db_commander.get_available_ids() - assert len(ids) == 1 - assert ids[0] == 4 - - def test_insert_empty_silently_skip(self, db_commander: DatabaseCommander): - db_commander.delete_existing_handadd_ingredient() - db_commander.insert_multiple_existing_handadd_ingredients([]) - assert db_commander.get_available_ingredient_names() == [] - - -class TestData: - def test_get_consumption_data_lists_recipes(self, db_commander: DatabaseCommander): - """Test the get_consumption_data_lists_recipes method.""" - data = db_commander.get_consumption_data_lists_recipes() - assert len(data) == 3 - assert data[0][1] == "Cuba Libre" - - def test_get_consumption_data_lists_ingredients(self, db_commander: DatabaseCommander): - """Test the get_consumption_data_lists_ingredients method.""" - data = db_commander.get_consumption_data_lists_ingredients() - assert len(data) == 3 - assert data[0][1] == "White Rum" - - def test_get_cost_data_lists_ingredients(self, db_commander: DatabaseCommander): - """Test the get_cost_data_lists_ingredients method.""" - data = db_commander.get_cost_data_lists_ingredients() - assert len(data) == 3 - assert data[0][1] == "White Rum" - - def test_delete_database_data(self, db_commander: DatabaseCommander): - """Test the delete_database_data method.""" - db_commander.delete_database_data() - cocktails = db_commander.get_all_cocktails() - ingredients = db_commander.get_all_ingredients() - assert len(cocktails) == 0 - assert len(ingredients) == 0 - - -class TestFailedTeamData: - def test_save_failed_teamdata(self, db_commander: DatabaseCommander): - """Test the save_failed_teamdata method.""" - db_commander.save_failed_teamdata("test_payload") - data = db_commander.get_failed_teamdata() - assert data is not None - assert data[1] == "test_payload" - - def test_get_failed_teamdata(self, db_commander: DatabaseCommander): - """Test the get_failed_teamdata method.""" - db_commander.save_failed_teamdata("test_payload") - data = db_commander.get_failed_teamdata() - assert data is not None - assert data[1] == "test_payload" - - def test_get_nonexistent_failed_teamdata(self, db_commander: DatabaseCommander): - """Test getting failed teamdata that does not exist.""" - data = db_commander.get_failed_teamdata() - assert data is None - - def test_delete_failed_teamdata(self, db_commander: DatabaseCommander): - """Test the delete_failed_teamdata method.""" - db_commander.save_failed_teamdata("test_payload") - data = db_commander.get_failed_teamdata() - assert data is not None - db_commander.delete_failed_teamdata(data[0]) - data = db_commander.get_failed_teamdata() - assert data is None - - def test_delete_nonexistent_failed_teamdata(self, db_commander: DatabaseCommander): - """Test deleting failed teamdata that does not exist.""" - with pytest.raises(ElementNotFoundError): - db_commander.delete_failed_teamdata(999) - - -class TestExports: - def test_export_recipe_data(self, db_commander: DatabaseCommander): - """Test that recipe counters are exported correctly.""" - number_cocktails = 3 - for _ in range(number_cocktails): - db_commander.increment_recipe_counter("Cuba Libre", virgin=False) - - db_commander.export_recipe_data() - export_data = db_commander.get_export_data() - today = datetime.date.today().strftime("%Y-%m-%d") - assert today in export_data - - # Check if the recipe counter was exported correctly - assert "Cuba Libre" in export_data[today].recipes - assert export_data[today].recipes["Cuba Libre"] == number_cocktails - - # Check that the counter was reset in the database (need to check directly in DB) - session = Session(db_commander.engine) - recipe = session.query(DbRecipe).filter_by(name="Cuba Libre").first() - session.close() - assert recipe is not None - assert recipe.counter == 0 - - def test_export_ingredient_data(self, db_commander: DatabaseCommander): - """Test that ingredient consumption and cost are exported correctly.""" - consumption = 100 - db_commander.increment_ingredient_consumption("White Rum", consumption) - db_commander.export_ingredient_data() - export_data = db_commander.get_export_data() - today = datetime.date.today().strftime("%Y-%m-%d") - assert today in export_data - - # Check if the ingredient consumption was exported correctly - assert "White Rum" in export_data[today].ingredients - assert export_data[today].ingredients["White Rum"] == consumption - today_cost = export_data[today].cost - assert today_cost is not None - assert "White Rum" in today_cost - - # Get the ingredient to calculate the expected cost - ingredient = db_commander.get_ingredient("White Rum") - assert ingredient is not None - expected_cost = int(round(ingredient.cost / ingredient.bottle_volume * consumption, 0)) - assert today_cost["White Rum"] == expected_cost - - # Check that the consumption was reset (directly in DB) - session = Session(db_commander.engine) - db_ingredient = session.query(DbIngredient).filter_by(name="White Rum").first() - assert db_ingredient is not None - session.close() - assert db_ingredient.consumption == 0 - assert db_ingredient.cost_consumption == 0 - - def test_export_zero_cost_ingredient(self, db_commander: DatabaseCommander): - """Test that ingredients with zero cost are not included in cost exports.""" - # Set the cost of an ingredient to 0 - ingredient = db_commander.get_ingredient("Cola") - assert ingredient is not None - db_commander.set_ingredient_data( - ingredient.name, - ingredient.alcohol, - ingredient.bottle_volume, - ingredient.fill_level, - ingredient.hand, - ingredient.pump_speed, - ingredient.id, - 0, - ingredient.unit, - ) - - consumption = 200 - db_commander.increment_ingredient_consumption("Cola", consumption) - db_commander.export_ingredient_data() - export_data = db_commander.get_export_data() - today = datetime.date.today().strftime("%Y-%m-%d") - - assert "Cola" in export_data[today].ingredients - assert export_data[today].ingredients["Cola"] == consumption - today_cost = export_data[today].cost - assert today_cost is not None - assert "Cola" not in today_cost - - def test_export_only_if_consumption_greater_than_zero(self, db_commander: DatabaseCommander): - """Test that only ingredients with consumption > 0 are exported.""" - ingredient_name = "Fanta" - db_commander.export_ingredient_data() - export_data = db_commander.get_export_data() - today = datetime.date.today().strftime("%Y-%m-%d") - - # Check that the ingredient without consumption is not in the export - if today in export_data: - assert ingredient_name not in export_data[today].ingredients - - def test_export_dates(self, db_commander: DatabaseCommander): - """Test that export dates are returned correctly.""" - db_commander.increment_recipe_counter("Cuba Libre", virgin=False) - db_commander.export_recipe_data() - export_dates = db_commander.get_export_dates() - - # Today's date should be in the list - today = datetime.date.today().strftime("%Y-%m-%d") - assert today in export_dates - - def test_multiple_exports_same_day(self, db_commander: DatabaseCommander): - """Test that multiple exports on the same day are combined correctly.""" - today = datetime.date.today() - - # First export - db_commander.increment_recipe_counter("Cuba Libre", virgin=False) - db_commander.increment_ingredient_consumption("White Rum", 50) - db_commander.export_recipe_data() - db_commander.export_ingredient_data() - - # Second export on the same day - db_commander.increment_recipe_counter("Cuba Libre", virgin=False) - db_commander.increment_recipe_counter("Cuba Libre", virgin=True) - db_commander.increment_ingredient_consumption("White Rum", 100) - db_commander.export_recipe_data() - db_commander.export_ingredient_data() - - # Verify exports were combined - export_data = db_commander.get_export_data() - today_str = today.strftime("%Y-%m-%d") - - # Check that the recipe counter was combined (1 + 2 = 3) - assert today_str in export_data - assert "Cuba Libre" in export_data[today_str].recipes - assert export_data[today_str].recipes["Cuba Libre"] == 2 - assert export_data[today_str].recipes.get(VIRGIN_NAME_TEMPLATE.format("Cuba Libre"), 0) == 1 - - # Check that the ingredient consumption was combined (50 + 100 = 150) - assert "White Rum" in export_data[today_str].ingredients - assert export_data[today_str].ingredients["White Rum"] == 150 - - # Check that the cost consumption was also combined correctly - ingredient = db_commander.get_ingredient("White Rum") - assert ingredient is not None - expected_cost = int(round(ingredient.cost / ingredient.bottle_volume * 150, 0)) - today_cost = export_data[today_str].cost - assert today_cost is not None - assert "White Rum" in today_cost - assert today_cost["White Rum"] == expected_cost - - def test_same_cocktail_counter_only_if_greater_zero(self, db_commander: DatabaseCommander): - """Test that virgin and normal cocktail are only exported if consumption > 0.""" - db_commander.increment_recipe_counter("Cuba Libre", virgin=False) - db_commander.increment_recipe_counter("Tequila Sunrise", virgin=True) - db_commander.export_recipe_data() - export_data = db_commander.get_export_data() - today = datetime.date.today().strftime("%Y-%m-%d") - - assert today in export_data - assert "Cuba Libre" in export_data[today].recipes - assert VIRGIN_NAME_TEMPLATE.format("Cuba Libre") not in export_data[today].recipes - assert "Tequila Sunrise" not in export_data[today].recipes - assert VIRGIN_NAME_TEMPLATE.format("Tequila Sunrise") in export_data[today].recipes - - -class TestResourceUsage: - def test_save_and_get_resource_stats(self, db_commander: DatabaseCommander): - """Test saving resource usage and retrieving resource stats.""" - # Prepare test data - cpu_values = [10.5, 20.0, 15.0] - ram_values = [10.0, 20.0, 15.0] - session_number = 33 - now = datetime.datetime.now() - for cpu, ram in zip(cpu_values, ram_values): - db_commander.save_resource_usage(cpu, ram, session_number, timestamp=now) - - # Retrieve resource stats for session 1 - stats = db_commander.get_resource_stats(session_number) - assert stats is not None - assert stats.samples == len(cpu_values) - assert stats.min_cpu == min(cpu_values) - assert stats.max_cpu == max(cpu_values) - assert pytest.approx(stats.mean_cpu) == round(sum(cpu_values) / len(cpu_values), 1) - assert stats.min_ram == min(ram_values) - assert stats.max_ram == max(ram_values) - assert pytest.approx(stats.mean_ram) == round(sum(ram_values) / len(ram_values), 1) - # When below max_raw_points, all values should be returned - assert len(stats.raw_cpu) == len(cpu_values) - assert len(stats.raw_ram) == len(ram_values) - assert set(stats.raw_cpu) == set(cpu_values) - assert set(stats.raw_ram) == set(ram_values) - - def test_save_and_get_resource_stats_returns_empty_data(self, db_commander: DatabaseCommander): - """Test saving resource usage and retrieving empty resource stats.""" - stats = db_commander.get_resource_stats(1) - assert stats is not None - assert stats.samples == 0 - assert stats.min_cpu == pytest.approx(0.0) - assert stats.max_cpu == pytest.approx(0.0) - assert stats.mean_cpu == pytest.approx(0.0) - assert stats.min_ram == pytest.approx(0.0) - assert stats.max_ram == pytest.approx(0.0) - assert stats.mean_ram == pytest.approx(0.0) - assert len(stats.raw_cpu) == 0 - assert len(stats.raw_ram) == 0 - - def test_get_resource_session_numbers(self, db_commander: DatabaseCommander): - """Test getting resource session numbers.""" - cpu_values = [10.5, 20.0, 15.0] - ram_values = [10.0, 20.0, 15.0] - session_numbers = [2, 1, 3] - now = datetime.datetime.now() - for cpu, ram, session_number in zip(cpu_values, ram_values, session_numbers): - db_commander.save_resource_usage(cpu, ram, session_number, timestamp=now) - - inserted_numbers = db_commander.get_resource_session_numbers() - assert len(inserted_numbers) == 3 - assert [x.session_id for x in inserted_numbers] == sorted(session_numbers) - # check that the date string second element is "%Y-%m-%d %H:%M" - assert all(now.strftime("%Y-%m-%d %H:%M") == x.start_time for x in inserted_numbers) - - def test_get_highest_session_number(self, db_commander: DatabaseCommander): - """Test getting the highest session number.""" - cpu_values = [10.5, 20.0, 15.0] - ram_values = [10.0, 20.0, 15.0] - session_numbers = [1, 2, 3] - now = datetime.datetime.now() - for cpu, ram, session_number in zip(cpu_values, ram_values, session_numbers): - db_commander.save_resource_usage(cpu, ram, session_number, timestamp=now) - - highest_session_number = db_commander.get_highest_session_number() - assert highest_session_number == max(session_numbers) - - def test_get_resource_stats_samples_large_datasets(self, db_commander: DatabaseCommander): - """Test that large datasets are sampled to prevent OOM.""" - session_number = 99 - now = datetime.datetime.now() - # Create more data points than max_raw_points - total_points = 100 - max_raw_points = 20 - cpu_values = [float(i % 100) for i in range(total_points)] - ram_values = [float((i + 10) % 100) for i in range(total_points)] - - for cpu, ram in zip(cpu_values, ram_values): - db_commander.save_resource_usage(cpu, ram, session_number, timestamp=now) - - stats = db_commander.get_resource_stats(session_number, max_raw_points=max_raw_points) - - # Samples should reflect total count - assert stats.samples == total_points - # Raw data should be limited to max_raw_points - assert len(stats.raw_cpu) <= max_raw_points - assert len(stats.raw_ram) <= max_raw_points - # Aggregated stats should still be accurate (computed from all data in SQL) - assert stats.min_cpu == min(cpu_values) - assert stats.max_cpu == max(cpu_values) - assert stats.min_ram == min(ram_values) - assert stats.max_ram == max(ram_values) - assert pytest.approx(stats.mean_cpu, rel=0.1) == sum(cpu_values) / len(cpu_values) - assert pytest.approx(stats.mean_ram, rel=0.1) == sum(ram_values) / len(ram_values) - - def test_cleanup_resource_stats_does_not_remove_if_50_or_less(self, db_commander: DatabaseCommander): - """Test that cleanup does not remove sessions if we have 50 or fewer.""" - now = datetime.datetime.now() - # Create exactly 50 sessions - for session_number in range(1, 51): - db_commander.save_resource_usage(10.0, 20.0, session_number, timestamp=now) - - deleted_count = db_commander.cleanup_resource_stats(keep_sessions=50) - assert deleted_count == 0 - - # All sessions should still exist - session_numbers = db_commander.get_resource_session_numbers() - assert len(session_numbers) == 50 - - def test_cleanup_resource_stats_removes_older_sessions(self, db_commander: DatabaseCommander): - """Test that cleanup removes sessions older than the latest 50.""" - now = datetime.datetime.now() - # Create 55 sessions (should remove the 5 oldest) - for session_number in range(1, 56): - db_commander.save_resource_usage(10.0, 20.0, session_number, timestamp=now) - db_commander.save_resource_usage(15.0, 25.0, session_number, timestamp=now) # 2 records per session - - deleted_count = db_commander.cleanup_resource_stats(keep_sessions=50) - # 5 sessions with 2 records each = 10 deleted records - assert deleted_count == 10 - - # Only 50 sessions should remain - session_numbers = db_commander.get_resource_session_numbers() - assert len(session_numbers) == 50 - - # The oldest sessions (1-5) should be removed, keeping 6-55 - session_ids = [s.session_id for s in session_numbers] - assert min(session_ids) == 6 - assert max(session_ids) == 55 - - def test_cleanup_resource_stats_with_no_data(self, db_commander: DatabaseCommander): - """Test that cleanup works correctly when there's no data.""" - deleted_count = db_commander.cleanup_resource_stats(keep_sessions=50) - assert deleted_count == 0 - - def test_cleanup_resource_stats_with_custom_keep_sessions(self, db_commander: DatabaseCommander): - """Test cleanup with a custom number of sessions to keep.""" - now = datetime.datetime.now() - # Create 10 sessions - for session_number in range(1, 11): - db_commander.save_resource_usage(10.0, 20.0, session_number, timestamp=now) - - # Keep only 5 sessions - deleted_count = db_commander.cleanup_resource_stats(keep_sessions=5) - assert deleted_count == 5 # 5 sessions with 1 record each - - # Only 5 sessions should remain - session_numbers = db_commander.get_resource_session_numbers() - assert len(session_numbers) == 5 - - # Sessions 6-10 should remain (the latest 5) - session_ids = [s.session_id for s in session_numbers] - assert session_ids == [6, 7, 8, 9, 10] - - -class TestNews: - def test_get_unacknowledged_news_keys_empty_list(self, db_commander: DatabaseCommander): - """Test that empty list returns empty list.""" - result = db_commander.get_unacknowledged_news_keys([]) - assert result == [] - - def test_get_unacknowledged_news_keys_new_keys(self, db_commander: DatabaseCommander): - """Test that new news keys are returned as unacknowledged.""" - news_keys = ["news_v2_available", "news_feature_update"] - result = db_commander.get_unacknowledged_news_keys(news_keys) - assert len(result) == 2 - assert "news_v2_available" in result - assert "news_feature_update" in result - - def test_acknowledge_news(self, db_commander: DatabaseCommander): - """Test that news can be acknowledged and won't be returned again.""" - news_keys = ["news_v2_available", "news_feature_update"] - - # First call - should return all keys - result = db_commander.get_unacknowledged_news_keys(news_keys) - assert len(result) == 2 - - # Acknowledge one news - db_commander.acknowledge_news("news_v2_available") - - # Second call - should only return unacknowledged - result = db_commander.get_unacknowledged_news_keys(news_keys) - assert len(result) == 1 - assert "news_feature_update" in result - assert "news_v2_available" not in result - - def test_acknowledge_nonexistent_news(self, db_commander: DatabaseCommander): - """Test that acknowledging non-existent news doesn't raise an error.""" - with pytest.raises(ElementNotFoundError): - db_commander.acknowledge_news("nonexistent_news_key") - - def test_news_persistence_across_calls(self, db_commander: DatabaseCommander): - """Test that news state persists across multiple calls.""" - news_keys = ["news_v2_available"] - - # Get unacknowledged news - result1 = db_commander.get_unacknowledged_news_keys(news_keys) - assert len(result1) == 1 - - # Call again without acknowledging - should still return same - result2 = db_commander.get_unacknowledged_news_keys(news_keys) - assert len(result2) == 1 - assert result1 == result2 - - # Acknowledge - db_commander.acknowledge_news("news_v2_available") - - # Call again - should return empty - result3 = db_commander.get_unacknowledged_news_keys(news_keys) - assert len(result3) == 0 - - -class TestEvents: - def test_save_event_basic(self, db_commander: DatabaseCommander): - """Test saving a basic event without additional info.""" - db_commander.save_event(EventType.CLEANING) - events = db_commander.get_events() - assert len(events) == 1 - assert events[0].event_type == EventType.CLEANING - assert events[0].additional_info is None - - def test_save_event_with_additional_info(self, db_commander: DatabaseCommander): - """Test saving an event with additional info.""" - db_commander.save_event(EventType.COCKTAIL_PREPARATION, additional_info='{"cocktail": "Cuba Libre"}') - events = db_commander.get_events() - assert len(events) == 1 - assert events[0].event_type == EventType.COCKTAIL_PREPARATION - assert events[0].additional_info == '{"cocktail": "Cuba Libre"}' - - def test_save_multiple_events(self, db_commander: DatabaseCommander): - """Test saving multiple events of different types.""" - db_commander.save_event(EventType.CLEANING) - db_commander.save_event(EventType.COCKTAIL_PREPARATION, additional_info="Cuba Libre") - db_commander.save_event(EventType.SHUTDOWN) - - events = db_commander.get_events() - assert len(events) == 3 - # Events are ordered by timestamp descending - event_types = [e.event_type for e in events] - assert EventType.CLEANING in event_types - assert EventType.COCKTAIL_PREPARATION in event_types - assert EventType.SHUTDOWN in event_types - - def test_get_events_filter_by_type(self, db_commander: DatabaseCommander): - """Test filtering events by type.""" - db_commander.save_event(EventType.CLEANING) - db_commander.save_event(EventType.COCKTAIL_PREPARATION) - db_commander.save_event(EventType.CLEANING) - db_commander.save_event(EventType.SHUTDOWN) - - # Filter for CLEANING only - cleaning_events = db_commander.get_events(event_types=[EventType.CLEANING]) - assert len(cleaning_events) == 2 - assert all(e.event_type == EventType.CLEANING for e in cleaning_events) - - # Filter for multiple types - filtered_events = db_commander.get_events(event_types=[EventType.CLEANING, EventType.SHUTDOWN]) - assert len(filtered_events) == 3 - - def test_get_events_filter_by_date(self, db_commander: DatabaseCommander): - """Test filtering events by date range.""" - db_commander.save_event(EventType.CLEANING) - - # Get events with future start_date should return empty - future_date = datetime.datetime.now() + datetime.timedelta(days=1) - events = db_commander.get_events(start_date=future_date) - assert len(events) == 0 - - # Get events with past start_date should return all - past_date = datetime.datetime.now() - datetime.timedelta(days=1) - events = db_commander.get_events(start_date=past_date) - assert len(events) == 1 - - def test_all_event_types_can_be_saved(self, db_commander: DatabaseCommander): - """Test that all EventType values can be saved and retrieved.""" - for event_type in EventType: - db_commander.save_event(event_type) - - events = db_commander.get_events() - assert len(events) == len(EventType) - saved_types = {e.event_type for e in events} - assert saved_types == set(EventType) - - def test_event_timestamp_auto_generated(self, db_commander: DatabaseCommander): - """Test that timestamp is automatically generated.""" - before = datetime.datetime.now() - db_commander.save_event(EventType.CLEANING) - after = datetime.datetime.now() - - events = db_commander.get_events() - assert len(events) == 1 - event_timestamp = datetime.datetime.fromisoformat(events[0].timestamp) - assert before.replace(microsecond=0) <= event_timestamp <= after.replace(microsecond=0) - - def test_get_events_handles_legacy_renamed_event_type(self, db_commander: DatabaseCommander): - """Test that legacy renamed event values can still be loaded from DB.""" - session = Session(db_commander.engine) - try: - session.add(DbEvent(event_type="COCKTAIL_CANCELLATION", additional_info="legacy")) - session.commit() - finally: - session.close() - - events = db_commander.get_events() - assert len(events) == 1 - assert events[0].event_type == EventType.COCKTAIL_CANCELED - assert events[0].additional_info == "legacy" - - def test_get_events_handles_removed_or_unknown_event_type(self, db_commander: DatabaseCommander): - """Test that removed/unknown historic event values do not crash loading.""" - session = Session(db_commander.engine) - try: - session.add(DbEvent(event_type="SOME_REMOVED_EVENT", additional_info="historic")) - session.commit() - finally: - session.close() - - events = db_commander.get_events() - assert len(events) == 1 - assert events[0].event_type == EventType.UNKNOWN - assert events[0].additional_info == "historic" diff --git a/web_client/src/App.tsx b/web_client/src/App.tsx index 71fc3943..a5828b09 100644 --- a/web_client/src/App.tsx +++ b/web_client/src/App.tsx @@ -22,6 +22,7 @@ import NewsWindow from './components/options/NewsWindow.tsx'; import OptionWindow from './components/options/OptionWindow.tsx'; import SumupManager from './components/options/SumupManager.tsx'; import TimeManager from './components/options/TimeManager.tsx'; +import WaiterWindow from './components/options/WaiterWindow.tsx'; import WifiManager from './components/options/WifiManager.tsx'; import RecipeCalculator from './components/recipe/RecipeCalculator.tsx'; import RecipeList from './components/recipe/RecipeList.tsx'; @@ -206,6 +207,14 @@ function App() { } /> + + + + } + /> } /> } /> diff --git a/web_client/src/api/bottles.ts b/web_client/src/api/bottles.ts index 64b58ba2..a8895156 100644 --- a/web_client/src/api/bottles.ts +++ b/web_client/src/api/bottles.ts @@ -1,4 +1,4 @@ -import { useQuery, type UseQueryResult } from 'react-query'; +import { type UseQueryResult, useQuery } from 'react-query'; import type { Bottle } from '../types/models'; import { axiosInstance } from './common'; diff --git a/web_client/src/api/cocktails.ts b/web_client/src/api/cocktails.ts index 4e1a0ad4..5eea3f29 100644 --- a/web_client/src/api/cocktails.ts +++ b/web_client/src/api/cocktails.ts @@ -1,4 +1,4 @@ -import { useQuery, type UseQueryResult } from 'react-query'; +import { type UseQueryResult, useQuery } from 'react-query'; import type { Cocktail, CocktailInput, CocktailStatus, Ingredient } from '../types/models'; import { axiosInstance } from './common'; diff --git a/web_client/src/api/ingredients.ts b/web_client/src/api/ingredients.ts index 9c886f7d..291a98bd 100644 --- a/web_client/src/api/ingredients.ts +++ b/web_client/src/api/ingredients.ts @@ -1,4 +1,4 @@ -import { useQuery, type UseQueryResult } from 'react-query'; +import { type UseQueryResult, useQuery } from 'react-query'; import type { Ingredient, IngredientInput } from '../types/models'; import { axiosInstance } from './common'; diff --git a/web_client/src/api/options.ts b/web_client/src/api/options.ts index e4c404e1..72ed5551 100644 --- a/web_client/src/api/options.ts +++ b/web_client/src/api/options.ts @@ -5,8 +5,8 @@ import type { ConfigData, ConfigDataWithUiInfo, ConsumeData, - EventData, DefinedConfigData, + EventData, IssueData, LogData, ResourceInfo, diff --git a/web_client/src/api/payment.ts b/web_client/src/api/payment.ts index 88e69f5a..5cd3f787 100644 --- a/web_client/src/api/payment.ts +++ b/web_client/src/api/payment.ts @@ -4,6 +4,9 @@ import type { Cocktail, PaymentUserData, PaymentUserUpdate } from '../types/mode import { errorToast } from '../utils'; import { API_URL } from './common'; +const RECONNECT_BASE_DELAY_MS = 1_000; +const RECONNECT_MAX_DELAY_MS = 16_000; + export const usePaymentWebSocket = (enabled: boolean) => { const [user, setUser] = useState(null); const [cocktails, setCocktails] = useState([]); @@ -24,62 +27,83 @@ export const usePaymentWebSocket = (enabled: boolean) => { return undefined; } - // Create WebSocket URL (convert http to ws) - const wsUrl = `${API_URL.replace(/^http/, 'ws')}/cocktails/ws/payment/user`; - const ws = new WebSocket(wsUrl); - // Track if connection was ever established (to suppress StrictMode double-mount noise) - let wasConnected = false; + let disposed = false; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType | null = null; + let ws: WebSocket | null = null; - ws.onopen = () => { - console.log('Payment WebSocket connected'); - wasConnected = true; - setIsConnected(true); - }; + const connect = () => { + if (disposed) return; + const wsUrl = `${API_URL.replace(/^http/, 'ws')}/cocktails/ws/payment/user`; + ws = new WebSocket(wsUrl); + // Track if connection was ever established (to suppress StrictMode double-mount noise) + let wasConnected = false; - ws.onmessage = (event) => { - try { - const data: PaymentUserUpdate = JSON.parse(event.data); + ws.onopen = () => { + console.log('Payment WebSocket connected'); + wasConnected = true; + reconnectAttempt = 0; + setIsConnected(true); + }; - // Handle error states - show toast but don't change user state - const lookupResult = data.changeReason; - if (lookupResult === 'USER_NOT_FOUND') { - errorToast(t('payment.userNotFound')); - return; - } - if (lookupResult === 'SERVICE_UNAVAILABLE') { - errorToast(t('payment.serviceUnavailable')); - return; + ws.onmessage = (event) => { + try { + const data: PaymentUserUpdate = JSON.parse(event.data); + + // Handle error states - show toast but don't change user state + const lookupResult = data.changeReason; + if (lookupResult === 'USER_NOT_FOUND') { + errorToast(t('payment.userNotFound')); + return; + } + if (lookupResult === 'SERVICE_UNAVAILABLE') { + errorToast(t('payment.serviceUnavailable')); + return; + } + + // Only update if user actually changed + const currentUid = data.user?.nfc_id ?? null; + if (currentUid !== prevUserUidRef.current) { + prevUserUidRef.current = currentUid; + setUser(data.user); + setCocktails(data.cocktails); + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); } + }; - // Only update if user actually changed - const currentUid = data.user?.nfc_id ?? null; - if (currentUid !== prevUserUidRef.current) { - prevUserUidRef.current = currentUid; - setUser(data.user); - setCocktails(data.cocktails); + ws.onerror = (error) => { + // Only log errors if connection was established (avoid React StrictMode noise) + if (wasConnected) { + console.error('Payment WebSocket error:', error); } - } catch (error) { - console.error('Error parsing WebSocket message:', error); - } - }; + }; - ws.onerror = (error) => { - // Only log errors if connection was established (avoid React StrictMode noise) - if (wasConnected) { - console.error('Payment WebSocket error:', error); - } + ws.onclose = () => { + // Only log if connection was established (avoid React StrictMode noise) + if (wasConnected) { + console.log('Payment WebSocket closed'); + } + setIsConnected(false); + // Schedule reconnection with exponential backoff + if (!disposed) { + const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttempt, RECONNECT_MAX_DELAY_MS); + reconnectAttempt += 1; + console.log(`Payment WebSocket reconnecting in ${delay}ms (attempt ${reconnectAttempt})`); + reconnectTimer = setTimeout(connect, delay); + } + }; }; - ws.onclose = () => { - // Only log if connection was established (avoid React StrictMode noise) - if (wasConnected) { - console.log('Payment WebSocket closed'); - } - setIsConnected(false); - }; + connect(); return () => { - ws.close(); + disposed = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + ws?.close(); }; }, [enabled, t]); diff --git a/web_client/src/api/waiters.ts b/web_client/src/api/waiters.ts new file mode 100644 index 00000000..58b1edcd --- /dev/null +++ b/web_client/src/api/waiters.ts @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState } from 'react'; +import { type UseQueryResult, useQuery } from 'react-query'; +import type { CurrentWaiterState, Waiter, WaiterCreate, WaiterLogEntry, WaiterUpdate } from '../types/models'; +import { API_URL, axiosInstance } from './common'; + +const waitersUrl = '/waiters'; + +const RECONNECT_BASE_DELAY_MS = 1_000; +const RECONNECT_MAX_DELAY_MS = 16_000; + +// Logout + +export const logoutWaiter = async (): Promise<{ message: string }> => { + return axiosInstance.post<{ message: string }>(`${waitersUrl}/logout`).then((res) => res.data); +}; + +// CRUD operations + +export const getWaiters = async (): Promise => { + return axiosInstance.get(waitersUrl).then((res) => res.data); +}; + +export const useWaiters = (): UseQueryResult => { + return useQuery('waiters', getWaiters); +}; + +export const createWaiter = async (data: WaiterCreate): Promise => { + return axiosInstance.post(waitersUrl, data).then((res) => res.data); +}; + +export const updateWaiter = async (nfcId: string, data: WaiterUpdate): Promise => { + return axiosInstance.put(`${waitersUrl}/${nfcId}`, data).then((res) => res.data); +}; + +export const deleteWaiter = async (nfcId: string): Promise<{ message: string }> => { + return axiosInstance.delete<{ message: string }>(`${waitersUrl}/${nfcId}`).then((res) => res.data); +}; + +// Logs + +export const getWaiterLogs = async (): Promise => { + return axiosInstance.get(`${waitersUrl}/logs`).then((res) => res.data); +}; + +export const useWaiterLogs = (): UseQueryResult => { + return useQuery('waiterLogs', getWaiterLogs); +}; + +// Current waiter state + +export const getCurrentWaiter = async (): Promise => { + return axiosInstance.get(`${waitersUrl}/current`).then((res) => res.data); +}; + +export const useCurrentWaiter = (enabled: boolean): UseQueryResult => { + return useQuery('currentWaiter', getCurrentWaiter, { + enabled, + staleTime: 5_000, + refetchOnWindowFocus: true, + }); +}; + +// WebSocket for real-time waiter state + +export const useWaiterWebSocket = (enabled: boolean) => { + const [waiter, setWaiter] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const prevStateRef = useRef(null); + + useEffect(() => { + if (!enabled) { + setWaiter(null); + setIsConnected(false); + prevStateRef.current = null; + return undefined; + } + + let disposed = false; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType | null = null; + let ws: WebSocket | null = null; + + const connect = () => { + if (disposed) return; + const wsUrl = `${API_URL.replace(/^http/, 'ws')}/waiters/ws/current`; + ws = new WebSocket(wsUrl); + let wasConnected = false; + + ws.onopen = () => { + console.log('Waiter WebSocket connected'); + wasConnected = true; + reconnectAttempt = 0; + setIsConnected(true); + }; + + ws.onmessage = (event) => { + try { + const data: CurrentWaiterState = JSON.parse(event.data); + // Use serialized string to detect any change (nfc_id, waiter) + const stateKey = JSON.stringify(data); + if (stateKey !== prevStateRef.current) { + prevStateRef.current = stateKey; + setWaiter(data); + } + } catch (error) { + console.error('Error parsing waiter WebSocket message:', error); + } + }; + + ws.onerror = (error) => { + if (wasConnected) { + console.error('Waiter WebSocket error:', error); + } + }; + + ws.onclose = () => { + if (wasConnected) { + console.log('Waiter WebSocket closed'); + } + setIsConnected(false); + // Schedule reconnection with exponential backoff + if (!disposed) { + const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttempt, RECONNECT_MAX_DELAY_MS); + reconnectAttempt += 1; + console.log(`Waiter WebSocket reconnecting in ${delay}ms (attempt ${reconnectAttempt})`); + reconnectTimer = setTimeout(connect, delay); + } + }; + }; + + connect(); + + return () => { + disposed = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + ws?.close(); + }; + }, [enabled]); + + return { waiter, isConnected }; +}; diff --git a/web_client/src/components/bottle/BottleComponent.tsx b/web_client/src/components/bottle/BottleComponent.tsx index f86ce37e..10df8638 100644 --- a/web_client/src/components/bottle/BottleComponent.tsx +++ b/web_client/src/components/bottle/BottleComponent.tsx @@ -1,10 +1,10 @@ import type React from 'react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import Modal from 'react-modal'; import { updateBottle } from '../../api/bottles'; import type { Bottle, Ingredient } from '../../types/models'; import { errorToast, executeAndShow } from '../../utils'; -import Modal from 'react-modal'; import Button from '../common/Button'; import CloseButton from '../common/CloseButton'; import DropDown from '../common/DropDown'; diff --git a/web_client/src/components/cocktail/CocktailList.tsx b/web_client/src/components/cocktail/CocktailList.tsx index 9e334bbc..e7d44841 100644 --- a/web_client/src/components/cocktail/CocktailList.tsx +++ b/web_client/src/components/cocktail/CocktailList.tsx @@ -9,12 +9,14 @@ import { API_URL } from '../../api/common'; import { usePaymentWebSocket } from '../../api/payment'; import { useConfig } from '../../providers/ConfigProvider'; import { useRestrictedMode } from '../../providers/RestrictedModeProvider'; +import { useWaiter } from '../../providers/WaiterProvider'; import type { Cocktail } from '../../types/models'; import ErrorComponent from '../common/ErrorComponent'; import LoadingData from '../common/LoadingData'; import LockScreen from '../common/LockScreen'; import SearchBar from '../common/SearchBar'; import UserDisplay from '../common/UserDisplay'; +import WaiterDisplay from '../common/WaiterDisplay'; import CocktailSelection from './CocktailSelection'; import RandomCocktailSelection from './RandomCocktailSelection'; import SingleIngredientSelection from './SingleIngredientSelection'; @@ -37,6 +39,9 @@ const CocktailList: React.FC = () => { isConnected, } = usePaymentWebSocket(config.PAYMENT_TYPE === 'CocktailBerry'); + // Use waiter state from provider (single shared WebSocket + HTTP fallback) + const { waiterState: effectiveWaiter, isLoading: isCurrentWaiterLoading } = useWaiter(); + if (isLoading) return ; if (error) return ; @@ -51,6 +56,17 @@ const CocktailList: React.FC = () => { return ; } + // Show lock screen if waiter mode is active but no registered waiter is logged in + if (config.WAITER_MODE && selectedCocktail === null) { + if (isCurrentWaiterLoading && effectiveWaiter === null) { + return null; + } + + if (!effectiveWaiter?.waiter) { + return ; + } + } + const handleCloseModal = () => { setSelectedCocktail(null); }; @@ -92,7 +108,8 @@ const CocktailList: React.FC = () => {
{config.PAYMENT_TYPE === 'CocktailBerry' && } -
+ {config.WAITER_MODE && } +
- {Icon && } + {Icon && } {label} ); diff --git a/web_client/src/components/common/InfoScreen/index.stories.tsx b/web_client/src/components/common/InfoScreen/index.stories.tsx index 50923217..4740b589 100644 --- a/web_client/src/components/common/InfoScreen/index.stories.tsx +++ b/web_client/src/components/common/InfoScreen/index.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { FaCog, FaExclamationTriangle, FaSpinner } from 'react-icons/fa'; import { MdLock } from 'react-icons/md'; -import InfoScreen from '.'; import Button from '../Button'; +import InfoScreen from '.'; const meta: Meta = { title: 'Feedback/InfoScreen', diff --git a/web_client/src/components/common/ItemCard/index.stories.tsx b/web_client/src/components/common/ItemCard/index.stories.tsx index 43a643bf..af7f0c3e 100644 --- a/web_client/src/components/common/ItemCard/index.stories.tsx +++ b/web_client/src/components/common/ItemCard/index.stories.tsx @@ -1,9 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; import { FaCheck, FaTrashAlt } from 'react-icons/fa'; - -import ItemCard from '.'; +import { fn } from 'storybook/test'; import Button from '../Button'; +import ItemCard from '.'; const meta: Meta = { title: 'Elements/ItemCard', diff --git a/web_client/src/components/common/ItemCard/index.tsx b/web_client/src/components/common/ItemCard/index.tsx index 49472452..d78fd107 100644 --- a/web_client/src/components/common/ItemCard/index.tsx +++ b/web_client/src/components/common/ItemCard/index.tsx @@ -17,7 +17,7 @@ const ItemCard = ({ title, subtitle, description, highlighted = false, actions,

{title}

{subtitle && {subtitle}}
- {actions &&
{actions}
} + {actions &&
{actions}
}
{description &&

{description}

} {children} diff --git a/web_client/src/components/common/MinMaxInput/index.stories.tsx b/web_client/src/components/common/MinMaxInput/index.stories.tsx index 88a92ef4..f24de72c 100644 --- a/web_client/src/components/common/MinMaxInput/index.stories.tsx +++ b/web_client/src/components/common/MinMaxInput/index.stories.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; import MinMaxInput from '.'; diff --git a/web_client/src/components/common/ProtectedRoute.tsx b/web_client/src/components/common/ProtectedRoute.tsx index 1ca17eac..eb7265ca 100644 --- a/web_client/src/components/common/ProtectedRoute.tsx +++ b/web_client/src/components/common/ProtectedRoute.tsx @@ -2,6 +2,8 @@ import type React from 'react'; import { validateMakerPassword, validateMasterPassword } from '../../api/options'; import { useAuth } from '../../providers/AuthProvider'; import { useConfig } from '../../providers/ConfigProvider'; +import { useWaiter } from '../../providers/WaiterProvider'; +import type { Waiter } from '../../types/models'; import PasswordPage from './PasswordPage'; interface ProtectedRouteProps { @@ -31,14 +33,36 @@ interface MakerPasswordProtectedProps { tabNumber: number; } +const waiterHasPermissionForTab = (waiter: Waiter | null | undefined, tabNumber: number): boolean => { + if (!waiter) { + return false; + } + + const permissionByTab: Record = { + 0: waiter.permissions.maker, + 1: waiter.permissions.ingredients, + 2: waiter.permissions.recipes, + 3: waiter.permissions.bottles, + }; + return permissionByTab[tabNumber] ?? false; +}; + export const MakerPasswordProtected: React.FC = ({ children, tabNumber }) => { const { config } = useConfig(); const isProtected = config.UI_LOCKED_TABS[tabNumber]; const { makerAuthenticated, setMakerAuthenticated, setMakerPassword } = useAuth(); const hasPassword = config.UI_MAKER_PASSWORD; + const { waiterState, isLoading: isWaiterLoading } = useWaiter(); + const shouldCheckWaiter = Boolean(config.WAITER_MODE && hasPassword && isProtected && !makerAuthenticated); + const waiterCanBypass = shouldCheckWaiter && waiterHasPermissionForTab(waiterState?.waiter, tabNumber); + + if (shouldCheckWaiter && isWaiterLoading && !waiterState) { + return null; + } + return ( { setMakerAuthenticated(true); setMakerPassword(password); diff --git a/web_client/src/components/common/SearchBar/index.tsx b/web_client/src/components/common/SearchBar/index.tsx index e79fc60f..0313eabc 100644 --- a/web_client/src/components/common/SearchBar/index.tsx +++ b/web_client/src/components/common/SearchBar/index.tsx @@ -34,27 +34,27 @@ const SearchBar: React.FC = ({ }; return ( -
-
+
+
setSearch(e.target.value)} - className='h-10 input-base mr-1 w-full p-3 max-w-sm' + className='h-10 input-base mr-1 w-full p-3 max-w-sm pointer-events-auto' hidden={!showSearch} />
- {afterInput &&
{afterInput}
} + {afterInput &&
{afterInput}
}
+
+ ); +}; + +export default WaiterDisplay; diff --git a/web_client/src/components/common/WaiterStatistics/index.stories.tsx b/web_client/src/components/common/WaiterStatistics/index.stories.tsx new file mode 100644 index 00000000..bec6057c --- /dev/null +++ b/web_client/src/components/common/WaiterStatistics/index.stories.tsx @@ -0,0 +1,136 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { WaiterLogEntry } from '../../../types/models'; +import WaiterStatistics from '.'; + +const meta: Meta = { + title: 'Elements/WaiterStatistics', + component: WaiterStatistics, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +const multiWaiterMultiDateLogs: WaiterLogEntry[] = [ + // Alice - 2026-02-28 + { + id: 1, + timestamp: '2026-02-28 18:05', + waiter_name: 'Alice', + recipe_name: 'Cuba Libre', + volume: 300, + is_virgin: false, + }, + { + id: 2, + timestamp: '2026-02-28 18:22', + waiter_name: 'Alice', + recipe_name: 'Tequila Sunrise', + volume: 250, + is_virgin: false, + }, + { + id: 3, + timestamp: '2026-02-28 19:10', + waiter_name: 'Alice', + recipe_name: 'Cuba Libre', + volume: 300, + is_virgin: true, + }, + // Alice - 2026-03-01 + { id: 4, timestamp: '2026-03-01 20:00', waiter_name: 'Alice', recipe_name: 'Mojito', volume: 350, is_virgin: false }, + { + id: 5, + timestamp: '2026-03-01 20:45', + waiter_name: 'Alice', + recipe_name: 'Piña Colada', + volume: 400, + is_virgin: false, + }, + // Bob - 2026-02-28 + { id: 6, timestamp: '2026-02-28 18:30', waiter_name: 'Bob', recipe_name: 'Mojito', volume: 350, is_virgin: false }, + { id: 7, timestamp: '2026-02-28 19:00', waiter_name: 'Bob', recipe_name: 'Mojito', volume: 350, is_virgin: true }, + // Bob - 2026-03-01 + { + id: 8, + timestamp: '2026-03-01 20:15', + waiter_name: 'Bob', + recipe_name: 'Cuba Libre', + volume: 300, + is_virgin: false, + }, + { + id: 9, + timestamp: '2026-03-01 20:30', + waiter_name: 'Bob', + recipe_name: 'Tequila Sunrise', + volume: 250, + is_virgin: false, + }, + { + id: 10, + timestamp: '2026-03-01 21:00', + waiter_name: 'Bob', + recipe_name: 'Piña Colada', + volume: 400, + is_virgin: false, + }, + { id: 11, timestamp: '2026-03-01 21:20', waiter_name: 'Bob', recipe_name: 'Mojito', volume: 350, is_virgin: true }, + // Charlie - 2026-03-01 + { + id: 12, + timestamp: '2026-03-01 20:10', + waiter_name: 'Charlie', + recipe_name: 'Cuba Libre', + volume: 300, + is_virgin: false, + }, +]; + +export const MultiWaiterMultiDate: Story = { + args: { + logs: multiWaiterMultiDateLogs, + }, +}; + +const singleWaiterLogs: WaiterLogEntry[] = [ + { + id: 1, + timestamp: '2026-03-01 19:00', + waiter_name: 'Alice', + recipe_name: 'Cuba Libre', + volume: 300, + is_virgin: false, + }, + { id: 2, timestamp: '2026-03-01 19:15', waiter_name: 'Alice', recipe_name: 'Mojito', volume: 350, is_virgin: true }, + { + id: 3, + timestamp: '2026-03-01 19:40', + waiter_name: 'Alice', + recipe_name: 'Tequila Sunrise', + volume: 250, + is_virgin: false, + }, +]; + +export const SingleWaiter: Story = { + args: { + logs: singleWaiterLogs, + }, +}; + +export const Empty: Story = { + args: { + logs: [], + }, +}; diff --git a/web_client/src/components/common/WaiterStatistics/index.tsx b/web_client/src/components/common/WaiterStatistics/index.tsx new file mode 100644 index 00000000..bb0e1d1d --- /dev/null +++ b/web_client/src/components/common/WaiterStatistics/index.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from 'react-i18next'; +import { MdNoDrinks } from 'react-icons/md'; +import { formatDate } from '../../../dateUtils'; +import type { WaiterLogEntry } from '../../../types/models'; +import Accordion from '../Accordion'; + +interface WaiterStatisticsProps { + logs: WaiterLogEntry[]; +} + +function groupLogsByDateAndWaiter(logs: WaiterLogEntry[]): Record> { + const grouped: Record> = {}; + for (const log of logs) { + const date = log.timestamp.split(' ')[0]; + if (!grouped[date]) { + grouped[date] = {}; + } + if (!grouped[date][log.waiter_name]) { + grouped[date][log.waiter_name] = []; + } + grouped[date][log.waiter_name].push(log); + } + return grouped; +} + +const WaiterStatistics: React.FC = ({ logs }) => { + const { t, i18n } = useTranslation(); + + if (logs.length === 0) { + return

{t('waiter.noLogs')}

; + } + + const grouped = groupLogsByDateAndWaiter(logs); + + return ( +
+ {Object.entries(grouped).map(([date, waiterGroups]) => { + const totalCocktails = Object.values(waiterGroups).flat().length; + const totalVolume = Object.values(waiterGroups) + .flat() + .reduce((sum, log) => sum + log.volume, 0); + + return ( + + {formatDate(date, i18n.language)} + + {t('waiter.statsSummary', { count: totalCocktails, volume: totalVolume })} + +
+ } + > + {Object.entries(waiterGroups).map(([waiterName, waiterLogs]) => { + const waiterVolume = waiterLogs.reduce((sum, log) => sum + log.volume, 0); + return ( + + {waiterName} + + {t('waiter.statsSummary', { count: waiterLogs.length, volume: waiterVolume })} + +
+ } + > +
+ {waiterLogs.map((log) => ( +
+
+ {log.timestamp.split(' ')[1]} + {log.recipe_name} + {log.is_virgin && } +
+ {log.volume} ml +
+ ))} +
+ + ); + })} + + ); + })} +
+ ); +}; + +export default WaiterStatistics; diff --git a/web_client/src/components/ingredient/IngredientList.tsx b/web_client/src/components/ingredient/IngredientList.tsx index 461503f1..95ef965a 100644 --- a/web_client/src/components/ingredient/IngredientList.tsx +++ b/web_client/src/components/ingredient/IngredientList.tsx @@ -3,11 +3,11 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FaPlus } from 'react-icons/fa'; import { IoHandLeft } from 'react-icons/io5'; +import Modal from 'react-modal'; import { deleteIngredient, postIngredient, updateIngredient, useIngredients } from '../../api/ingredients'; import { useRestrictedMode } from '../../providers/RestrictedModeProvider'; import type { Ingredient, IngredientInput } from '../../types/models'; import { confirmAndExecute, executeAndShow } from '../../utils'; -import Modal from 'react-modal'; import CheckBox from '../common/CheckBox'; import CloseButton from '../common/CloseButton'; import ErrorComponent from '../common/ErrorComponent'; diff --git a/web_client/src/components/options/OptionWindow.tsx b/web_client/src/components/options/OptionWindow.tsx index 0258bd3c..61bc3aae 100644 --- a/web_client/src/components/options/OptionWindow.tsx +++ b/web_client/src/components/options/OptionWindow.tsx @@ -2,7 +2,15 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AiOutlineLoading3Quarters } from 'react-icons/ai'; import { BsBootstrapReboot, BsInfoCircleFill } from 'react-icons/bs'; -import { FaCocktail, FaCreditCard, FaExclamationTriangle, FaInfoCircle, FaNewspaper, FaRegClock } from 'react-icons/fa'; +import { + FaCocktail, + FaCreditCard, + FaExclamationTriangle, + FaInfoCircle, + FaNewspaper, + FaRegClock, + FaUserTie, +} from 'react-icons/fa'; import { FaCalculator, FaChartSimple, FaDownload, FaGear, FaScaleUnbalanced, FaUpload, FaWifi } from 'react-icons/fa6'; import { GrUpdate } from 'react-icons/gr'; import { MdEventNote, MdOutlineSignalWifiStatusbarConnectedNoInternet4, MdWaterDrop } from 'react-icons/md'; @@ -197,6 +205,9 @@ const OptionWindow = () => { {config.PAYMENT_TYPE === 'SumUp' && ( navigate('sumup')} /> )} + {config.WAITER_MODE && ( + navigate('waiters')} /> + )} setIsAboutModalOpen(true)} />
diff --git a/web_client/src/components/options/WaiterWindow.tsx b/web_client/src/components/options/WaiterWindow.tsx new file mode 100644 index 00000000..ede677d3 --- /dev/null +++ b/web_client/src/components/options/WaiterWindow.tsx @@ -0,0 +1,271 @@ +import type React from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaEdit, FaPlus, FaTrashAlt } from 'react-icons/fa'; +import { + createWaiter, + deleteWaiter, + updateWaiter, + useWaiterLogs, + useWaiters, + useWaiterWebSocket, +} from '../../api/waiters'; +import { useConfig } from '../../providers/ConfigProvider'; +import type { Waiter, WaiterPermissions } from '../../types/models'; +import { confirmAndExecute, executeAndShow } from '../../utils'; +import Button from '../common/Button'; +import CheckBox from '../common/CheckBox'; +import ErrorComponent from '../common/ErrorComponent'; +import ItemCard from '../common/ItemCard'; +import LoadingData from '../common/LoadingData'; +import TabSelector from '../common/TabSelector'; +import TextHeader from '../common/TextHeader'; +import TextInput from '../common/TextInput'; +import TileButton from '../common/TileButton'; +import WaiterStatistics from '../common/WaiterStatistics'; + +const TABS = ['Management', 'Statistics'] as const; +type WaiterTab = (typeof TABS)[number]; + +const WaiterWindow: React.FC = () => { + const [selectedTab, setSelectedTab] = useState('Management'); + const { t } = useTranslation(); + + return ( +
+ + setSelectedTab(tab as WaiterTab)} /> +
+ {selectedTab === 'Management' && } + {selectedTab === 'Statistics' && } +
+ ); +}; + +const DEFAULT_PERMISSIONS: WaiterPermissions = { + maker: true, + ingredients: false, + recipes: false, + bottles: false, +}; +const PERMISSION_KEYS = ['maker', 'ingredients', 'recipes', 'bottles'] as const; + +const ManagementTab: React.FC = () => { + const { config } = useConfig(); + const { data: waiters, isLoading, error, refetch } = useWaiters(); + const { waiter: currentWaiter } = useWaiterWebSocket(config.WAITER_MODE); + const [nfcId, setNfcId] = useState(''); + const [name, setName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [createPermissions, setCreatePermissions] = useState({ ...DEFAULT_PERMISSIONS }); + const [editingWaiter, setEditingWaiter] = useState(null); + const [editName, setEditName] = useState(''); + const [editPermissions, setEditPermissions] = useState({ ...DEFAULT_PERMISSIONS }); + const { t } = useTranslation(); + + const handleCreate = async () => { + if (!nfcId.trim() || !name.trim()) return; + setIsCreating(true); + const success = await executeAndShow(async () => { + const waiter = await createWaiter({ nfc_id: nfcId.trim(), name: name.trim(), permissions: createPermissions }); + return { message: t('waiter.created', { name: waiter.name }) }; + }); + setIsCreating(false); + if (success) { + setNfcId(''); + setName(''); + setCreatePermissions({ ...DEFAULT_PERMISSIONS }); + refetch(); + } + }; + + const handleDelete = async (waiter: Waiter) => { + const success = await confirmAndExecute(t('waiter.confirmDelete', { name: waiter.name }), () => + deleteWaiter(waiter.nfc_id), + ); + if (success) { + refetch(); + } + }; + + const handleEdit = (waiter: Waiter) => { + setEditingWaiter(waiter.nfc_id); + setEditName(waiter.name); + setEditPermissions({ ...waiter.permissions }); + }; + + const handleSaveEdit = async (nfcIdToEdit: string) => { + if (!editName.trim()) return; + const success = await executeAndShow(async () => { + await updateWaiter(nfcIdToEdit, { name: editName.trim(), permissions: editPermissions }); + return { message: t('waiter.updated') }; + }); + if (success) { + setEditingWaiter(null); + setEditName(''); + refetch(); + } + }; + + const handleCancelEdit = () => { + setEditingWaiter(null); + setEditName(''); + }; + + // Pre-fill NFC ID from current scan if available + const handleUseScannedNfc = () => { + if (currentWaiter?.nfc_id) { + setNfcId(currentWaiter.nfc_id); + } + }; + + if (isLoading) + return ( +
+ +
+ ); + if (error) return ; + + const isFormValid = nfcId.trim() !== '' && name.trim() !== ''; + + return ( + <> + {currentWaiter?.nfc_id && ( +
+ } + /> +
+ )} + +
{ + e.preventDefault(); + handleCreate(); + }} + className='border border-primary p-4 rounded-xl mb-4' + > +

{t('waiter.registerWaiter')}

+
+
+ +
+ +
+

{t('waiter.permissionsLabel')}

+
+ {PERMISSION_KEYS.map((key) => ( + setCreatePermissions((prev) => ({ ...prev, [key]: val }))} + /> + ))} +
+ + + +
+ {waiters?.length === 0 &&

{t('waiter.noWaiters')}

} + {waiters?.map((waiter) => { + const isActive = currentWaiter?.nfc_id === waiter.nfc_id; + const isEditing = editingWaiter === waiter.nfc_id; + + if (isEditing) { + return ( +
+
+ +
+

{t('waiter.permissionsLabel')}

+
+ {PERMISSION_KEYS.map((key) => ( + setEditPermissions((prev) => ({ ...prev, [key]: val }))} + /> + ))} +
+
+ ); + } + + const activePermissions = PERMISSION_KEYS.filter((key) => waiter.permissions[key]); + + return ( + +
+ + ); +}; + +const StatisticsTab: React.FC = () => { + const { data: logs, isLoading, error } = useWaiterLogs(); + + if (isLoading) + return ( +
+ +
+ ); + if (error) return ; + + return ; +}; + +export default WaiterWindow; diff --git a/web_client/src/components/recipe/RecipeList.tsx b/web_client/src/components/recipe/RecipeList.tsx index 643f8f83..926e8252 100644 --- a/web_client/src/components/recipe/RecipeList.tsx +++ b/web_client/src/components/recipe/RecipeList.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FaPlus, FaTrashAlt, FaUpload } from 'react-icons/fa'; import { MdNoDrinks } from 'react-icons/md'; +import Modal from 'react-modal'; import { deleteCocktail, deleteCocktailImage, @@ -18,9 +19,8 @@ import type { Cocktail, CocktailInput } from '../../types/models'; import { confirmAndExecute, errorToast, executeAndShow } from '../../utils'; import Button from '../common/Button'; import CheckBox from '../common/CheckBox'; -import DropDown from '../common/DropDown'; -import Modal from 'react-modal'; import CloseButton from '../common/CloseButton'; +import DropDown from '../common/DropDown'; import ErrorComponent from '../common/ErrorComponent'; import LoadingData from '../common/LoadingData'; import ModalActions from '../common/ModalActions'; diff --git a/web_client/src/dateUtils.ts b/web_client/src/dateUtils.ts new file mode 100644 index 00000000..ded8c8d4 --- /dev/null +++ b/web_client/src/dateUtils.ts @@ -0,0 +1,12 @@ +/** + * Format an ISO date string (YYYY-MM-DD) according to the given locale. + */ +export function formatDate(dateStr: string, locale: string): string { + const [year, month, day] = dateStr.split('-').map(Number); + return new Date(year, month - 1, day).toLocaleDateString(locale, { + weekday: 'short', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +} diff --git a/web_client/src/locales/de/translation.json b/web_client/src/locales/de/translation.json index 6fd4119e..107476cf 100644 --- a/web_client/src/locales/de/translation.json +++ b/web_client/src/locales/de/translation.json @@ -4,6 +4,7 @@ "apply": "Anwenden", "save": "Speichern", "delete": "Löschen", + "edit": "Bearbeiten", "submit": "Absenden", "add": "Hinzufügen", "back": "Zurück", @@ -112,7 +113,8 @@ "updateCocktailBerry": "CocktailBerry Software aktualisieren", "updateSystem": "System aktualisieren", "updateTheSystem": "Möchtest du das System aktualisieren?", - "wifi": "WLAN" + "wifi": "WLAN", + "waiters": "Servicepersonal" }, "news": { "noNews": "Du bist auf dem neusten Stand!", @@ -232,5 +234,33 @@ "use": "Verwenden", "deleteReader": "Möchtest du den Leser '{{name}}' löschen?", "readerCreated": "Leser '{{name}}' wurde erfolgreich erstellt" + }, + "waiter": { + "title": "Servicepersonal Verwaltung", + "noWaiter": "Kein Servicepersonal", + "unknown": "Unbekannt ({{nfcId}})", + "lockTitle": "Servicepersonal Scan erforderlich", + "lockMessage": "Bitte scanne deinen NFC-Tag, um dich als Servicepersonal anzumelden.", + "lastScanned": "Zuletzt gescannte NFC-ID", + "registerWaiter": "Neues Servicepersonal registrieren", + "nfcIdPlaceholder": "NFC-ID", + "namePlaceholder": "Servicepersonal Name", + "useScanned": "Nutzen", + "creating": "Wird erstellt...", + "created": "Servicepersonal '{{name}}' wurde erfolgreich registriert", + "updated": "Servicepersonal erfolgreich aktualisiert", + "confirmDelete": "Möchtest du den Servicepersonal '{{name}}' löschen? Bestehende Protokolle bleiben erhalten aber die Verknüpfungen entfernt.", + "active": "Derzeit aktiv", + "noWaiters": "Kein Servicepersonal registriert Scanne einen NFC-Tag und registriere einen Servicepersonal, um zu starten.", + "noLogs": "Noch keine Cocktail-Protokolle aufgezeichnet.", + "statsSummary": "{{count}} Cocktails, {{volume}} ml insgesamt", + "permissionsLabel": "Berechtigungen", + "permissions": { + "maker": "Maker", + "ingredients": "Zutaten", + "recipes": "Rezepte", + "bottles": "Flaschen" + }, + "logout": "Abmelden" } } diff --git a/web_client/src/locales/en/translation.json b/web_client/src/locales/en/translation.json index 6ecf513b..ec5323a3 100644 --- a/web_client/src/locales/en/translation.json +++ b/web_client/src/locales/en/translation.json @@ -4,6 +4,7 @@ "apply": "Apply", "save": "Save", "delete": "Delete", + "edit": "Edit", "submit": "Submit", "add": "Add", "back": "Back", @@ -112,7 +113,8 @@ "updateCocktailBerry": "Update CocktailBerry Software", "updateSystem": "Update System", "updateTheSystem": "Do you want to update the System?", - "wifi": "WiFi" + "wifi": "WiFi", + "waiters": "Waiters" }, "news": { "noNews": "You are up to date!", @@ -232,5 +234,33 @@ "use": "Use", "deleteReader": "Do you want to delete the reader '{{name}}'", "readerCreated": "Reader '{{name}}' was created successfully" + }, + "waiter": { + "title": "Service Personnel Management", + "noWaiter": "No Service Personnel", + "unknown": "Unknown ({{nfcId}})", + "lockTitle": "Service Personnel Scan Required", + "lockMessage": "Please scan your NFC tag to log in as a service personnel.", + "lastScanned": "Last scanned NFC ID", + "registerWaiter": "Register New Service Personnel", + "nfcIdPlaceholder": "NFC ID", + "namePlaceholder": "Service Personnel Name", + "useScanned": "Use", + "creating": "Creating...", + "created": "Service Personnel '{{name}}' was registered successfully", + "updated": "Service Personnel updated successfully", + "confirmDelete": "Do you want to delete the service personnel '{{name}}'? Existing logs will be preserved but associations are lost.", + "active": "Currently active", + "noWaiters": "No waiters registered. Scan an NFC tag and register a service personnel to get started.", + "noLogs": "No cocktail logs recorded yet.", + "statsSummary": "{{count}} cocktails, {{volume}} ml total", + "permissionsLabel": "Permissions", + "permissions": { + "maker": "Maker", + "ingredients": "Ingredients", + "recipes": "Recipes", + "bottles": "Bottles" + }, + "logout": "Logout" } } diff --git a/web_client/src/main.tsx b/web_client/src/main.tsx index eb3efd96..b42096af 100644 --- a/web_client/src/main.tsx +++ b/web_client/src/main.tsx @@ -10,6 +10,7 @@ import { AuthProvider } from './providers/AuthProvider.tsx'; import { ConfigProvider } from './providers/ConfigProvider.tsx'; import { CustomColorProvider } from './providers/CustomColorProvider.tsx'; import { RestrictedModeProvider } from './providers/RestrictedModeProvider.tsx'; +import { WaiterProvider } from './providers/WaiterProvider.tsx'; const queryClient = new QueryClient(); @@ -26,9 +27,11 @@ createRoot(root).render( - - - + + + + + diff --git a/web_client/src/providers/WaiterProvider.tsx b/web_client/src/providers/WaiterProvider.tsx new file mode 100644 index 00000000..103c5daa --- /dev/null +++ b/web_client/src/providers/WaiterProvider.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; +import { createContext, useContext, useMemo } from 'react'; +import { useCurrentWaiter, useWaiterWebSocket } from '../api/waiters'; +import type { CurrentWaiterState } from '../types/models'; +import { useConfig } from './ConfigProvider'; + +interface WaiterContextType { + /** Merged WebSocket + HTTP waiter state, always the freshest available */ + waiterState: CurrentWaiterState | null; + /** Whether the WebSocket connection is currently open */ + isConnected: boolean; + /** Whether the initial HTTP fetch is still loading */ + isLoading: boolean; +} + +const WaiterContext = createContext(undefined); + +export const WaiterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { config } = useConfig(); + const enabled = Boolean(config.WAITER_MODE); + + // Single WebSocket connection for the entire app + const { waiter: wsState, isConnected } = useWaiterWebSocket(enabled); + // HTTP fallback for initial load / when WS hasn't connected yet + const { data: httpWaiter, isLoading } = useCurrentWaiter(enabled); + + // WebSocket takes priority, HTTP is fallback + const waiterState: CurrentWaiterState | null = useMemo(() => { + if (wsState) return wsState; + if (httpWaiter) return { nfc_id: httpWaiter.nfc_id, waiter: httpWaiter }; + return null; + }, [wsState, httpWaiter]); + + const contextValue = useMemo( + () => ({ waiterState, isConnected, isLoading }), + [waiterState, isConnected, isLoading], + ); + + return {children}; +}; + +// eslint-disable-next-line react-refresh/only-export-components +export const useWaiter = () => { + const context = useContext(WaiterContext); + if (!context) { + throw new Error('useWaiter must be used within a WaiterProvider'); + } + return context; +}; diff --git a/web_client/src/types/models.ts b/web_client/src/types/models.ts index 15019db9..095d5575 100644 --- a/web_client/src/types/models.ts +++ b/web_client/src/types/models.ts @@ -73,6 +73,7 @@ export type PrepareResult = | 'NOT_ENOUGH_INGREDIENTS' | 'ADDON_ERROR' | 'WAITING_FOR_PAYMENT' + | 'NO_WAITER_LOGGED_IN' | 'UNDEFINED'; export interface UserAuth { @@ -181,6 +182,9 @@ export interface DefinedConfigData { PAYMENT_TIMEOUT_S: number; PAYMENT_AUTO_LOGOUT_TIME_S: number; PAYMENT_LOGOUT_AFTER_PREPARATION: boolean; + WAITER_MODE: boolean; + WAITER_LOGOUT_AFTER_COCKTAIL: boolean; + WAITER_AUTO_LOGOUT_S: number; CUSTOM_COLOR_PRIMARY: string; CUSTOM_COLOR_SECONDARY: string; CUSTOM_COLOR_NEUTRAL: string; @@ -337,3 +341,41 @@ export interface AboutInfo { project_name: string; version: string; } + +export interface WaiterPermissions { + maker: boolean; + ingredients: boolean; + recipes: boolean; + bottles: boolean; +} + +export interface Waiter { + nfc_id: string; + name: string; + permissions: WaiterPermissions; +} + +export interface WaiterCreate { + nfc_id: string; + name: string; + permissions?: WaiterPermissions; +} + +export interface WaiterUpdate { + name?: string; + permissions?: WaiterPermissions; +} + +export interface WaiterLogEntry { + id: number; + timestamp: string; + waiter_name: string; + recipe_name: string; + volume: number; + is_virgin: boolean; +} + +export interface CurrentWaiterState { + nfc_id: string | null; + waiter: Waiter | null; +} diff --git a/web_client/src/utils.tsx b/web_client/src/utils.tsx index 71285c3d..285907ff 100644 --- a/web_client/src/utils.tsx +++ b/web_client/src/utils.tsx @@ -104,7 +104,7 @@ const tabConfig: { [key: string]: string[] } = { UI: ['UI'], MAKER: ['MAKER'], HARDWARE: ['PUMP', 'LED', 'RFID', 'I2C'], - SOFTWARE: ['MICROSERVICE', 'TEAM'], + SOFTWARE: ['MICROSERVICE', 'TEAM', 'WAITER'], PAYMENT: ['PAYMENT'], };