Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c764cf73c6 |
@@ -23,7 +23,6 @@ async def download_spc_report(
|
|||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -33,7 +32,6 @@ async def download_spc_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -59,7 +57,6 @@ async def download_measurement_report(
|
|||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -69,7 +66,6 @@ async def download_measurement_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ from datetime import datetime
|
|||||||
from src.backend.models.api.tipi import UtcNaiveInput
|
from src.backend.models.api.tipi import UtcNaiveInput
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from src.backend.models.orm.production import ProductionRun
|
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
@@ -39,7 +38,6 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters.
|
"""Query measurements with common filters.
|
||||||
|
|
||||||
@@ -69,9 +67,6 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
if production_run_id is not None:
|
|
||||||
filters.append(Measurement.production_run_id == production_run_id)
|
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -111,7 +106,6 @@ async def get_summary(
|
|||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> SummaryData:
|
) -> SummaryData:
|
||||||
@@ -119,7 +113,6 @@ async def get_summary(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
pass_fail_values = [m.pass_fail for m in measurements]
|
pass_fail_values = [m.pass_fail for m in measurements]
|
||||||
return compute_summary(pass_fail_values)
|
return compute_summary(pass_fail_values)
|
||||||
@@ -135,7 +128,6 @@ async def get_capability(
|
|||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> CapabilityData:
|
) -> CapabilityData:
|
||||||
@@ -143,7 +135,6 @@ async def get_capability(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
@@ -160,7 +151,6 @@ async def get_control_chart(
|
|||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ControlChartData:
|
) -> ControlChartData:
|
||||||
@@ -168,7 +158,6 @@ async def get_control_chart(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
timestamps = [m.measured_at for m in measurements]
|
timestamps = [m.measured_at for m in measurements]
|
||||||
@@ -189,7 +178,6 @@ async def get_histogram(
|
|||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
production_run_id: int | None = Query(None), # #58
|
|
||||||
n_bins: int = Query(20, ge=5, le=100),
|
n_bins: int = Query(20, ge=5, le=100),
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -198,7 +186,6 @@ async def get_histogram(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
return compute_histogram(values, n_bins)
|
return compute_histogram(values, n_bins)
|
||||||
@@ -248,49 +235,3 @@ async def get_recipe_subtasks(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return subtasks_list
|
return subtasks_list
|
||||||
|
|
||||||
|
|
||||||
@router.get("/runs")
|
|
||||||
async def get_recipe_runs(
|
|
||||||
recipe_id: int = Query(...),
|
|
||||||
user: User = Depends(require_metrologist),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
|
|
||||||
runs = (await db.execute(
|
|
||||||
select(ProductionRun)
|
|
||||||
.where(ProductionRun.recipe_id == recipe_id)
|
|
||||||
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
|
|
||||||
)).scalars().all()
|
|
||||||
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
|
|
||||||
run_ids = [r.id for r in runs]
|
|
||||||
counts = {}
|
|
||||||
if run_ids:
|
|
||||||
counts = dict((await db.execute(
|
|
||||||
select(Measurement.production_run_id, func.count(Measurement.id))
|
|
||||||
.where(Measurement.production_run_id.in_(run_ids))
|
|
||||||
.group_by(Measurement.production_run_id)
|
|
||||||
)).all())
|
|
||||||
operator_ids = {r.operator_id for r in runs}
|
|
||||||
names = {}
|
|
||||||
if operator_ids:
|
|
||||||
names = dict((await db.execute(
|
|
||||||
select(User.id, User.display_name).where(User.id.in_(operator_ids))
|
|
||||||
)).all())
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"station_id": r.station_id,
|
|
||||||
"station_name": r.station.name if r.station else None,
|
|
||||||
"operator_id": r.operator_id,
|
|
||||||
"operator_name": names.get(r.operator_id),
|
|
||||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
|
||||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
|
||||||
"status": r.status,
|
|
||||||
"lot_number": r.lot_number,
|
|
||||||
"serial_number": r.serial_number,
|
|
||||||
"cycle_count": r.cycle_count,
|
|
||||||
"measurement_count": counts.get(r.id, 0),
|
|
||||||
}
|
|
||||||
for r in runs
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None, # #58
|
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||||
filters = []
|
filters = []
|
||||||
@@ -74,9 +73,6 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
if production_run_id is not None:
|
|
||||||
filters.append(Measurement.production_run_id == production_run_id)
|
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -285,7 +281,6 @@ async def generate_spc_report(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None, # #58
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate SPC PDF report and return bytes.
|
"""Generate SPC PDF report and return bytes.
|
||||||
|
|
||||||
@@ -295,7 +290,6 @@ async def generate_spc_report(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Compute SPC data
|
# 2. Compute SPC data
|
||||||
@@ -342,8 +336,6 @@ async def generate_spc_report(
|
|||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {serial_number}")
|
filters_desc.append(f"Serial: {serial_number}")
|
||||||
if production_run_id is not None:
|
|
||||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
|
||||||
|
|
||||||
# 6. Render HTML template
|
# 6. Render HTML template
|
||||||
template = _jinja_env.get_template("spc_report.html")
|
template = _jinja_env.get_template("spc_report.html")
|
||||||
@@ -375,14 +367,12 @@ async def generate_measurement_report(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None, # #58
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate measurement table PDF report and return bytes."""
|
"""Generate measurement table PDF report and return bytes."""
|
||||||
# 1. Query measurements
|
# 1. Query measurements
|
||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Get recipe info
|
# 2. Get recipe info
|
||||||
@@ -437,8 +427,6 @@ async def generate_measurement_report(
|
|||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {serial_number}")
|
filters_desc.append(f"Serial: {serial_number}")
|
||||||
if production_run_id is not None:
|
|
||||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
|
||||||
|
|
||||||
# 7. Render template
|
# 7. Render template
|
||||||
template = _jinja_env.get_template("measurement_report.html")
|
template = _jinja_env.get_template("measurement_report.html")
|
||||||
|
|||||||
@@ -211,79 +211,3 @@ class TestDateFilter:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# All measurements should be within this wide range
|
# All measurements should be within this wide range
|
||||||
assert resp.json()["total"] >= 2
|
assert resp.json()["total"] >= 2
|
||||||
|
|
||||||
|
|
||||||
class TestRunFilter:
|
|
||||||
"""#58: statistiche per sessione di produzione."""
|
|
||||||
|
|
||||||
async def _setup(self, db_session, user, metro):
|
|
||||||
from datetime import datetime
|
|
||||||
from src.backend.models.orm.production import ProductionRun
|
|
||||||
from src.backend.models.orm.station import Station
|
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
|
||||||
from sqlalchemy import select
|
|
||||||
recipe = await create_test_recipe(db_session, user.id)
|
|
||||||
version = (await db_session.execute(
|
|
||||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
|
||||||
)).scalars().first()
|
|
||||||
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
|
|
||||||
db_session.add(station)
|
|
||||||
await db_session.commit()
|
|
||||||
runs = []
|
|
||||||
for i, lot in enumerate(["L1", "L2"]):
|
|
||||||
run = ProductionRun(
|
|
||||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
|
||||||
operator_id=user.id, status="closed", lot_number=lot,
|
|
||||||
started_at=datetime(2026, 1, 1 + i),
|
|
||||||
)
|
|
||||||
db_session.add(run)
|
|
||||||
runs.append(run)
|
|
||||||
await db_session.commit()
|
|
||||||
return recipe, runs
|
|
||||||
|
|
||||||
async def test_filter_by_run_and_unfiltered(
|
|
||||||
self, client, measurement_tec_user, metrologist_user, db_session,
|
|
||||||
):
|
|
||||||
from src.backend.models.orm.measurement import Measurement
|
|
||||||
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
|
|
||||||
subtask_id, version_id = await _seed_measurements(
|
|
||||||
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
|
|
||||||
)
|
|
||||||
# Assegna le prime due misure al run 1, la terza al run 2.
|
|
||||||
from sqlalchemy import select
|
|
||||||
ms = (await db_session.execute(
|
|
||||||
select(Measurement).order_by(Measurement.id)
|
|
||||||
)).scalars().all()
|
|
||||||
ms[0].production_run_id = runs[0].id
|
|
||||||
ms[1].production_run_id = runs[0].id
|
|
||||||
ms[2].production_run_id = runs[1].id
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
h = auth_headers(metrologist_user)
|
|
||||||
r1 = await client.get("/api/statistics/summary", headers=h,
|
|
||||||
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
|
|
||||||
assert r1.json()["total"] == 2
|
|
||||||
r2 = await client.get("/api/statistics/summary", headers=h,
|
|
||||||
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
|
|
||||||
assert r2.json()["total"] == 1
|
|
||||||
r_all = await client.get("/api/statistics/summary", headers=h,
|
|
||||||
params={"recipe_id": recipe.id})
|
|
||||||
assert r_all.json()["total"] == 3
|
|
||||||
|
|
||||||
runs_resp = await client.get("/api/statistics/runs", headers=h,
|
|
||||||
params={"recipe_id": recipe.id})
|
|
||||||
assert runs_resp.status_code == 200
|
|
||||||
data = runs_resp.json()
|
|
||||||
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
|
|
||||||
assert [r["measurement_count"] for r in data] == [1, 2]
|
|
||||||
assert data[0]["lot_number"] == "L2"
|
|
||||||
assert data[0]["station_name"] == "Stazione 58"
|
|
||||||
assert data[0]["closed_at"] is None
|
|
||||||
|
|
||||||
async def test_runs_require_metrologist(
|
|
||||||
self, client, measurement_tec_user, db_session,
|
|
||||||
):
|
|
||||||
resp = await client.get("/api/statistics/runs",
|
|
||||||
headers=auth_headers(measurement_tec_user),
|
|
||||||
params={"recipe_id": 1})
|
|
||||||
assert resp.status_code == 403
|
|
||||||
|
|||||||
@@ -59,6 +59,45 @@ def _post_login_landing():
|
|||||||
return url_for("auth.profile")
|
return url_for("auth.profile")
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_login(response, fallback_name):
|
||||||
|
"""Dopo una risposta di login riuscita: sessione, preferenze, impostazioni (#59).
|
||||||
|
|
||||||
|
Comune al login con password e al login con PIN, cosi' i due restano identici.
|
||||||
|
Ritorna l'utente salvato in sessione.
|
||||||
|
"""
|
||||||
|
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
||||||
|
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
||||||
|
# limite configurato non ha mai effetto (#33).
|
||||||
|
session.permanent = True
|
||||||
|
session["api_key"] = response["api_key"]
|
||||||
|
session["user"] = response["user"]
|
||||||
|
session["user_id"] = response["user"]["id"]
|
||||||
|
|
||||||
|
# Applica preferenze utente
|
||||||
|
user = response["user"]
|
||||||
|
if user.get("language_pref"):
|
||||||
|
session["language"] = user["language_pref"]
|
||||||
|
if user.get("theme_pref"):
|
||||||
|
session["theme"] = user["theme_pref"]
|
||||||
|
|
||||||
|
# Carica impostazioni di sistema
|
||||||
|
settings_resp = api_client.get("/api/settings")
|
||||||
|
if not settings_resp.get("error"):
|
||||||
|
logo_path = settings_resp.get("company_logo_path")
|
||||||
|
if logo_path:
|
||||||
|
session["company_logo"] = logo_path
|
||||||
|
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||||
|
if auto_logout:
|
||||||
|
try:
|
||||||
|
session["auto_logout_minutes"] = int(auto_logout)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", fallback_name)), "success")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""Login page and form handler."""
|
||||||
@@ -81,35 +120,7 @@ def login():
|
|||||||
flash(response.get("detail", _("Credenziali non valide")), "error")
|
flash(response.get("detail", _("Credenziali non valide")), "error")
|
||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
user = _complete_login(response, username)
|
||||||
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
|
|
||||||
# limite configurato non ha mai effetto (#33).
|
|
||||||
session.permanent = True
|
|
||||||
session["api_key"] = response["api_key"]
|
|
||||||
session["user"] = response["user"]
|
|
||||||
session["user_id"] = response["user"]["id"]
|
|
||||||
|
|
||||||
# Applica preferenze utente
|
|
||||||
user = response["user"]
|
|
||||||
if user.get("language_pref"):
|
|
||||||
session["language"] = user["language_pref"]
|
|
||||||
if user.get("theme_pref"):
|
|
||||||
session["theme"] = user["theme_pref"]
|
|
||||||
|
|
||||||
# Carica impostazioni di sistema
|
|
||||||
settings_resp = api_client.get("/api/settings")
|
|
||||||
if not settings_resp.get("error"):
|
|
||||||
logo_path = settings_resp.get("company_logo_path")
|
|
||||||
if logo_path:
|
|
||||||
session["company_logo"] = logo_path
|
|
||||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
|
||||||
if auto_logout:
|
|
||||||
try:
|
|
||||||
session["auto_logout_minutes"] = int(auto_logout)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
|
||||||
|
|
||||||
# Redirect dopo login (in base al ruolo)
|
# Redirect dopo login (in base al ruolo)
|
||||||
return redirect(_post_login_landing())
|
return redirect(_post_login_landing())
|
||||||
@@ -121,6 +132,55 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/pin-users", methods=["GET"])
|
||||||
|
def pin_users():
|
||||||
|
"""Elenco nomi per la vista Operatore del login (#59): solo id e nome.
|
||||||
|
|
||||||
|
Pubblica come il login; se il backend non risponde o non ha il PIN, lista
|
||||||
|
vuota e la pagina apre il form password.
|
||||||
|
"""
|
||||||
|
if session.get("api_key"):
|
||||||
|
return jsonify([])
|
||||||
|
try:
|
||||||
|
resp = api_client.get("/api/auth/pin-users")
|
||||||
|
except Exception:
|
||||||
|
return jsonify([])
|
||||||
|
if not isinstance(resp, list):
|
||||||
|
return jsonify([])
|
||||||
|
return jsonify([
|
||||||
|
{"id": u.get("id"), "display_name": u.get("display_name", "")}
|
||||||
|
for u in resp if isinstance(u, dict)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/login-pin", methods=["POST"])
|
||||||
|
def login_pin():
|
||||||
|
"""Login con nome + PIN (#59). JSON in/out; CSRF come il login password
|
||||||
|
(header X-CSRFToken, gestito da CSRFProtect)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
user_id = data.get("user_id")
|
||||||
|
pin = str(data.get("pin", ""))
|
||||||
|
if not isinstance(user_id, int) or not (len(pin) == 4 and pin.isdigit()):
|
||||||
|
return jsonify({"error": True, "status_code": 422, "detail": _("Il PIN deve essere di 4 cifre")}), 422
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = api_client.post("/api/auth/login-pin", data={"user_id": user_id, "pin": pin})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": True, "status_code": 502,
|
||||||
|
"detail": _("Errore di connessione al server: %(error)s", error=str(e))}), 502
|
||||||
|
|
||||||
|
if response.get("error"):
|
||||||
|
status = response.get("status_code") or 401
|
||||||
|
if status == 401:
|
||||||
|
detail = _("PIN errato")
|
||||||
|
else:
|
||||||
|
detail = response.get("detail") or _("Credenziali non valide")
|
||||||
|
return jsonify({"error": True, "status_code": status, "detail": detail}), status
|
||||||
|
|
||||||
|
_complete_login(response, "") # include il flash di benvenuto
|
||||||
|
return jsonify({"ok": True, "redirect": _post_login_landing()})
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/logout", methods=["POST"])
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
def logout():
|
def logout():
|
||||||
"""Clear session and redirect to login."""
|
"""Clear session and redirect to login."""
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str):
|
|||||||
for key in [
|
for key in [
|
||||||
"recipe_id", "version_id", "subtask_id",
|
"recipe_id", "version_id", "subtask_id",
|
||||||
"date_from", "date_to", "operator_id",
|
"date_from", "date_to", "operator_id",
|
||||||
"lot_number", "serial_number", "n_bins", "production_run_id", # #58
|
"lot_number", "serial_number", "n_bins",
|
||||||
]:
|
]:
|
||||||
val = request.args.get(key)
|
val = request.args.get(key)
|
||||||
if val is not None and val != "":
|
if val is not None and val != "":
|
||||||
@@ -127,27 +127,6 @@ def api_subtasks():
|
|||||||
return jsonify(resp)
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
@statistics_bp.route("/api/runs")
|
|
||||||
@login_required
|
|
||||||
@role_required("Metrologist")
|
|
||||||
def api_runs():
|
|
||||||
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
|
|
||||||
params = {}
|
|
||||||
recipe_id = request.args.get("recipe_id")
|
|
||||||
if recipe_id:
|
|
||||||
params["recipe_id"] = recipe_id
|
|
||||||
|
|
||||||
resp = api_client.get("/api/statistics/runs", params=params)
|
|
||||||
|
|
||||||
if isinstance(resp, dict) and resp.get("error"):
|
|
||||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
|
||||||
if resp.get("session_expired"):
|
|
||||||
error_body["session_expired"] = True
|
|
||||||
return jsonify(error_body), resp.get("status_code", 500)
|
|
||||||
|
|
||||||
return jsonify(resp)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PROXY REPORT PDF → FastAPI
|
# PROXY REPORT PDF → FastAPI
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -158,7 +137,7 @@ def _proxy_report(endpoint: str):
|
|||||||
for key in [
|
for key in [
|
||||||
"recipe_id", "version_id", "subtask_id",
|
"recipe_id", "version_id", "subtask_id",
|
||||||
"date_from", "date_to", "operator_id",
|
"date_from", "date_to", "operator_id",
|
||||||
"lot_number", "serial_number", "production_run_id", # #58
|
"lot_number", "serial_number",
|
||||||
]:
|
]:
|
||||||
val = request.args.get(key)
|
val = request.args.get(key)
|
||||||
if val is not None and val != "":
|
if val is not None and val != "":
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/* Login Operatore: nome + PIN a 4 cifre (#59).
|
||||||
|
* Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
|
||||||
|
* Contratto: GET /auth/pin-users -> [{id, display_name}];
|
||||||
|
* POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
|
||||||
|
function pinLogin() {
|
||||||
|
return {
|
||||||
|
mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
|
||||||
|
users: [],
|
||||||
|
selected: null,
|
||||||
|
pin: '',
|
||||||
|
error: '',
|
||||||
|
busy: false,
|
||||||
|
|
||||||
|
init: function () {
|
||||||
|
var self = this;
|
||||||
|
fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(function (list) {
|
||||||
|
self.users = Array.isArray(list) ? list : [];
|
||||||
|
if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
|
||||||
|
})
|
||||||
|
.catch(function () { self.users = []; self.mode = 'password'; });
|
||||||
|
},
|
||||||
|
select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
|
||||||
|
back: function () { this.selected = null; this.pin = ''; this.error = ''; },
|
||||||
|
del: function () { this.pin = this.pin.slice(0, -1); },
|
||||||
|
press: function (d) {
|
||||||
|
if (this.busy || this.pin.length >= 4) return;
|
||||||
|
this.pin += d;
|
||||||
|
this.error = '';
|
||||||
|
if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
|
||||||
|
},
|
||||||
|
submit: function () {
|
||||||
|
var self = this;
|
||||||
|
var csrf = document.querySelector('meta[name=csrf-token]');
|
||||||
|
self.busy = true;
|
||||||
|
fetch(window.TMF_PIN.loginUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRFToken': (csrf && csrf.content) || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
|
||||||
|
self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
|
||||||
|
self.pin = '';
|
||||||
|
self.busy = false;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
self.error = window.TMF_PIN.msgNet;
|
||||||
|
self.pin = '';
|
||||||
|
self.busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,8 +26,50 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<div x-data="pinLogin()" x-init="init()">
|
||||||
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
<!-- Vista Operatore: nome + PIN (#59) -->
|
||||||
|
<div x-show="mode === 'pin'" x-cloak>
|
||||||
|
<!-- Elenco nomi -->
|
||||||
|
<div x-show="!selected">
|
||||||
|
<p class="text-sm text-center text-[var(--text-secondary)] mb-4">{{ _('Chi sei?') }}</p>
|
||||||
|
<div class="grid grid-cols-2 gap-3" id="pin-user-list">
|
||||||
|
<template x-for="u in users" :key="u.id">
|
||||||
|
<button type="button" @click="select(u)"
|
||||||
|
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tastierino -->
|
||||||
|
<div x-show="selected" id="pin-pad">
|
||||||
|
<p class="text-center font-semibold mb-1" x-text="selected && selected.display_name"></p>
|
||||||
|
<p class="text-sm text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
|
||||||
|
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
|
||||||
|
<template x-for="i in 4" :key="i">
|
||||||
|
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
|
||||||
|
:class="pin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-center text-red-600 min-h-[1.25rem] mb-2" role="alert" x-text="error"></p>
|
||||||
|
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
|
||||||
|
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
|
||||||
|
<button type="button" @click="press(d)" :disabled="busy"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
||||||
|
</template>
|
||||||
|
<button type="button" @click="back()" :disabled="busy"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
||||||
|
<button type="button" @click="press('0')" :disabled="busy"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
||||||
|
<button type="button" @click="del()" :disabled="busy"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-6 text-center">
|
||||||
|
<a href="#" @click.prevent="mode = 'password'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con password') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form (password: admin e chi non ha PIN) -->
|
||||||
|
<form x-show="mode === 'password'" method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!-- Username -->
|
<!-- Username -->
|
||||||
<div>
|
<div>
|
||||||
@@ -98,6 +140,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-center" x-show="mode === 'password' && users.length" x-cloak>
|
||||||
|
<a href="#" @click.prevent="mode = 'pin'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con PIN') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Help Text -->
|
<!-- Help Text -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<p class="text-xs text-[var(--text-muted)]">
|
<p class="text-xs text-[var(--text-muted)]">
|
||||||
@@ -115,3 +162,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
/* Configurazione e testi per pin-pad.js (#59) */
|
||||||
|
window.TMF_PIN = {
|
||||||
|
usersUrl: {{ url_for('auth.pin_users')|tojson }},
|
||||||
|
loginUrl: {{ url_for('auth.login_pin')|tojson }},
|
||||||
|
msgNet: {{ _('Errore di connessione al server')|tojson }}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/pin-pad.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -35,19 +35,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sessione di produzione (#58) -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
|
|
||||||
<select x-model="selectedRunId"
|
|
||||||
:disabled="runs.length === 0"
|
|
||||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
|
||||||
<option value="">{{ _("Tutte le sessioni") }}</option>
|
|
||||||
<template x-for="r in runs" :key="r.id">
|
|
||||||
<option :value="r.id" x-text="runLabel(r)"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subtask -->
|
<!-- Subtask -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||||
@@ -271,8 +258,6 @@ function spcDashboard() {
|
|||||||
// Filter state
|
// Filter state
|
||||||
selectedRecipeId: '',
|
selectedRecipeId: '',
|
||||||
selectedSubtaskId: '',
|
selectedSubtaskId: '',
|
||||||
selectedRunId: '', // #58
|
|
||||||
runs: [], // #58 sessioni della ricetta
|
|
||||||
dateFrom: '',
|
dateFrom: '',
|
||||||
dateTo: '',
|
dateTo: '',
|
||||||
subtasks: [],
|
subtasks: [],
|
||||||
@@ -296,8 +281,6 @@ function spcDashboard() {
|
|||||||
|
|
||||||
async onRecipeChange() {
|
async onRecipeChange() {
|
||||||
this.selectedSubtaskId = '';
|
this.selectedSubtaskId = '';
|
||||||
this.selectedRunId = ''; // #58
|
|
||||||
this.runs = [];
|
|
||||||
this.subtasks = [];
|
this.subtasks = [];
|
||||||
this.hasData = false;
|
this.hasData = false;
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
@@ -307,15 +290,6 @@ function spcDashboard() {
|
|||||||
|
|
||||||
if (!this.selectedRecipeId) return;
|
if (!this.selectedRecipeId) return;
|
||||||
|
|
||||||
// #58: sessioni della ricetta per il filtro «Sessione»
|
|
||||||
try {
|
|
||||||
const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId);
|
|
||||||
const rdata = await rresp.json();
|
|
||||||
if (Array.isArray(rdata)) this.runs = rdata;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading runs:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load subtasks for dropdown
|
// Load subtasks for dropdown
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
||||||
@@ -328,12 +302,6 @@ function spcDashboard() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// #58: etichetta sessione = data, stazione, operatore, lotto
|
|
||||||
runLabel(r) {
|
|
||||||
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
|
||||||
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadData() {
|
async loadData() {
|
||||||
if (!this.selectedRecipeId) return;
|
if (!this.selectedRecipeId) return;
|
||||||
|
|
||||||
@@ -344,7 +312,6 @@ function spcDashboard() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('recipe_id', this.selectedRecipeId);
|
params.set('recipe_id', this.selectedRecipeId);
|
||||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
|
||||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
|
||||||
@@ -450,7 +417,6 @@ function spcDashboard() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('recipe_id', this.selectedRecipeId);
|
params.set('recipe_id', this.selectedRecipeId);
|
||||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
|
||||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
|
||||||
|
|||||||
@@ -136,3 +136,67 @@ class TestLanguageSwitch:
|
|||||||
with client.session_transaction() as sess:
|
with client.session_transaction() as sess:
|
||||||
# Invalid language should NOT be stored
|
# Invalid language should NOT be stored
|
||||||
assert sess.get("language") != "xx"
|
assert sess.get("language") != "xx"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPinLogin:
|
||||||
|
"""Login Operatore con nome + PIN (#59)."""
|
||||||
|
|
||||||
|
USER = {
|
||||||
|
"id": 7, "username": "mario", "display_name": "Mario Rossi",
|
||||||
|
"roles": ["MeasurementTec"], "is_admin": False,
|
||||||
|
"language_pref": "it", "theme_pref": "light", "active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_login_page_has_pin_view_and_password_link(self, client):
|
||||||
|
resp = client.get("/auth/login")
|
||||||
|
assert b"pin-pad.js" in resp.data
|
||||||
|
assert b"pin-pad" in resp.data # tastierino presente nel markup
|
||||||
|
assert b'name="password"' in resp.data # form password ancora presente
|
||||||
|
|
||||||
|
def test_pin_users_listed(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = [
|
||||||
|
{"id": 7, "display_name": "Mario Rossi", "secret": "x"},
|
||||||
|
]
|
||||||
|
resp = client.get("/auth/pin-users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == [{"id": 7, "display_name": "Mario Rossi"}]
|
||||||
|
|
||||||
|
def test_pin_users_empty_on_error(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"error": True, "detail": "boom"}
|
||||||
|
assert client.get("/auth/pin-users").get_json() == []
|
||||||
|
|
||||||
|
def test_pin_users_empty_list_falls_back(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = []
|
||||||
|
assert client.get("/auth/pin-users").get_json() == []
|
||||||
|
|
||||||
|
def test_login_pin_success_sets_session(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"api_key": "pin-key", "user": self.USER}
|
||||||
|
mock_api_client.get.return_value = {}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "1234"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
assert resp.get_json()["redirect"]
|
||||||
|
mock_api_client.post.assert_called_once_with(
|
||||||
|
"/api/auth/login-pin", data={"user_id": 7, "pin": "1234"})
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert sess["api_key"] == "pin-key"
|
||||||
|
assert sess["user_id"] == 7
|
||||||
|
assert sess.permanent is True
|
||||||
|
|
||||||
|
def test_login_pin_wrong_pin_401(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"error": True, "status_code": 401, "detail": "x"}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["detail"] == "PIN errato"
|
||||||
|
|
||||||
|
def test_login_pin_locked_423_shows_detail(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 423, "detail": "Utente bloccato fino alle 10:15"}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
||||||
|
assert resp.status_code == 423
|
||||||
|
assert "10:15" in resp.get_json()["detail"]
|
||||||
|
|
||||||
|
def test_login_pin_invalid_pin_422(self, client, mock_api_client):
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "12"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|||||||
@@ -112,21 +112,3 @@ class TestSPCProxy:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert "cp" in data
|
assert "cp" in data
|
||||||
|
|
||||||
|
|
||||||
class TestRunsProxy:
|
|
||||||
"""#58: filtro per sessione di produzione."""
|
|
||||||
|
|
||||||
def test_runs_proxy(self, logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}]
|
|
||||||
resp = logged_in_client.get("/statistics/api/runs?recipe_id=7")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.get_json()[0]["id"] == 5
|
|
||||||
args, kwargs = mock_api_client.get.call_args
|
|
||||||
assert args[0] == "/api/statistics/runs"
|
|
||||||
assert kwargs["params"] == {"recipe_id": "7"}
|
|
||||||
|
|
||||||
def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client):
|
|
||||||
mock_api_client.get.return_value = {"total": 1}
|
|
||||||
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
|
||||||
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user