feat(production): dai una vita propria alla produzione, lato server
Punto 1 del documento modifiche del 28/07, il prerequisito su cui poggiano i punti 3, 4 e 6. Lo stato di una produzione viveva dentro una pagina del browser: timer, conteggio cicli e flag "produzione avviata" erano variabili Alpine di task_execute.html, e la navigazione fra task e' un ricaricamento completo, quindi cambiando task si perdeva tutto. Da qui il loop di misura che non reggeva, il fermo linea che non aveva nulla da fermare e l'assenza di storico. Nuove tabelle production_runs e production_events (migrazione 005), endpoint REST senza stato in memoria di processo - con un'app di stazione installata su ogni PC il database e' l'unico posto condiviso - e il frontend che legge lo stato all'apertura invece di tenerlo in memoria. Tre scelte di modello: - la scadenza e' un timestamp assoluto (next_measurement_at), non un contatore: il countdown si ricalcola da li' a ogni caricamento, e lasciarne andare la differenza sotto zero dara' gratis il contatore del ritardo del punto 3. Al client vanno i secondi gia' calcolati, non il timestamp: un datetime naive verrebbe letto nel fuso del browser e il conto sarebbe sfasato dell'offset UTC; - l'intervallo di misura e' copiato sulla produzione, non referenziato: modificare la ricetta a produzione avviata non deve spostare una scadenza in corso; - active_station_id rispecchia la stazione finche' la produzione e' aperta e va a NULL alla chiusura. Con un vincolo unico sopra, "una stazione = una produzione aperta" e' una garanzia del database e non un controllo soggetto a race; i NULL non collidono, quindi le produzioni chiuse si accumulano senza disturbo. Il fermo linea congela il conto alla rovescia e alla ripresa la scadenza viene traslata della durata del fermo, non ricalcolata: un fermo non regala ne' toglie tempo all'operatore. L'autorizzazione del capoturno passa da authenticate_user e non da un login, che rigenererebbe la sua API key buttando giu' la sessione che ha aperta altrove. La migrazione e' stata eseguita davvero, non solo scritta, su uno SQLite usa e getta: upgrade e downgrade girano e le colonne coincidono con i modelli. La prova ha trovato un difetto - create_unique_constraint dopo create_table e' un ALTER, che SQLite rifiuta - ora il vincolo e' dichiarato dentro create_table. Fuori da questo commit, per stare nei confini del punto 1: l'API espone gia' pause, resume e close, ma i pulsanti fermo linea e fine produzione restano da collegare (punto 6), e il rientro forzato sulla misura allo scadere e' il punto 3. Corretti due difetti trovati strada facendo: env.py non importava ne' Station ne' ProductionRun, quindi l'autogenerate di Alembic era gia' cieco sulle stazioni; e task_execute.html, lo schermo con piu' JavaScript dell'applicazione, non era coperto dal test di sintassi. Aggiungerlo ha richiesto di correggere l'helper, che validava le espressioni Alpine solo come espressione singola e bocciava @click="a = false; b = true", forma che Alpine accetta: ora prova entrambe le letture e fallisce solo se cadono tutte e due. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
"""Integration tests for /api/production-runs.
|
||||
|
||||
The point of these tables is that a production outlives the page it was started
|
||||
from, so the tests lean on what must survive: the deadline, the cycle count, the
|
||||
trace of what happened, and the rule that a station has one production at a time.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.services import auth_service, production_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _station(db_session, user_id: int, code: str = "ST-PROD") -> Station:
|
||||
station = Station(code=code, name=f"Stazione {code}", active=True, created_by=user_id)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(station)
|
||||
return station
|
||||
|
||||
|
||||
async def _recipe_with_interval(db_session, user_id: int, minutes, code="REC-PROD") -> Recipe:
|
||||
recipe = await create_test_recipe(db_session, user_id=user_id, code=code)
|
||||
recipe.measurement_interval_minutes = minutes
|
||||
await db_session.commit()
|
||||
await db_session.refresh(recipe)
|
||||
return recipe
|
||||
|
||||
|
||||
async def _supervisor(db_session, username="capoturno", password="TurnoPwd1"):
|
||||
user = await auth_service.create_user(
|
||||
db_session,
|
||||
username=username,
|
||||
password=password,
|
||||
display_name="Capo Turno",
|
||||
roles=["Supervisor"],
|
||||
)
|
||||
await db_session.commit()
|
||||
return user, password
|
||||
|
||||
|
||||
async def _advance_clock(db_session, run_id: int, delta: timedelta) -> None:
|
||||
"""Simulate wall-clock time passing, without sleeping in a test.
|
||||
|
||||
Winding every instant stored on the run back by D is indistinguishable from D
|
||||
having elapsed. Moving only one of them would not be time passing - it would be
|
||||
rewriting history, and would measure the wrong thing.
|
||||
"""
|
||||
run = (await db_session.execute(
|
||||
select(ProductionRun).where(ProductionRun.id == run_id)
|
||||
)).scalar_one()
|
||||
run.started_at = run.started_at - delta
|
||||
if run.next_measurement_at is not None:
|
||||
run.next_measurement_at = run.next_measurement_at - delta
|
||||
if run.paused_at is not None:
|
||||
run.paused_at = run.paused_at - delta
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def _open(client, user, station, recipe, **extra):
|
||||
return await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id, **extra},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_open_run_requires_auth(client: AsyncClient):
|
||||
resp = await client.post("/api/production-runs", json={"station_code": "X", "recipe_id": 1})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_open_run_starts_the_countdown(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id)
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, minutes=30)
|
||||
|
||||
resp = await _open(client, measurement_tec_user, station, recipe)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "running"
|
||||
assert body["cycle_count"] == 0
|
||||
assert body["measurement_interval_minutes"] == 30
|
||||
# About half an hour out, allowing for the round trip.
|
||||
assert 29 * 60 <= body["seconds_to_next_measurement"] <= 30 * 60
|
||||
assert body["overdue"] is False
|
||||
|
||||
|
||||
async def test_open_run_without_interval_has_no_deadline(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-NOINT")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, None, code="REC-NOINT")
|
||||
|
||||
body = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||
assert body["next_measurement_at"] is None
|
||||
assert body["seconds_to_next_measurement"] is None
|
||||
assert body["overdue"] is False
|
||||
|
||||
|
||||
async def test_open_run_snapshots_the_interval(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""Editing the recipe mid-production must not move a running deadline."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-SNAP")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 20, code="REC-SNAP")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
|
||||
recipe.measurement_interval_minutes = 90
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/production-runs/{run_id}", headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.json()["measurement_interval_minutes"] == 20
|
||||
|
||||
|
||||
async def test_station_cannot_have_two_open_runs(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-ONE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ONE")
|
||||
first = await _open(client, measurement_tec_user, station, recipe)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = await _open(client, measurement_tec_user, station, recipe)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
async def test_open_run_rejects_unknown_station(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-NOSTAT")
|
||||
resp = await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": "ST-GHOST", "recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading the current run - what replaces the in-page state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_current_run_is_null_when_nothing_is_running(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-IDLE")
|
||||
resp = await client.get(
|
||||
"/api/production-runs/current",
|
||||
params={"station_code": station.code},
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() is None
|
||||
|
||||
|
||||
async def test_current_run_survives_and_keeps_counting_down(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""The deadline is stored, so a fresh page load rejoins the same countdown."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-KEEP")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 45, code="REC-KEEP")
|
||||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||
|
||||
later = await client.get(
|
||||
"/api/production-runs/current",
|
||||
params={"station_code": station.code},
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
body = later.json()
|
||||
assert body["id"] == opened["id"]
|
||||
assert body["next_measurement_at"] == opened["next_measurement_at"]
|
||||
assert body["seconds_to_next_measurement"] <= opened["seconds_to_next_measurement"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cycle_restarts_the_interval_and_counts(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-CYC")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CYC")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["cycle_count"] == 1
|
||||
assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60
|
||||
|
||||
|
||||
async def test_overdue_run_reports_negative_seconds(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""Past the interval the countdown keeps going the other way, so the delay shows."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-LATE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 5, code="REC-LATE")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
|
||||
await _advance_clock(db_session, run_id, timedelta(minutes=8))
|
||||
|
||||
body = (await client.get(
|
||||
"/api/production-runs/current",
|
||||
params={"station_code": station.code},
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)).json()
|
||||
assert body["overdue"] is True
|
||||
assert -190 <= body["seconds_to_next_measurement"] <= -170
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fermo linea / ripresa / chiusura
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_pause_requires_supervisor_credentials(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-PAUSE1")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE1")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"supervisor_username": "nobody", "supervisor_password": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_pause_rejects_non_supervisor(
|
||||
client: AsyncClient, measurement_tec_user, maker_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-PAUSE2")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE2")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": maker_user.username,
|
||||
"supervisor_password": "testpassword123",
|
||||
},
|
||||
)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
async def test_pause_freezes_the_countdown(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""A fermo linea must not eat the measurement interval."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-FREEZE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-FREEZE")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
supervisor, password = await _supervisor(db_session, username="capo-freeze")
|
||||
|
||||
paused = (await client.post(
|
||||
f"/api/production-runs/{run_id}/pause",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||
)).json()
|
||||
assert paused["status"] == "paused"
|
||||
frozen = paused["seconds_to_next_measurement"]
|
||||
|
||||
# An hour goes by with the line stopped: were the countdown still ticking, the
|
||||
# run would now be deeply overdue.
|
||||
await _advance_clock(db_session, run_id, timedelta(hours=1))
|
||||
|
||||
still = (await client.get(
|
||||
"/api/production-runs/current",
|
||||
params={"station_code": station.code},
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)).json()
|
||||
assert still["status"] == "paused"
|
||||
assert still["seconds_to_next_measurement"] == frozen
|
||||
assert still["overdue"] is False
|
||||
|
||||
|
||||
async def test_resume_gives_back_the_time_the_stop_took(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-RESUME")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-RESUME")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
supervisor, password = await _supervisor(db_session, username="capo-resume")
|
||||
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||||
|
||||
frozen = (await client.post(
|
||||
f"/api/production-runs/{run_id}/pause",
|
||||
headers=auth_headers(measurement_tec_user), json=creds,
|
||||
)).json()["seconds_to_next_measurement"]
|
||||
|
||||
# The line stays down for ten minutes.
|
||||
await _advance_clock(db_session, run_id, timedelta(minutes=10))
|
||||
|
||||
resumed = (await client.post(
|
||||
f"/api/production-runs/{run_id}/resume",
|
||||
headers=auth_headers(measurement_tec_user), json=creds,
|
||||
)).json()
|
||||
assert resumed["status"] == "running"
|
||||
# The ten minutes of stop were handed back, not charged to the operator.
|
||||
assert abs(resumed["seconds_to_next_measurement"] - frozen) <= 2
|
||||
|
||||
|
||||
async def test_cycle_refused_while_paused(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-PCYC")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PCYC")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
supervisor, password = await _supervisor(db_session, username="capo-pcyc")
|
||||
|
||||
await client.post(
|
||||
f"/api/production-runs/{run_id}/pause",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_close_stops_the_timer_and_frees_the_station(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-CLOSE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CLOSE")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
supervisor, password = await _supervisor(db_session, username="capo-close")
|
||||
|
||||
closed = (await client.post(
|
||||
f"/api/production-runs/{run_id}/close",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||
)).json()
|
||||
assert closed["status"] == "closed"
|
||||
assert closed["closed_by"] == supervisor.id
|
||||
assert closed["seconds_to_next_measurement"] is None
|
||||
|
||||
# Nothing is open at the station any more...
|
||||
current = await client.get(
|
||||
"/api/production-runs/current",
|
||||
params={"station_code": station.code},
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert current.json() is None
|
||||
|
||||
# ...and a new production can start there.
|
||||
again = await _open(client, measurement_tec_user, station, recipe)
|
||||
assert again.status_code == 201
|
||||
|
||||
|
||||
async def test_closed_run_refuses_further_actions(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-DEAD")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-DEAD")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
supervisor, password = await _supervisor(db_session, username="capo-dead")
|
||||
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||||
|
||||
await client.post(
|
||||
f"/api/production-runs/{run_id}/close",
|
||||
headers=auth_headers(measurement_tec_user), json=creds,
|
||||
)
|
||||
cycle = await client.post(
|
||||
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert cycle.status_code == 409
|
||||
pause = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause",
|
||||
headers=auth_headers(measurement_tec_user), json=creds,
|
||||
)
|
||||
assert pause.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The trace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_run_records_what_happened(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""The history a production never had: start, cycles, stop, resume, close."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-TRACE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-TRACE")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
supervisor, password = await _supervisor(db_session, username="capo-trace")
|
||||
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||||
headers = auth_headers(measurement_tec_user)
|
||||
|
||||
await client.post(f"/api/production-runs/{run_id}/cycle", headers=headers)
|
||||
await client.post(f"/api/production-runs/{run_id}/pause", headers=headers, json=creds)
|
||||
await client.post(f"/api/production-runs/{run_id}/resume", headers=headers, json=creds)
|
||||
await client.post(f"/api/production-runs/{run_id}/close", headers=headers, json=creds)
|
||||
|
||||
events = (await client.get(
|
||||
f"/api/production-runs/{run_id}", headers=headers,
|
||||
)).json()["events"]
|
||||
assert [e["event_type"] for e in events] == [
|
||||
"start", "cycle_completed", "line_stop", "resume", "close",
|
||||
]
|
||||
# Who authorised what is on the record.
|
||||
by_type = {e["event_type"]: e for e in events}
|
||||
assert by_type["line_stop"]["supervisor_id"] == supervisor.id
|
||||
assert by_type["close"]["supervisor_id"] == supervisor.id
|
||||
assert by_type["cycle_completed"]["supervisor_id"] is None
|
||||
|
||||
|
||||
async def test_traceability_travels_with_the_run(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-LOT")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LOT")
|
||||
body = (await _open(
|
||||
client, measurement_tec_user, station, recipe,
|
||||
lot_number="LOT-42", serial_number="SN-7",
|
||||
)).json()
|
||||
assert body["lot_number"] == "LOT-42"
|
||||
assert body["serial_number"] == "SN-7"
|
||||
Reference in New Issue
Block a user