Compare commits
10 Commits
5959c9c92a
...
a6c335ca8b
| Author | SHA1 | Date | |
|---|---|---|---|
| a6c335ca8b | |||
| 946264637b | |||
| a4a849920f | |||
| 958f6ac0b0 | |||
| 2e4db53f6a | |||
| a79ab37add | |||
| 338f21fba0 | |||
| e8cd1f05aa | |||
| 559e740d64 | |||
| e36bbbb7d7 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Station(Base):
|
|||||||
__tablename__ = "stations"
|
__tablename__ = "stations"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
code: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
@@ -35,6 +35,7 @@ class Station(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
UniqueConstraint("code", name="uq_stations_code"),
|
||||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
"""Test the Station and StationRecipeAssignment ORM models."""
|
"""Test the Station and StationRecipeAssignment ORM models."""
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.station import Station, StationRecipeAssignment
|
from models.station import Station, StationRecipeAssignment
|
||||||
@@ -24,7 +25,6 @@ async def test_create_station(db_session: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
async def test_station_code_is_unique(db_session: AsyncSession):
|
async def test_station_code_is_unique(db_session: AsyncSession):
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
admin = await _create_user(db_session, username="admin2", is_admin=True)
|
admin = await _create_user(db_session, username="admin2", is_admin=True)
|
||||||
db_session.add(Station(code="ST-DUP", name="A", created_by=admin.id))
|
db_session.add(Station(code="ST-DUP", name="A", created_by=admin.id))
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
@@ -53,3 +53,21 @@ async def test_assign_recipe_to_station(db_session: AsyncSession):
|
|||||||
assignments = result.scalars().all()
|
assignments = result.scalars().all()
|
||||||
assert len(assignments) == 1
|
assert len(assignments) == 1
|
||||||
assert assignments[0].recipe_id == recipe.id
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)}"
|
||||||
@@ -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() == []
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user