merge: rev04 Phase 1 — stations and per-tablet identity (feature/rev04-phase1-stations into V2.0.0)

This commit is contained in:
2026-04-25 11:54:51 +02:00
29 changed files with 2050 additions and 15 deletions
+4
View File
@@ -20,6 +20,10 @@ CLIENT_HOST=0.0.0.0
CLIENT_PORT=5000 CLIENT_PORT=5000
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
API_SERVER_URL=http://localhost:8000 API_SERVER_URL=http://localhost:8000
# Station code this client container belongs to (e.g. ST-001).
# Each physical tablet/PC deployment must set this unique per-station value.
# Leave empty only for a single-station all-in-one demo using ST-DEFAULT.
STATION_CODE=ST-DEFAULT
# --- File Storage --- # --- File Storage ---
UPLOAD_DIR=server/uploads UPLOAD_DIR=server/uploads
+1 -3
View File
@@ -52,9 +52,7 @@ node_modules/
# Flask-Babel compiled # Flask-Babel compiled
*.mo *.mo
# Alembic # Alembic migrations are versioned in git (only __pycache__ is ignored, covered globally)
server/migrations/versions/*.py
!server/migrations/versions/.gitkeep
# Logs # Logs
*.log *.log
+96
View File
@@ -43,6 +43,31 @@ def user_list():
return render_template("admin/users.html", users=users) return render_template("admin/users.html", users=users)
@admin_bp.route("/stations")
@login_required
@admin_required
def station_list():
"""Station management page."""
resp = api_client.get("/api/stations")
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento delle stazioni: %(error)s", error=resp.get("detail", "")), "error")
stations = []
elif isinstance(resp, list):
stations = resp
else:
stations = []
recipes_resp = api_client.get("/api/recipes")
if isinstance(recipes_resp, list):
all_recipes = recipes_resp
elif isinstance(recipes_resp, dict) and isinstance(recipes_resp.get("items"), list):
all_recipes = recipes_resp["items"]
else:
all_recipes = []
return render_template("admin/stations.html", stations=stations, all_recipes=all_recipes)
# ============================================================================ # ============================================================================
# API PROXY AJAX (JSON) # API PROXY AJAX (JSON)
# ============================================================================ # ============================================================================
@@ -102,3 +127,74 @@ def api_toggle_active(user_id: int):
return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200 return jsonify(resp), 200
# --- Stations ---
@admin_bp.route("/api/stations", methods=["POST"])
@login_required
@admin_required
def api_create_station():
"""Proxy: Create a new station."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/stations", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/stations/<int:station_id>", methods=["PUT"])
@login_required
@admin_required
def api_update_station(station_id: int):
"""Proxy: Update a station."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/stations/{station_id}", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/stations/<int:station_id>", methods=["DELETE"])
@login_required
@admin_required
def api_delete_station(station_id: int):
"""Proxy: Delete a station."""
resp = api_client.delete(f"/api/stations/{station_id}")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify({"deleted": True}), 200
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["GET"])
@login_required
@admin_required
def api_list_station_recipes(station_id: int):
"""Proxy: List recipes assigned to a station."""
resp = api_client.get(f"/api/stations/{station_id}/recipes")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["POST"])
@login_required
@admin_required
def api_assign_recipe(station_id: int):
"""Proxy: Assign a recipe to a station."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/stations/{station_id}/recipes", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/stations/<int:station_id>/recipes/<int:recipe_id>", methods=["DELETE"])
@login_required
@admin_required
def api_unassign_recipe(station_id: int, recipe_id: int):
"""Proxy: Remove a recipe assignment from a station."""
resp = api_client.delete(f"/api/stations/{station_id}/recipes/{recipe_id}")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify({"deleted": True}), 200
+13 -2
View File
@@ -22,8 +22,18 @@ measure_bp = Blueprint("measure", __name__)
@role_required("MeasurementTec") @role_required("MeasurementTec")
def select_recipe(): def select_recipe():
"""Recipe selection page with search and barcode support.""" """Recipe selection page with search and barcode support."""
# Load recipes from API # Fail-fast if STATION_CODE is not configured
resp = api_client.get("/api/recipes", params={"per_page": 100}) if not Config.STATION_CODE:
return render_template("errors/station_not_configured.html"), 503
# Load recipes filtered by station
try:
resp = api_client.get_station_recipes(Config.STATION_CODE)
except Exception as e:
return render_template(
"errors/station_not_configured.html", error=str(e),
), 502
if isinstance(resp, dict) and resp.get("error"): if isinstance(resp, dict) and resp.get("error"):
flash( flash(
_("Errore nel caricamento delle ricette: %(detail)s", _("Errore nel caricamento delle ricette: %(detail)s",
@@ -43,6 +53,7 @@ def select_recipe():
return render_template( return render_template(
"measure/select_recipe.html", "measure/select_recipe.html",
recipes=recipes, recipes=recipes,
station_code=Config.STATION_CODE,
auto_recipe_code=auto_recipe_code, auto_recipe_code=auto_recipe_code,
auto_lot=auto_lot, auto_lot=auto_lot,
auto_serial=auto_serial, auto_serial=auto_serial,
+4
View File
@@ -22,6 +22,10 @@ class Config:
PERMANENT_SESSION_LIFETIME = 28800 # 8 hours PERMANENT_SESSION_LIFETIME = 28800 # 8 hours
WTF_CSRF_TIME_LIMIT = 3600 # 1 hour WTF_CSRF_TIME_LIMIT = 3600 # 1 hour
# Station identity: each deployed client container sets this to the station
# code it belongs to. Empty/None means "not configured".
STATION_CODE: str | None = os.getenv("STATION_CODE") or None
# Babel i18n # Babel i18n
BABEL_DEFAULT_LOCALE = "it" BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome" BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
+6
View File
@@ -131,5 +131,11 @@ class APIClient:
"detail": f"Errore di connessione al server: {str(e)}" "detail": f"Errore di connessione al server: {str(e)}"
} }
# --- Domain helpers ---
def get_station_recipes(self, station_code: str) -> dict[str, Any]:
"""Return the list of active recipes assigned to the given station."""
return self.get(f"/api/stations/by-code/{station_code}/recipes")
api_client = APIClient() api_client = APIClient()
+568
View File
@@ -0,0 +1,568 @@
{% extends "base.html" %}
{% block title %}{{ _('Gestione Stazioni') }} - TieMeasureFlow{% endblock %}
{% block content %}
<script>
window.__stations = {{ stations|tojson }};
window.__allRecipes = {{ all_recipes|tojson }};
</script>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
x-data="stationManagement(window.__stations, window.__allRecipes)">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Stazioni') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci stazioni di misurazione e relative ricette assegnate') }}</p>
</div>
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div class="relative w-full sm:w-80">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" x-model="search"
placeholder="{{ _('Cerca stazione...') }}"
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] placeholder-[var(--text-secondary)]
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
</div>
<button @click="openCreateModal()"
class="flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Nuova Stazione') }}
</button>
</div>
<!-- Stations Table -->
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]">
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Codice') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-color)]">
<template x-for="station in filteredStations" :key="station.id">
<tr class="hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer"
@click="openEditModal(station)">
<td class="px-4 py-3">
<span class="font-mono text-sm font-semibold text-[var(--text-primary)]" x-text="station.code"></span>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-primary)]" x-text="station.name"></td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden md:table-cell" x-text="station.location || '-'"></td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="station.active
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'"
x-text="station.active ? '{{ _('Attiva') }}' : '{{ _('Disattivata') }}'"></span>
</td>
<td class="px-4 py-3 text-right" @click.stop>
<div class="flex items-center justify-end gap-1">
<button @click="openAssignmentsModal(station)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
:title="'{{ _('Gestisci ricette') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</button>
<button @click="openEditModal(station)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
:title="'{{ _('Modifica') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click="confirmDelete(station)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
:title="'{{ _('Elimina') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="filteredStations.length === 0">
<div class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto text-[var(--text-secondary)] opacity-40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
<p class="mt-3 text-sm text-[var(--text-secondary)]">{{ _('Nessuna stazione trovata') }}</p>
</div>
</template>
</div>
<div class="mt-3 text-xs text-[var(--text-secondary)]">
<span x-text="filteredStations.length"></span> {{ _('stazioni') }}
</div>
<!-- Modal: Create / Edit Station -->
<div x-show="showModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeModal()">
<div class="absolute inset-0 bg-black/50" @click="closeModal()"></div>
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<h2 class="text-lg font-semibold text-[var(--text-primary)]"
x-text="isEditing ? '{{ _('Modifica Stazione') }}' : '{{ _('Nuova Stazione') }}'"></h2>
<button @click="closeModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Codice') }} <span class="text-red-500">*</span></label>
<input type="text" x-model="form.code"
:disabled="isEditing"
:class="isEditing ? 'opacity-50 cursor-not-allowed font-mono' : 'font-mono uppercase'"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="ST-001"
maxlength="100">
<template x-if="isEditing">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Il codice non può essere modificato') }}</p>
</template>
<template x-if="!isEditing">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Identificativo univoco usato dal client tramite STATION_CODE') }}</p>
</template>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nome') }} <span class="text-red-500">*</span></label>
<input type="text" x-model="form.name"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Nome descrittivo della stazione') }}"
maxlength="255">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Postazione') }}</label>
<input type="text" x-model="form.location"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Es. Reparto A - Linea 2') }}"
maxlength="255">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
<textarea x-model="form.notes" rows="3"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Note opzionali') }}"></textarea>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.active"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm font-medium text-[var(--text-primary)]">{{ _('Attiva') }}</span>
</label>
</div>
<template x-if="errorMsg">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
</div>
</template>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
<button @click="closeModal()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="saveStation()"
:disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
<span x-show="!saving" x-text="isEditing ? '{{ _('Salva Modifiche') }}' : '{{ _('Crea Stazione') }}'"></span>
<span x-show="saving" x-cloak>{{ _('Salvataggio...') }}</span>
</button>
</div>
</div>
</div>
<!-- Modal: Recipe Assignments -->
<div x-show="showAssignments" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeAssignmentsModal()">
<div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
<div x-show="showAssignments"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Ricette Assegnate') }}</h2>
<p class="text-xs text-[var(--text-secondary)] mt-0.5" x-text="assignmentStation ? assignmentStation.code + ' — ' + assignmentStation.name : ''"></p>
</div>
<button @click="closeAssignmentsModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="px-6 py-4 space-y-4">
<!-- Add recipe -->
<div class="flex items-end gap-2">
<div class="flex-1">
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Aggiungi ricetta') }}</label>
<select x-model="recipeToAdd"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="">{{ _('-- Seleziona ricetta --') }}</option>
<template x-for="r in unassignedRecipes" :key="r.id">
<option :value="r.id" x-text="r.code + ' — ' + r.name"></option>
</template>
</select>
</div>
<button @click="assignRecipe()"
:disabled="!recipeToAdd || saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
{{ _('Assegna') }}
</button>
</div>
<!-- Assigned list -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2">
{{ _('Ricette correntemente assegnate') }} (<span x-text="assignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in assignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div>
<span class="font-mono text-xs font-semibold text-[var(--text-primary)]" x-text="r.code"></span>
<span class="ml-2 text-sm text-[var(--text-secondary)]" x-text="r.name"></span>
</div>
<button @click="unassignRecipe(r.id)"
:disabled="saving"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
:title="'{{ _('Rimuovi') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<template x-if="assignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]">
{{ _('Nessuna ricetta assegnata') }}
</div>
</template>
</div>
</div>
<template x-if="errorMsg">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
</div>
</template>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
<button @click="closeAssignmentsModal()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Chiudi') }}
</button>
</div>
</div>
</div>
<!-- Confirm Delete Modal -->
<div x-show="showDeleteConfirm" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="showDeleteConfirm = false"></div>
<div x-show="showDeleteConfirm"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">{{ _('Conferma Eliminazione') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Sei sicuro di voler eliminare la stazione') }}
<strong x-text="deleteTarget?.code"></strong>?
<br><span class="text-xs text-red-600 dark:text-red-400">{{ _('Verranno rimosse anche tutte le assegnazioni di ricette.') }}</span>
</p>
<div class="flex justify-end gap-3">
<button @click="showDeleteConfirm = false"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="performDelete()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm bg-red-600 hover:bg-red-700 disabled:opacity-50 transition-colors">
{{ _('Elimina') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function stationManagement(initialStations, initialRecipes) {
return {
stations: initialStations || [],
allRecipes: initialRecipes || [],
search: '',
showModal: false,
showAssignments: false,
showDeleteConfirm: false,
isEditing: false,
editingId: null,
saving: false,
errorMsg: '',
deleteTarget: null,
assignmentStation: null,
assignedRecipes: [],
recipeToAdd: '',
form: {
code: '',
name: '',
location: '',
notes: '',
active: true,
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredStations() {
if (!this.search.trim()) return this.stations;
const q = this.search.toLowerCase();
return this.stations.filter(s =>
s.code.toLowerCase().includes(q) ||
s.name.toLowerCase().includes(q) ||
(s.location && s.location.toLowerCase().includes(q))
);
},
get unassignedRecipes() {
const assignedIds = new Set(this.assignedRecipes.map(r => r.id));
return this.allRecipes.filter(r => !assignedIds.has(r.id) && r.active !== false);
},
openCreateModal() {
this.isEditing = false;
this.editingId = null;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', active: true };
this.showModal = true;
},
openEditModal(station) {
this.isEditing = true;
this.editingId = station.id;
this.errorMsg = '';
this.form = {
code: station.code,
name: station.name,
location: station.location || '',
notes: station.notes || '',
active: station.active,
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveStation() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
const data = {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
active: this.form.active,
};
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nel salvataggio") }}';
return;
}
const idx = this.stations.findIndex(s => s.id === this.editingId);
if (idx >= 0) this.stations[idx] = result;
} else {
if (!this.form.code || !this.form.name) {
this.errorMsg = '{{ _("Codice e nome sono obbligatori") }}';
return;
}
const data = {
code: this.form.code.trim(),
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
active: this.form.active,
};
const resp = await fetch('/admin/api/stations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nella creazione") }}';
return;
}
this.stations.push(result);
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
confirmDelete(station) {
this.deleteTarget = station;
this.showDeleteConfirm = true;
},
async performDelete() {
if (!this.deleteTarget) return;
this.saving = true;
try {
const resp = await fetch(`/admin/api/stations/${this.deleteTarget.id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken },
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
return;
}
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
this.showDeleteConfirm = false;
this.deleteTarget = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
},
async openAssignmentsModal(station) {
this.assignmentStation = station;
this.assignedRecipes = [];
this.recipeToAdd = '';
this.errorMsg = '';
this.showAssignments = true;
try {
const resp = await fetch(`/admin/api/stations/${station.id}/recipes`);
const data = await resp.json();
if (resp.ok && Array.isArray(data)) {
this.assignedRecipes = data;
} else {
this.errorMsg = (data && data.detail) || '{{ _("Errore nel caricamento delle ricette") }}';
}
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
}
},
closeAssignmentsModal() {
this.showAssignments = false;
this.assignmentStation = null;
this.assignedRecipes = [];
this.errorMsg = '';
},
async assignRecipe() {
if (!this.recipeToAdd || !this.assignmentStation) return;
this.saving = true;
this.errorMsg = '';
try {
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ recipe_id: parseInt(this.recipeToAdd, 10) }),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
return;
}
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
this.recipeToAdd = '';
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
async unassignRecipe(recipeId) {
if (!this.assignmentStation) return;
this.saving = true;
this.errorMsg = '';
try {
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes/${recipeId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken },
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
this.errorMsg = result.detail || '{{ _("Errore nella rimozione") }}';
return;
}
this.assignedRecipes = this.assignedRecipes.filter(r => r.id !== recipeId);
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
};
}
</script>
{% endblock %}
+26 -2
View File
@@ -79,7 +79,7 @@
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200 transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('admin.') %} {% if request.endpoint == 'admin.user_list' %}
text-primary bg-primary-50 dark:bg-primary-900/20 text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}"> {% endif %}">
<!-- Users Icon --> <!-- Users Icon -->
@@ -88,6 +88,20 @@
</svg> </svg>
<span>{{ _('Utenti') }}</span> <span>{{ _('Utenti') }}</span>
</a> </a>
<a href="{{ url_for('admin.station_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint == 'admin.station_list' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Station / Workstation Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
<span>{{ _('Stazioni') }}</span>
</a>
{% endif %} {% endif %}
</div> </div>
@@ -264,7 +278,7 @@
</a> </a>
{% endif %} {% endif %}
{# Admin: Utenti #} {# Admin: Utenti + Stazioni #}
{% if current_user.get('is_admin') %} {% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}" <a href="{{ url_for('admin.user_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
@@ -275,6 +289,16 @@
</svg> </svg>
{{ _('Utenti') }} {{ _('Utenti') }}
</a> </a>
<a href="{{ url_for('admin.station_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
{{ _('Stazioni') }}
</a>
{% endif %} {% endif %}
</div> </div>
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="mt-20 p-8 bg-[var(--bg-card)] rounded-xl shadow-lg text-center border border-red-200 dark:border-red-800">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-red-50 dark:bg-red-900/30 mb-6">
<svg class="w-8 h-8 text-measure-fail" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-measure-fail mb-4">
{{ _('Stazione non configurata') }}
</h1>
<p class="mb-4 text-[var(--text-primary)]">
{{ _('Questo client non ha impostato la variabile di ambiente STATION_CODE.') }}
</p>
<p class="mb-6 text-sm text-[var(--text-secondary)]">
{{ _('Contattare il responsabile IT: il file .env del container deve contenere STATION_CODE con il codice della stazione assegnata.') }}
</p>
{% if error %}
<pre class="text-xs text-left text-[var(--text-secondary)] bg-[var(--bg-secondary)]
p-3 rounded-lg border border-[var(--border-color)] overflow-auto">{{ error }}</pre>
{% endif %}
</div>
</div>
{% endblock %}
@@ -75,6 +75,9 @@
<p class="mt-1 text-sm text-[var(--text-secondary)]"> <p class="mt-1 text-sm text-[var(--text-secondary)]">
{{ _('Scegli la ricetta di misura da eseguire') }} {{ _('Scegli la ricetta di misura da eseguire') }}
</p> </p>
<p class="mt-1 text-sm text-steel-500 dark:text-steel-400">
{{ _('Stazione') }}: <span class="font-mono font-bold">{{ station_code }}</span>
</p>
</div> </div>
</div> </div>
+134
View File
@@ -0,0 +1,134 @@
"""Tests for the admin Stations management UI and proxy endpoints."""
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def mock_admin_api():
"""Patch api_client used by the admin blueprint."""
mock = MagicMock()
with patch("blueprints.admin.api_client", mock):
yield mock
def test_station_list_page_requires_admin(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
assert b"Gestione Stazioni" in resp.data or b"stations" in resp.data.lower()
def test_station_list_page_calls_correct_endpoints(logged_in_client, mock_admin_api):
mock_admin_api.get.side_effect = [
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
"notes": None, "active": True, "created_by": 1, "created_at": "2026-04-25T10:00:00"}],
[{"id": 1, "code": "REC-001", "name": "Test Recipe", "active": True}],
]
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
calls = [c.args[0] for c in mock_admin_api.get.call_args_list]
assert "/api/stations" in calls
assert "/api/recipes" in calls
def test_create_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"id": 5, "code": "ST-005", "name": "Nuova",
"location": None, "notes": None, "active": True,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.post(
"/admin/api/stations",
json={"code": "ST-005", "name": "Nuova", "active": True},
)
assert resp.status_code == 201
body = resp.get_json()
assert body["code"] == "ST-005"
mock_admin_api.post.assert_called_once_with(
"/api/stations",
data={"code": "ST-005", "name": "Nuova", "active": True},
)
def test_create_station_propagates_error(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"error": True, "status_code": 409, "detail": "code already exists",
}
resp = logged_in_client.post(
"/admin/api/stations", json={"code": "ST-001", "name": "x"},
)
assert resp.status_code == 409
assert resp.get_json()["detail"] == "code already exists"
def test_update_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {
"id": 3, "code": "ST-003", "name": "Aggiornata",
"location": "Reparto B", "notes": None, "active": False,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.put(
"/admin/api/stations/3",
json={"name": "Aggiornata", "location": "Reparto B", "active": False},
)
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with(
"/api/stations/3",
data={"name": "Aggiornata", "location": "Reparto B", "active": False},
)
def test_delete_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {}
resp = logged_in_client.delete("/admin/api/stations/7")
assert resp.status_code == 200
assert resp.get_json() == {"deleted": True}
mock_admin_api.delete.assert_called_once_with("/api/stations/7")
def test_assign_recipe_proxy(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"id": 1, "station_id": 2, "recipe_id": 10,
"assigned_by": 1, "assigned_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.post(
"/admin/api/stations/2/recipes", json={"recipe_id": 10},
)
assert resp.status_code == 201
mock_admin_api.post.assert_called_once_with(
"/api/stations/2/recipes", data={"recipe_id": 10},
)
def test_unassign_recipe_proxy(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {}
resp = logged_in_client.delete("/admin/api/stations/2/recipes/10")
assert resp.status_code == 200
mock_admin_api.delete.assert_called_once_with("/api/stations/2/recipes/10")
def test_list_station_recipes_proxy(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = [
{"id": 1, "code": "REC-001", "name": "R1", "active": True},
]
resp = logged_in_client.get("/admin/api/stations/2/recipes")
assert resp.status_code == 200
assert resp.get_json() == [{"id": 1, "code": "REC-001", "name": "R1", "active": True}]
mock_admin_api.get.assert_called_once_with("/api/stations/2/recipes")
def test_non_admin_cannot_access(client, mock_admin_api):
"""Non-admin user gets redirected away from station management."""
with client.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 2,
"username": "operator",
"roles": ["MeasurementTec"],
"is_admin": False,
"active": True,
}
sess["user_id"] = 2
resp = client.get("/admin/stations", follow_redirects=False)
assert resp.status_code in (301, 302)
+15
View File
@@ -0,0 +1,15 @@
"""Tests for the station-related helpers in api_client."""
from unittest.mock import patch
from services.api_client import APIClient
def test_get_station_recipes_calls_correct_endpoint():
client = APIClient()
with patch.object(client, "get") as mock_get:
mock_get.return_value = [{"id": 1, "code": "R1", "name": "R1", "active": True}]
result = client.get_station_recipes("ST-001")
# The helper must call the underlying .get() with the exact endpoint path.
called_args, called_kwargs = mock_get.call_args
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
+17
View File
@@ -0,0 +1,17 @@
"""Tests that STATION_CODE is loaded from env and exposed on the client Config class."""
import importlib
import os
def test_station_code_read_from_env(monkeypatch):
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
importlib.reload(config)
assert config.Config.STATION_CODE == "ST-TEST"
def test_station_code_defaults_to_none_when_missing(monkeypatch):
monkeypatch.delenv("STATION_CODE", raising=False)
import config
importlib.reload(config)
assert config.Config.STATION_CODE is None
+10 -7
View File
@@ -8,15 +8,18 @@ import pytest
class TestSelectRecipe: class TestSelectRecipe:
"""GET /measure/select tests.""" """GET /measure/select tests."""
def test_select_recipe_renders(self, logged_in_client, mock_api_client): def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
"""Recipe selection page renders for MeasurementTec role.""" """Recipe selection page renders for MeasurementTec role."""
mock_api_client.get.return_value = { monkeypatch.setenv("STATION_CODE", "ST-TEST")
"items": [ import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"}, {"id": 1, "code": "REC-001", "name": "Test Recipe"},
], ]
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/measure/select") resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -0,0 +1,37 @@
"""Verify that /measure/select reads STATION_CODE and filters recipes via the server."""
import importlib
from unittest.mock import patch, MagicMock
def _reload_measure(monkeypatch, station_code=None):
"""Reload config and measure module under the given STATION_CODE env."""
if station_code is None:
monkeypatch.delenv("STATION_CODE", raising=False)
else:
monkeypatch.setenv("STATION_CODE", station_code)
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-TEST")
from blueprints import measure as measure_bp_mod
with patch.object(measure_bp_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = [
{"id": 1, "code": "R1", "name": "Recipe 1", "active": True},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
mock_api.get_station_recipes.assert_called_once()
args, kwargs = mock_api.get_station_recipes.call_args
assert args[0] == "ST-TEST" or kwargs.get("station_code") == "ST-TEST"
def test_select_recipe_without_station_code_shows_error(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code=None)
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 503
body = resp.data.lower()
assert b"station_code" in body or b"stazione" in body
+2
View File
@@ -20,6 +20,7 @@ from routers.settings import router as settings_router
from routers.reports import router as reports_router from routers.reports import router as reports_router
from routers.statistics import router as statistics_router from routers.statistics import router as statistics_router
from routers.setup import router as setup_router from routers.setup import router as setup_router
from routers.stations import router as stations_router
@asynccontextmanager @asynccontextmanager
@@ -71,6 +72,7 @@ app.include_router(settings_router)
app.include_router(statistics_router) app.include_router(statistics_router)
app.include_router(reports_router) app.include_router(reports_router)
app.include_router(setup_router) app.include_router(setup_router)
app.include_router(stations_router)
@app.get("/api/health") @app.get("/api/health")
@@ -0,0 +1,27 @@
"""add image_path to recipe and subtask
Revision ID: 001_image_path
Revises:
Create Date: 2026-02-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001_image_path'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('recipes', sa.Column('image_path', sa.String(500), nullable=True))
op.add_column('recipe_subtasks', sa.Column('image_path', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('recipe_subtasks', 'image_path')
op.drop_column('recipes', 'image_path')
@@ -0,0 +1,53 @@
"""add stations and station_recipe_assignments tables
Revision ID: 002_add_stations
Revises: 001_image_path
Create Date: 2026-04-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '002_add_stations'
down_revision: Union[str, None] = '001_image_path'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'stations',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('code', sa.String(100), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('location', sa.String(255), nullable=True),
sa.Column('notes', sa.Text, nullable=True),
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint('code', name='uq_stations_code'),
sa.Index('ix_stations_active', 'active'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_table(
'station_recipe_assignments',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id', ondelete='CASCADE'), nullable=False),
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id', ondelete='CASCADE'), nullable=False),
sa.Column('assigned_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('assigned_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint('station_id', 'recipe_id', name='uq_station_recipe'),
sa.Index('ix_sra_station', 'station_id'),
sa.Index('ix_sra_recipe', 'recipe_id'),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
def downgrade() -> None:
op.drop_table('station_recipe_assignments')
op.drop_table('stations')
+70
View File
@@ -0,0 +1,70 @@
"""Station and StationRecipeAssignment models.
A Station represents a physical control point (typically one per tablet/PC).
Recipes are assigned to stations so that each station only sees the products
it is supposed to inspect.
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
if TYPE_CHECKING:
from models.recipe import Recipe
class Station(Base):
__tablename__ = "stations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
assignments: Mapped[list["StationRecipeAssignment"]] = relationship(
back_populates="station", cascade="all, delete-orphan", lazy="selectin"
)
__table_args__ = (
UniqueConstraint("code", name="uq_stations_code"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<Station {self.code} '{self.name}'>"
class StationRecipeAssignment(Base):
__tablename__ = "station_recipe_assignments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
station_id: Mapped[int] = mapped_column(
Integer, ForeignKey("stations.id", ondelete="CASCADE"), nullable=False, index=True
)
recipe_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True
)
assigned_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
assigned_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
station: Mapped["Station"] = relationship(back_populates="assignments")
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
__table_args__ = (
UniqueConstraint("station_id", "recipe_id", name="uq_station_recipe"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<StationRecipeAssignment station={self.station_id} recipe={self.recipe_id}>"
+50
View File
@@ -13,12 +13,14 @@ from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import inspect as sa_inspect, select from sqlalchemy import inspect as sa_inspect, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
from database import Base, engine, async_session_factory from database import Base, engine, async_session_factory
from models import ( from models import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
) )
from models.station import Station, StationRecipeAssignment
from services.auth_service import hash_password from services.auth_service import hash_password
from services.measurement_service import calculate_pass_fail from services.measurement_service import calculate_pass_fail
@@ -161,6 +163,50 @@ def _generate_measurements_for_subtask(
return measurements return measurements
async def _seed_default_station(session: AsyncSession, admin_user: User) -> None:
"""Ensure ST-DEFAULT station exists and all active recipes are assigned to it.
Idempotent: safe to call multiple times. Skips creation if the station
already exists and only adds assignments for recipes not yet assigned.
"""
existing = await session.execute(
select(Station).where(Station.code == "ST-DEFAULT")
)
default_station = existing.scalar_one_or_none()
if default_station is None:
default_station = Station(
code="ST-DEFAULT",
name="Default Station",
location="Initial seed - change me",
created_by=admin_user.id,
)
session.add(default_station)
await session.flush()
await session.refresh(default_station)
# Collect already-assigned recipe IDs to avoid unique-constraint violations.
existing_assignments = await session.execute(
select(StationRecipeAssignment.recipe_id).where(
StationRecipeAssignment.station_id == default_station.id
)
)
existing_ids = {row[0] for row in existing_assignments}
recipes_result = await session.execute(
select(Recipe).where(Recipe.active == True)
)
for r in recipes_result.scalars().all():
if r.id not in existing_ids:
session.add(StationRecipeAssignment(
station_id=default_station.id,
recipe_id=r.id,
assigned_by=admin_user.id,
))
await session.flush()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Endpoints # Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -299,6 +345,7 @@ async def seed_demo_data(body: PasswordBody):
select(Recipe).where(Recipe.code == "DEMO-001") select(Recipe).where(Recipe.code == "DEMO-001")
) )
if existing_recipe.scalar_one_or_none(): if existing_recipe.scalar_one_or_none():
await _seed_default_station(session, admin_user)
return { return {
"status": "ok", "status": "ok",
"message": "Demo data already exists, skipped", "message": "Demo data already exists, skipped",
@@ -453,6 +500,9 @@ async def seed_demo_data(body: PasswordBody):
session.add_all(ms) session.add_all(ms)
measurements_created += len(ms) measurements_created += len(ms)
# ---- Default Station ----------------------------------------------
await _seed_default_station(session, admin_user)
return { return {
"status": "ok", "status": "ok",
"message": "Demo data seeded successfully", "message": "Demo data seeded successfully",
+142
View File
@@ -0,0 +1,142 @@
"""Stations router - CRUD + recipe assignments."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import get_current_user, require_admin_user
from models.user import User
from schemas.station import (
StationCreate,
StationUpdate,
StationResponse,
StationRecipeAssignmentCreate,
StationRecipeAssignmentResponse,
RecipeSummary,
)
from services import station_service
router = APIRouter(prefix="/api/stations", tags=["stations"])
@router.get("", response_model=list[StationResponse])
async def list_stations(
active_only: bool = False,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all stations (admin only)."""
stations = await station_service.list_stations(db, active_only=active_only)
return [StationResponse.model_validate(s) for s in stations]
@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED)
async def create_new_station(
data: StationCreate,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Create a station (admin only)."""
station = await station_service.create_station(db, data, admin)
return StationResponse.model_validate(station)
# NOTE: this literal-prefix route must stay above the /{station_id} routes.
# The int-typed station_id param already guards against "by-code" being
# matched as a station id, but keeping the explicit order avoids surprises
# during refactors (e.g. if someone regroups handlers by HTTP method).
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
async def list_recipes_by_station_code(
code: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Operator view: active recipes assigned to the station with this code.
Used by the Flask client at startup / on select_recipe page.
Any authenticated user can call this; filtering is by station code from
the client's STATION_CODE environment variable.
"""
station = await station_service.get_station_by_code(db, code)
if station is None or not station.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Station not found",
)
recipes = await station_service.list_station_recipes(db, station.id)
return [RecipeSummary.model_validate(r) for r in recipes]
@router.get("/{station_id}", response_model=StationResponse)
async def get_single_station(
station_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Get a station by id (admin only)."""
station = await station_service.get_station(db, station_id)
return StationResponse.model_validate(station)
@router.put("/{station_id}", response_model=StationResponse)
async def update_existing_station(
station_id: int,
data: StationUpdate,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Update a station (admin only)."""
station = await station_service.update_station(db, station_id, data)
return StationResponse.model_validate(station)
@router.delete("/{station_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_station(
station_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a station (admin only). Cascades to assignments."""
await station_service.delete_station(db, station_id)
@router.get("/{station_id}/recipes", response_model=list[RecipeSummary])
async def list_assigned_recipes(
station_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Admin view: recipes assigned to this station (active only)."""
recipes = await station_service.list_station_recipes(db, station_id)
return [RecipeSummary.model_validate(r) for r in recipes]
@router.post(
"/{station_id}/recipes",
response_model=StationRecipeAssignmentResponse,
status_code=status.HTTP_201_CREATED,
)
async def assign_recipe_to_station(
station_id: int,
data: StationRecipeAssignmentCreate,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Assign a recipe to a station (admin only)."""
assignment = await station_service.assign_recipe(
db, station_id, data.recipe_id, admin,
)
return StationRecipeAssignmentResponse.model_validate(assignment)
@router.delete(
"/{station_id}/recipes/{recipe_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def unassign_recipe_from_station(
station_id: int,
recipe_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Remove a recipe assignment (admin only)."""
await station_service.unassign_recipe(db, station_id, recipe_id)
+57
View File
@@ -0,0 +1,57 @@
"""Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class StationCreate(BaseModel):
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
active: bool = True
class StationUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
active: Optional[bool] = None
class StationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
code: str
name: str
location: Optional[str]
notes: Optional[str]
active: bool
created_by: int
created_at: datetime
class StationRecipeAssignmentCreate(BaseModel):
recipe_id: int = Field(..., gt=0)
class StationRecipeAssignmentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
station_id: int
recipe_id: int
assigned_by: int
assigned_at: datetime
class RecipeSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
code: str
name: str
active: bool
class StationWithRecipesResponse(StationResponse):
recipes: list[RecipeSummary] = Field(default_factory=list)
+155
View File
@@ -0,0 +1,155 @@
"""Business logic for stations and recipe assignments.
Routers must call into these functions rather than manipulating models directly.
All functions are async and accept an AsyncSession; they flush but do NOT commit
(commit is handled by the FastAPI get_db dependency).
"""
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.recipe import Recipe
from models.station import Station, StationRecipeAssignment
from models.user import User
from schemas.station import StationCreate, StationUpdate
async def create_station(
db: AsyncSession, data: StationCreate, creator: User,
) -> Station:
existing = await db.execute(select(Station).where(Station.code == data.code))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Station code '{data.code}' already exists",
)
station = Station(
code=data.code,
name=data.name,
location=data.location,
notes=data.notes,
active=data.active,
created_by=creator.id,
)
db.add(station)
await db.flush()
await db.refresh(station)
return station
async def get_station(db: AsyncSession, station_id: int) -> Station:
result = await db.execute(select(Station).where(Station.id == station_id))
station = result.scalar_one_or_none()
if station is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
)
return station
async def get_station_by_code(db: AsyncSession, code: str) -> Optional[Station]:
result = await db.execute(select(Station).where(Station.code == code))
return result.scalar_one_or_none()
async def list_stations(db: AsyncSession, active_only: bool = False) -> list[Station]:
query = select(Station).order_by(Station.code)
if active_only:
query = query.where(Station.active == True) # noqa: E712
result = await db.execute(query)
return list(result.scalars().all())
async def update_station(
db: AsyncSession, station_id: int, data: StationUpdate,
) -> Station:
station = await get_station(db, station_id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(station, field, value)
await db.flush()
await db.refresh(station)
return station
async def delete_station(db: AsyncSession, station_id: int) -> None:
station = await get_station(db, station_id)
# Explicitly delete assignments so the ORM cascade fires within the
# current session (SQLite test DB does not enforce FK CASCADE without
# PRAGMA foreign_keys = ON; production MySQL handles it at DB level too,
# but explicit ORM deletion is engine-agnostic and safer).
assignments = await db.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station_id
)
)
for assignment in assignments.scalars().all():
await db.delete(assignment)
await db.delete(station)
await db.flush()
async def assign_recipe(
db: AsyncSession, station_id: int, recipe_id: int, assigner: User,
) -> StationRecipeAssignment:
await get_station(db, station_id)
recipe_row = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
if recipe_row.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
)
existing = await db.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station_id,
StationRecipeAssignment.recipe_id == recipe_id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Recipe already assigned to this station",
)
assignment = StationRecipeAssignment(
station_id=station_id, recipe_id=recipe_id, assigned_by=assigner.id,
)
db.add(assignment)
await db.flush()
await db.refresh(assignment)
return assignment
async def unassign_recipe(
db: AsyncSession, station_id: int, recipe_id: int,
) -> None:
result = await db.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station_id,
StationRecipeAssignment.recipe_id == recipe_id,
)
)
assignment = result.scalar_one_or_none()
if assignment is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Assignment not found",
)
await db.delete(assignment)
await db.flush()
async def list_station_recipes(
db: AsyncSession, station_id: int,
) -> list[Recipe]:
"""Return active recipes assigned to this station, ordered by code."""
await get_station(db, station_id)
result = await db.execute(
select(Recipe)
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
.where(
StationRecipeAssignment.station_id == station_id,
Recipe.active == True, # noqa: E712
)
.order_by(Recipe.code)
)
return list(result.scalars().all())
+1
View File
@@ -39,6 +39,7 @@ from models.task import RecipeTask, RecipeSubtask
from models.measurement import Measurement from models.measurement import Measurement
from models.access_log import AccessLog from models.access_log import AccessLog
from models.setting import SystemSetting, RecipeVersionAudit from models.setting import SystemSetting, RecipeVersionAudit
from models.station import Station, StationRecipeAssignment
from services.auth_service import hash_password, generate_api_key from services.auth_service import hash_password, generate_api_key
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+73
View File
@@ -0,0 +1,73 @@
"""Test the Station and StationRecipeAssignment ORM models."""
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from models.station import Station, StationRecipeAssignment
from tests.conftest import _create_user, create_test_recipe
async def test_create_station(db_session: AsyncSession):
admin = await _create_user(db_session, username="admin1", is_admin=True)
station = Station(
code="ST-001",
name="Linea 1",
location="Reparto Nord",
created_by=admin.id,
)
db_session.add(station)
await db_session.flush()
await db_session.refresh(station)
assert station.id is not None
assert station.active is True
assert station.created_at is not None
async def test_station_code_is_unique(db_session: AsyncSession):
admin = await _create_user(db_session, username="admin2", is_admin=True)
db_session.add(Station(code="ST-DUP", name="A", created_by=admin.id))
await db_session.flush()
db_session.add(Station(code="ST-DUP", name="B", created_by=admin.id))
with pytest.raises(IntegrityError):
await db_session.flush()
await db_session.rollback()
async def test_assign_recipe_to_station(db_session: AsyncSession):
admin = await _create_user(db_session, username="admin3", is_admin=True)
station = Station(code="ST-002", name="Linea 2", created_by=admin.id)
db_session.add(station)
await db_session.flush()
recipe = await create_test_recipe(db_session, user_id=admin.id, code="REC-X")
assignment = StationRecipeAssignment(
station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id,
)
db_session.add(assignment)
await db_session.flush()
result = await db_session.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station.id
)
)
assignments = result.scalars().all()
assert len(assignments) == 1
assert assignments[0].recipe_id == recipe.id
async def test_duplicate_assignment_is_rejected(db_session: AsyncSession):
admin = await _create_user(db_session, username="admin4", is_admin=True)
station = Station(code="ST-003", name="Linea 3", created_by=admin.id)
db_session.add(station)
await db_session.flush()
recipe = await create_test_recipe(db_session, user_id=admin.id, code="REC-Y")
db_session.add(StationRecipeAssignment(
station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id,
))
await db_session.flush()
db_session.add(StationRecipeAssignment(
station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id,
))
with pytest.raises(IntegrityError):
await db_session.flush()
await db_session.rollback()
+35
View File
@@ -0,0 +1,35 @@
"""Tests for Station Pydantic schemas."""
import pytest
from pydantic import ValidationError
from schemas.station import (
StationCreate, StationUpdate, StationResponse,
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
StationWithRecipesResponse,
)
def test_station_create_valid():
data = StationCreate(code="ST-001", name="Linea 1", location="Reparto Nord")
assert data.code == "ST-001"
assert data.active is True
def test_station_create_rejects_empty_code():
with pytest.raises(ValidationError):
StationCreate(code="", name="X")
def test_station_create_rejects_too_long_code():
with pytest.raises(ValidationError):
StationCreate(code="A" * 101, name="X")
def test_station_update_all_optional():
data = StationUpdate()
assert data.name is None
def test_station_assignment_create():
data = StationRecipeAssignmentCreate(recipe_id=42)
assert data.recipe_id == 42
+100
View File
@@ -0,0 +1,100 @@
"""Verify /api/setup/seed creates a default station with all recipes assigned.
DEVIATION FROM PLAN: The real setup endpoint is NOT a single /api/setup/initialize.
The setup is split into separate endpoints:
- POST /api/setup/init-db (creates tables)
- POST /api/setup/seed (seeds users + demo recipe + stations)
The seed body is {"password": "..."} with no load_demo_data flag (demo data
is always seeded). The station seed is added inside /api/setup/seed.
The seed endpoint uses async_session_factory directly (not get_db dependency
injection), so we must monkeypatch routers.setup.engine and
routers.setup.async_session_factory to point at the test SQLite engine,
exactly as done in test_setup.py.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from models.station import Station, StationRecipeAssignment
from models.recipe import Recipe
from tests.conftest import test_engine, TestSessionFactory
SETUP_PWD = "test-setup-pwd"
@pytest.fixture(autouse=True)
def enable_setup(monkeypatch):
"""Enable setup endpoints and redirect engine/session to test SQLite DB."""
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
import routers.setup as setup_mod
monkeypatch.setattr(setup_mod, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
async def _seed(client: AsyncClient) -> dict:
"""Init DB tables then run seed; return seed response JSON."""
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
return resp.json()
@pytest.mark.asyncio
async def test_setup_seed_creates_default_station_and_assigns_recipes(
client: AsyncClient,
db_session: AsyncSession,
):
"""After seeding, ST-DEFAULT station must exist and all active recipes assigned."""
await _seed(client)
# Default station must exist and be active.
result = await db_session.execute(
select(Station).where(Station.code == "ST-DEFAULT")
)
default = result.scalar_one_or_none()
assert default is not None, "ST-DEFAULT station was not created"
assert default.active is True
# All active recipes must be assigned to the default station.
active_recipes_result = await db_session.execute(
select(Recipe).where(Recipe.active == True)
)
active_recipes = active_recipes_result.scalars().all()
assert len(active_recipes) > 0, "demo seed must create at least one active recipe"
assignments_result = await db_session.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == default.id
)
)
n_assignments = len(assignments_result.scalars().all())
assert n_assignments == len(active_recipes), (
f"Expected {len(active_recipes)} assignment(s), got {n_assignments}"
)
@pytest.mark.asyncio
async def test_setup_seed_station_idempotent(
client: AsyncClient,
db_session: AsyncSession,
):
"""Running seed twice must not duplicate ST-DEFAULT or its assignments."""
# First run — creates everything.
await _seed(client)
# Second run — recipe DEMO-001 already exists → seed returns early.
# ST-DEFAULT must still exist (not re-created, not duplicated).
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
stations_result = await db_session.execute(
select(Station).where(Station.code == "ST-DEFAULT")
)
stations = stations_result.scalars().all()
assert len(stations) == 1, f"Expected exactly 1 ST-DEFAULT, got {len(stations)}"
+114
View File
@@ -0,0 +1,114 @@
"""Tests for station_service business logic."""
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from models.station import Station
from schemas.station import StationCreate, StationUpdate
from services.station_service import (
create_station, update_station, delete_station,
assign_recipe, unassign_recipe, list_station_recipes,
get_station_by_code,
)
from tests.conftest import _create_user, create_test_recipe
async def test_create_station_ok(db_session: AsyncSession):
admin = await _create_user(db_session, username="a1", is_admin=True)
station = await create_station(
db_session, StationCreate(code="ST-100", name="Pilot"), admin
)
assert station.id is not None
assert station.code == "ST-100"
async def test_create_station_duplicate_code(db_session: AsyncSession):
admin = await _create_user(db_session, username="a2", is_admin=True)
await create_station(db_session, StationCreate(code="ST-DUP", name="A"), admin)
with pytest.raises(HTTPException) as exc:
await create_station(db_session, StationCreate(code="ST-DUP", name="B"), admin)
assert exc.value.status_code == 409
async def test_update_station(db_session: AsyncSession):
admin = await _create_user(db_session, username="a3", is_admin=True)
station = await create_station(db_session, StationCreate(code="ST-U", name="Old"), admin)
updated = await update_station(
db_session, station.id, StationUpdate(name="New name"),
)
assert updated.name == "New name"
assert updated.code == "ST-U"
async def test_update_missing_station(db_session: AsyncSession):
with pytest.raises(HTTPException) as exc:
await update_station(db_session, 9999, StationUpdate(name="x"))
assert exc.value.status_code == 404
async def test_assign_and_list_recipes(db_session: AsyncSession):
admin = await _create_user(db_session, username="a4", is_admin=True)
station = await create_station(db_session, StationCreate(code="ST-R", name="R"), admin)
r1 = await create_test_recipe(db_session, user_id=admin.id, code="REC-R1")
r2 = await create_test_recipe(db_session, user_id=admin.id, code="REC-R2")
await assign_recipe(db_session, station.id, r1.id, admin)
await assign_recipe(db_session, station.id, r2.id, admin)
recipes = await list_station_recipes(db_session, station.id)
assert {r.code for r in recipes} == {"REC-R1", "REC-R2"}
async def test_assign_same_recipe_twice_is_409(db_session: AsyncSession):
admin = await _create_user(db_session, username="a5", is_admin=True)
station = await create_station(db_session, StationCreate(code="ST-D", name="D"), admin)
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-D")
await assign_recipe(db_session, station.id, r.id, admin)
with pytest.raises(HTTPException) as exc:
await assign_recipe(db_session, station.id, r.id, admin)
assert exc.value.status_code == 409
async def test_unassign_recipe(db_session: AsyncSession):
admin = await _create_user(db_session, username="a6", is_admin=True)
station = await create_station(db_session, StationCreate(code="ST-UN", name="UN"), admin)
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-UN")
await assign_recipe(db_session, station.id, r.id, admin)
await unassign_recipe(db_session, station.id, r.id)
recipes = await list_station_recipes(db_session, station.id)
assert recipes == []
async def test_get_station_by_code(db_session: AsyncSession):
admin = await _create_user(db_session, username="a7", is_admin=True)
await create_station(db_session, StationCreate(code="ST-FIND", name="F"), admin)
found = await get_station_by_code(db_session, "ST-FIND")
assert found is not None
assert found.name == "F"
missing = await get_station_by_code(db_session, "ST-NOPE")
assert missing is None
async def test_list_recipes_only_returns_active(db_session: AsyncSession):
admin = await _create_user(db_session, username="a8", is_admin=True)
station = await create_station(db_session, StationCreate(code="ST-A", name="A"), admin)
active = await create_test_recipe(db_session, user_id=admin.id, code="REC-AC")
inactive = await create_test_recipe(db_session, user_id=admin.id, code="REC-IN")
inactive.active = False
await db_session.flush()
await assign_recipe(db_session, station.id, active.id, admin)
await assign_recipe(db_session, station.id, inactive.id, admin)
recipes = await list_station_recipes(db_session, station.id)
assert [r.code for r in recipes] == ["REC-AC"]
async def test_delete_station_cascades_assignments(db_session: AsyncSession):
from sqlalchemy import select
from models.station import StationRecipeAssignment
admin = await _create_user(db_session, username="a9", is_admin=True)
station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin)
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL")
await assign_recipe(db_session, station.id, r.id, admin)
await delete_station(db_session, station.id)
remaining = await db_session.execute(
select(StationRecipeAssignment).where(StationRecipeAssignment.station_id == station.id)
)
assert remaining.scalars().all() == []
+203
View File
@@ -0,0 +1,203 @@
"""Integration tests for /api/stations endpoints."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_headers, create_test_recipe
async def test_list_stations_requires_auth(client: AsyncClient):
resp = await client.get("/api/stations")
assert resp.status_code == 401
async def test_create_station_as_admin(client: AsyncClient, admin_user):
resp = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-API", "name": "Via API"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["code"] == "ST-API"
assert body["active"] is True
assert body["id"] > 0
async def test_create_station_non_admin_is_403(client: AsyncClient, maker_user):
resp = await client.post(
"/api/stations",
headers=auth_headers(maker_user),
json={"code": "ST-NO", "name": "No"},
)
assert resp.status_code == 403
async def test_update_station(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-UP", "name": "Old"},
)
station_id = created.json()["id"]
resp = await client.put(
f"/api/stations/{station_id}",
headers=auth_headers(admin_user),
json={"name": "New", "active": False},
)
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "New"
assert body["active"] is False
async def test_delete_station(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-D", "name": "D"},
)
sid = created.json()["id"]
resp = await client.delete(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
)
assert resp.status_code == 204
again = await client.get(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
)
assert again.status_code == 404
async def test_assign_and_unassign_recipe(
client: AsyncClient, admin_user, db_session,
):
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-AS")
await db_session.commit()
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-ASSIGN", "name": "A"},
)
sid = created.json()["id"]
a = await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
assert a.status_code == 201
r = await client.get(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
)
assert r.status_code == 200
assert [rec["code"] for rec in r.json()] == ["REC-AS"]
u = await client.delete(
f"/api/stations/{sid}/recipes/{recipe.id}",
headers=auth_headers(admin_user),
)
assert u.status_code == 204
r2 = await client.get(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
)
assert r2.json() == []
async def test_list_recipes_by_station_code(
client: AsyncClient, admin_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-BC")
await db_session.commit()
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-BC", "name": "BC"},
)
sid = created.json()["id"]
await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
resp = await client.get(
"/api/stations/by-code/ST-BC/recipes",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200
assert [r["code"] for r in resp.json()] == ["REC-BC"]
async def test_list_recipes_by_unknown_code_404(
client: AsyncClient, measurement_tec_user,
):
resp = await client.get(
"/api/stations/by-code/ST-DOES-NOT-EXIST/recipes",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 404
async def test_admin_can_list_stations(client: AsyncClient, admin_user):
await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-L1", "name": "A"},
)
await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-L2", "name": "B", "active": False},
)
resp = await client.get("/api/stations", headers=auth_headers(admin_user))
assert resp.status_code == 200
codes = {s["code"] for s in resp.json()}
assert {"ST-L1", "ST-L2"}.issubset(codes)
resp_active = await client.get(
"/api/stations?active_only=true", headers=auth_headers(admin_user),
)
assert resp_active.status_code == 200
active_codes = {s["code"] for s in resp_active.json()}
assert "ST-L1" in active_codes
assert "ST-L2" not in active_codes
async def test_assign_recipe_not_found_returns_404(
client: AsyncClient, admin_user,
):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-NR", "name": "NR"},
)
sid = created.json()["id"]
resp = await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": 99999},
)
assert resp.status_code == 404
async def test_duplicate_assignment_returns_409(
client: AsyncClient, admin_user, db_session,
):
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-DUP")
await db_session.commit()
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-DUP-A", "name": "Dup"},
)
sid = created.json()["id"]
first = await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
assert first.status_code == 201
second = await client.post(
f"/api/stations/{sid}/recipes",
headers=auth_headers(admin_user),
json={"recipe_id": recipe.id},
)
assert second.status_code == 409