From 4e2c36e925b96979765e16261ae275be4710d148 Mon Sep 17 00:00:00 2001 From: Miquel Date: Sun, 1 Nov 2026 16:11:22 +0100 Subject: [PATCH 01/19] feat: group account actions in a header menu and add the Arena Cat logo Co-Authored-By: Claude Sonnet 5.5 --- frontend/README.md | 7 +- frontend/src/App.tsx | 80 ++++------------ frontend/src/assets/arena-cat-logo.svg | 1 + frontend/src/components/AccountMenu.tsx | 118 ++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 62 deletions(-) create mode 100644 frontend/src/assets/arena-cat-logo.svg create mode 100644 frontend/src/components/AccountMenu.tsx diff --git a/frontend/README.md b/frontend/README.md index 04cba66..770720d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -24,6 +24,10 @@ fragments d'àudio en català i valorant les transcripcions. Les preguntes i el llindar provenen del backend; l'acreditació queda desada a `users.qualified_at` i evita repetir la prova en sessions posteriors. +La capçalera mostra el logotip i el nom, que duen a l'avaluació (`/`), i l'enllaç al rànquing. +Amb sessió hi apareix també el **menú del compte** (recordatoris, tancar la sessió i donar de +baixa el compte). + El **Rànquing** (`/ranking`) és accessible des de la capçalera amb sessió o sense. Amb sessió, el botó **Avalua** permet tornar a l’avaluació. @@ -122,6 +126,7 @@ s'hagi decidit. | `src/taskStore.ts` | Conserva la tasca en curs entre recàrregues i llegeix el `vote_after` del token. | | `src/errors.ts` | Tradueix al català els errors de validació de FastAPI, que arriben en anglès. | | `src/App.tsx` | Estat de sessió i disposició general (capçalera i peu). | +| `src/components/AccountMenu.tsx` | Menú desplegable del compte a la capçalera. | | `src/components/Login.tsx` | Alta i inici de sessió. | | `src/components/QualificationView.tsx` | Formulari de competència lingüística, correcció i resultat. | | `src/components/TaskView.tsx` | Filtre de categoria, tasca, vot, omissió i final de recorregut. | @@ -153,4 +158,4 @@ s'hagi decidit. Si acaben necessitant URL pròpia (compartir enllaç, botó «enrere»), caldrà `react-router-dom`; si no, es poden fer igual que ara, amb estat de React. -La capçalera ofereix **Recordatoris** (`/reminders`) amb sessió iniciada. Als múltiples de deu vots es mostra una invitació per activar els recordatoris setmanals, com a màxim un cop cada trenta dies i només si no estan activats. Consulteu els [recordatoris per correu](../docs/usuaris_autenticacio.md#recordatoris-voluntaris) per a les preferències i la baixa sense sessió. +El menú del compte ofereix **Recordatoris** (`/reminders`) amb sessió iniciada. Als múltiples de deu vots es mostra una invitació per activar els recordatoris setmanals, com a màxim un cop cada trenta dies i només si no estan activats. Consulteu els [recordatoris per correu](../docs/usuaris_autenticacio.md#recordatoris-voluntaris) per a les preferències i la baixa sense sessió. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 82ef920..34dabed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from "react"; import { Link, Navigate, Route, Routes, useNavigate, useSearchParams } from "react-router-dom"; import { api, UNAUTHENTICATED_EVENT } from "./api"; +import arenaLogo from "./assets/arena-cat-logo.svg"; import logotip from "./assets/softcatala-logotip.png"; +import AccountMenu from "./components/AccountMenu"; import ActivityView from "./components/ActivityView"; import DeleteAccountDialog from "./components/DeleteAccountDialog"; import ForgotPasswordView from "./components/ForgotPasswordView"; @@ -93,52 +95,28 @@ export default function App() {
-
-

Arena Cat

-

Avaluació humana de models d'IA en català

-
+ + + + Arena Cat + + Avaluació humana de models d'IA en català + + +
Rànquing {session?.authenticated && ( - <> - - Recordatoris - - {session.email} - - {/* Icona sola: `title` per al ratolí i `aria-label` per al lector de - pantalla, que altrament només trobaria un botó sense nom. */} - - + setShowDeleteAccount(true)} + /> )}
@@ -326,23 +304,3 @@ function CategoriesStatus({ error, onRetry }: { error: boolean; onRetry: () => v
); } - -/** Porta amb una fletxa cap enfora: el gest habitual per a «surt». */ -function LogoutIcon() { - return ( - - ); -} diff --git a/frontend/src/assets/arena-cat-logo.svg b/frontend/src/assets/arena-cat-logo.svg new file mode 100644 index 0000000..b3e914f --- /dev/null +++ b/frontend/src/assets/arena-cat-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx new file mode 100644 index 0000000..fc01714 --- /dev/null +++ b/frontend/src/components/AccountMenu.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; + +interface Props { + email: string | null; + onLogout: () => void; + onDeleteAccount: () => void; +} + +const ITEM = + "block w-full px-4 py-2 text-left text-sm hover:bg-brand-100 focus-visible:bg-brand-100 focus-visible:outline-none"; + +/** Menú desplegable amb les accions del compte de la persona autenticada. */ +export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) { + const [open, setOpen] = useState(false); + const root = useRef(null); + const button = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!root.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + setOpen(false); + button.current?.focus(); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + // Tanca el menú abans d'executar l'acció: alguna n'obre un diàleg i el menú no + // hauria de quedar-se obert darrere. + function run(action: () => void) { + setOpen(false); + action(); + } + + return ( +
+ + {open && ( +
+

+ {email} +

+ setOpen(false)} + className={`${ITEM} text-slate-700`} + > + Recordatoris + +
+ + +
+ )} +
+ ); +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + ); +} From 9237257e3b02fc5e2652f180db133cf8ef26f7c4 Mon Sep 17 00:00:00 2001 From: Miquel Date: Sun, 1 Nov 2026 16:19:17 +0100 Subject: [PATCH 02/19] feat: include task skips in the data export Co-Authored-By: Claude Sonnet 5.5 --- backend/app/routes/auth.py | 2 +- backend/app/schemas.py | 11 ++++++ backend/app/services/auth_service.py | 9 ++++- backend/tests/test_auth_api.py | 58 +++++++++++++++++++++++++++- docs/auth_flow_manual.md | 2 +- docs/usuaris_autenticacio.md | 3 +- 6 files changed, 79 insertions(+), 6 deletions(-) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 1148052..440d6ac 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -158,7 +158,7 @@ def delete_account( @router.get("/auth/export") def export_data(current_user: CurrentUser, db: DbSession) -> ExportDataResponse: - """Exporta les dades personals i els vots de l'usuari autenticat.""" + """Exporta les dades personals, els vots i les omissions de l'usuari autenticat.""" return auth_service.export_user_data(db, current_user) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 69c7440..3d350f0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -248,6 +248,16 @@ class ExportVoteResponse(BaseModel): created_at: datetime +class ExportSkipResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + prompt_id: int + response_a_id: int + response_b_id: int + created_at: datetime + + class ExportUserResponse(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -269,6 +279,7 @@ class ExportUserResponse(BaseModel): class ExportDataResponse(BaseModel): user: ExportUserResponse votes: list[ExportVoteResponse] + skips: list[ExportSkipResponse] class PairwiseStat(BaseModel): diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index c7d1cc0..3ac211d 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -8,10 +8,11 @@ from sqlalchemy.orm import Session as OrmSession from app.config import get_settings -from app.models import Session, User, Vote +from app.models import Session, TaskSkip, User, Vote from app.schemas import ( DeleteAccountResponse, ExportDataResponse, + ExportSkipResponse, ExportUserResponse, ExportVoteResponse, ForgotPasswordRequest, @@ -414,12 +415,16 @@ def delete_account( def export_user_data(db: OrmSession, user: User) -> ExportDataResponse: - """Exporta les dades personals i els vots de l'usuari autenticat.""" + """Exporta les dades personals de l'usuari autenticat: el compte, els vots i les omissions.""" votes = db.scalars( select(Vote).where(Vote.user_id == user.id).order_by(Vote.created_at.asc()) ).all() + skips = db.scalars( + select(TaskSkip).where(TaskSkip.user_id == user.id).order_by(TaskSkip.created_at.asc()) + ).all() return ExportDataResponse( user=ExportUserResponse.model_validate(user), votes=[ExportVoteResponse.model_validate(vote) for vote in votes], + skips=[ExportSkipResponse.model_validate(skip) for skip in skips], ) diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 1b0c036..c0b391d 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -7,7 +7,7 @@ from sqlalchemy import select from app.config import get_settings -from app.models import Category, Prompt, Response, Session, User, Vote, Winner +from app.models import Category, Prompt, Response, Session, TaskSkip, User, Vote, Winner from app.security import ( compute_email_hash, create_email_verification_token, @@ -309,6 +309,62 @@ def test_export_data_returns_user_and_votes(client, session, logged_in_user): assert data["votes"][0]["winner"] == "a" +def test_export_data_includes_skipped_tasks_in_chronological_order( + client, session, logged_in_user, create_user +): + user = logged_in_user("export_skips@example.com") + other = create_user("export_other@example.com") + category = Category(code="export_skips_cat", name="Categoria omissions") + session.add(category) + session.flush() + prompt = Prompt( + version="v1", code="export_skips", category_id=category.id, text="Text omissions" + ) + session.add(prompt) + session.flush() + first = Response(prompt_id=prompt.id, model="model_A", text="Resposta A") + second = Response(prompt_id=prompt.id, model="model_B", text="Resposta B") + third = Response(prompt_id=prompt.id, model="model_C", text="Resposta C") + session.add_all([first, second, third]) + session.flush() + base = datetime(2026, 1, 1, tzinfo=UTC) + late = TaskSkip( + prompt_id=prompt.id, + user_id=user.id, + response_a_id=first.id, + response_b_id=third.id, + created_at=base + timedelta(days=1), + ) + early = TaskSkip( + prompt_id=prompt.id, + user_id=user.id, + response_a_id=first.id, + response_b_id=second.id, + created_at=base, + ) + foreign = TaskSkip( + prompt_id=prompt.id, + user_id=other.id, + response_a_id=second.id, + response_b_id=third.id, + ) + session.add_all([late, early, foreign]) + session.commit() + + data = client.get("/api/auth/export").json() + + assert [skip["id"] for skip in data["skips"]] == [early.id, late.id] + assert data["skips"][0]["prompt_id"] == prompt.id + assert data["skips"][0]["response_a_id"] == first.id + assert data["skips"][0]["response_b_id"] == second.id + + +def test_export_data_without_skips_returns_an_empty_list(client, logged_in_user): + logged_in_user("export_noskips@example.com") + + assert client.get("/api/auth/export").json()["skips"] == [] + + def test_export_data_requires_session(client): response = client.get("/api/auth/export") diff --git a/docs/auth_flow_manual.md b/docs/auth_flow_manual.md index e4e594f..d992bfc 100644 --- a/docs/auth_flow_manual.md +++ b/docs/auth_flow_manual.md @@ -273,7 +273,7 @@ com l'omissió retornen `410`: cal obtenir una tasca nova. curl -s -b "$COOKIES" "$BASE_URL/api/auth/export" ``` -Resposta esperada (`200`): objecte amb `user` i la llista de `votes`. +Resposta esperada (`200`): objecte amb `user` i les llistes de `votes` i `skips`. --- diff --git a/docs/usuaris_autenticacio.md b/docs/usuaris_autenticacio.md index 01f4978..832ec39 100644 --- a/docs/usuaris_autenticacio.md +++ b/docs/usuaris_autenticacio.md @@ -327,7 +327,7 @@ Les rutes de la taula són les definides a | `GET` | `/auth/session` | *(cookie opcional)* | `{ authenticated, email, email_verified, qualified }` | — | | `POST` | `/auth/logout` | *(cookie)* | `{ status: "logged_out" }` | — | | `POST` | `/auth/delete-account` | `{ current_password }` + cookie | `{ status: "deleted" }` | 401 (sessió/contrasenya) | -| `GET` | `/auth/export` | *(cookie)* | `{ user, votes }` | 401 (sessió) | +| `GET` | `/auth/export` | *(cookie)* | `{ user, votes, skips }` | 401 (sessió) | Els esquemes de petició i resposta són a [`backend/app/schemas.py`](../backend/app/schemas.py). @@ -340,6 +340,7 @@ L'endpoint `GET /auth/export` requereix una sessió activa i retorna: - Les dades del compte (`ExportUserResponse`): `id`, `email`, `email_verified_at`, `qualified_at`, `consent_version`, `consent_at`, `created_at`, `deleted_at`. - Tots els vots de l'usuari (`ExportVoteResponse`), ordenats cronològicament. +- Totes les tasques que ha omès (`ExportSkipResponse`), també per ordre cronològic. L'exportació inclou també els vots de versions antigues dels prompts. From 01767ff44451a6e028c8bc68951d3b11c9130f90 Mon Sep 17 00:00:00 2001 From: Miquel Date: Sun, 1 Nov 2026 16:19:51 +0100 Subject: [PATCH 03/19] feat: let users download their data from the account menu Co-Authored-By: Claude Sonnet 5.5 --- docs/usuaris_autenticacio.md | 5 +++- frontend/README.md | 5 ++-- frontend/src/api.ts | 4 +++ frontend/src/components/AccountMenu.tsx | 35 +++++++++++++++++++++++++ frontend/src/download.ts | 10 +++++++ frontend/src/types.ts | 7 +++++ 6 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 frontend/src/download.ts diff --git a/docs/usuaris_autenticacio.md b/docs/usuaris_autenticacio.md index 832ec39..e917bdc 100644 --- a/docs/usuaris_autenticacio.md +++ b/docs/usuaris_autenticacio.md @@ -344,9 +344,12 @@ L'endpoint `GET /auth/export` requereix una sessió activa i retorna: L'exportació inclou també els vots de versions antigues dels prompts. +El menú del compte de la interfície l'ofereix com a «Descarrega les meves dades», +que baixa la resposta com a fitxer JSON. + ### Baixa i anonimització (`delete_account`) -La interfície ofereix una icona de baixa al costat del correu, a la capçalera. +La interfície ofereix l'opció «Dona de baixa el compte» al menú del compte, a la capçalera. Obre un diàleg que demana la contrasenya actual i informa que els vots i les omissions es conservaran. Després de confirmar la baixa, es mostra la portada. diff --git a/frontend/README.md b/frontend/README.md index 770720d..9cf553a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,8 +25,8 @@ Les preguntes i el llindar provenen del backend; l'acreditació queda desada a `users.qualified_at` i evita repetir la prova en sessions posteriors. La capçalera mostra el logotip i el nom, que duen a l'avaluació (`/`), i l'enllaç al rànquing. -Amb sessió hi apareix també el **menú del compte** (recordatoris, tancar la sessió i donar de -baixa el compte). +Amb sessió hi apareix també el **menú del compte** (recordatoris, descarregar les dades personals, +donar de baixa el compte i tancar la sessió). El **Rànquing** (`/ranking`) és accessible des de la capçalera amb sessió o sense. Amb sessió, el botó **Avalua** permet tornar a l’avaluació. @@ -122,6 +122,7 @@ s'hagi decidit. |---|---| | `src/api.ts` | Client de l'API. `credentials: "include"` a totes les crides, imprescindible per la cookie de sessió. | | `src/types.ts` | Tipus alineats amb `backend/app/schemas.py`. | +| `src/download.ts` | Baixa un objecte com a fitxer JSON (exportació de dades). | | `src/diff.ts` | Separa l'enunciat en instrucció i text, i calcula la diferència per paraules. És el que fa avaluable la categoria «correcció». | | `src/taskStore.ts` | Conserva la tasca en curs entre recàrregues i llegeix el `vote_after` del token. | | `src/errors.ts` | Tradueix al català els errors de validació de FastAPI, que arriben en anglès. | diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1086d9d..7b0ecdf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -13,6 +13,7 @@ import type { Activity, Category, CategoryFilter, + ExportData, Progress, QualificationChoice, QualificationResult, @@ -110,6 +111,9 @@ export const api = { logout: () => request<{ status: string }>("/auth/logout", { method: "POST" }), + /** Dades personals, vots i omissions de la persona autenticada (RGPD). */ + exportData: () => request("/auth/export"), + deleteAccount: async (password: string) => { try { return await request<{ status: string }>( diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index fc01714..d44d8eb 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -1,6 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; +import { api, ApiError } from "../api"; +import { downloadJson } from "../download"; + interface Props { email: string | null; onLogout: () => void; @@ -13,6 +16,8 @@ const ITEM = /** Menú desplegable amb les accions del compte de la persona autenticada. */ export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) { const [open, setOpen] = useState(false); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); const root = useRef(null); const button = useRef(null); @@ -41,6 +46,22 @@ export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) action(); } + async function exportData() { + setExporting(true); + setExportError(null); + try { + const date = new Date().toISOString().slice(0, 10); + downloadJson(await api.exportData(), `arena-cat-dades-${date}.json`); + setOpen(false); + } catch (err) { + setExportError( + err instanceof ApiError ? err.message : "No s'han pogut descarregar les dades.", + ); + } finally { + setExporting(false); + } + } + return (
+ {exportError && ( +

+ {exportError} +

+ )}
{open && ( @@ -121,6 +118,7 @@ export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) > Dona de baixa el compte +
@@ -125,6 +128,7 @@ export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) onClick={() => run(onLogout)} className={`${ITEM} text-slate-700`} > + Tanca la sessió
@@ -133,6 +137,62 @@ export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) ); } +/** Icona d'un element del menú: mateix traç que la resta i mida fixa per alinear el text. */ +function MenuIcon({ children }: { children: React.ReactNode }) { + return ( + + ); +} + +/** Porta amb una fletxa cap enfora: el gest habitual per a «surt». */ +function LogoutIcon() { + return ( + + + + + + ); +} + +function BellIcon() { + return ( + + + + + ); +} + +function DownloadIcon() { + return ( + + + + + + ); +} + +function TrashIcon() { + return ( + + + + ); +} + function UserIcon() { return ( Date: Sun, 1 Nov 2026 18:29:42 +0100 Subject: [PATCH 06/19] fix: make the account menu fit narrow screens and keyboard-friendly, and stop caching the data export Drop the ARIA menu roles (no arrow-key navigation is implemented), close the dropdown when focus leaves it, keep it inside the viewport and right-align the header controls when they wrap. Send Cache-Control: no-store on /auth/export. Co-Authored-By: Claude Sonnet 5.5 --- backend/app/routes/auth.py | 4 ++- backend/tests/test_auth_api.py | 6 +++++ docs/usuaris_autenticacio.md | 3 ++- frontend/src/App.tsx | 2 +- frontend/src/components/AccountMenu.tsx | 35 +++++++++---------------- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 440d6ac..c0f3bf4 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -157,8 +157,10 @@ def delete_account( @router.get("/auth/export") -def export_data(current_user: CurrentUser, db: DbSession) -> ExportDataResponse: +def export_data(response: Response, current_user: CurrentUser, db: DbSession) -> ExportDataResponse: """Exporta les dades personals, els vots i les omissions de l'usuari autenticat.""" + # Contenen dades personals: ni el navegador ni els intermediaris les han de desar. + response.headers["Cache-Control"] = "no-store" return auth_service.export_user_data(db, current_user) diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index c0b391d..ba4c479 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -365,6 +365,12 @@ def test_export_data_without_skips_returns_an_empty_list(client, logged_in_user) assert client.get("/api/auth/export").json()["skips"] == [] +def test_export_data_is_not_cacheable(client, logged_in_user): + logged_in_user("export_cache@example.com") + + assert client.get("/api/auth/export").headers["cache-control"] == "no-store" + + def test_export_data_requires_session(client): response = client.get("/api/auth/export") diff --git a/docs/usuaris_autenticacio.md b/docs/usuaris_autenticacio.md index e917bdc..3577117 100644 --- a/docs/usuaris_autenticacio.md +++ b/docs/usuaris_autenticacio.md @@ -342,7 +342,8 @@ L'endpoint `GET /auth/export` requereix una sessió activa i retorna: - Tots els vots de l'usuari (`ExportVoteResponse`), ordenats cronològicament. - Totes les tasques que ha omès (`ExportSkipResponse`), també per ordre cronològic. -L'exportació inclou també els vots de versions antigues dels prompts. +L'exportació inclou també els vots de versions antigues dels prompts. La resposta porta la +capçalera `Cache-Control: no-store` perquè no es desi en memòria cau. El menú del compte de la interfície l'ofereix com a «Descarrega les meves dades», que baixa la resposta com a fitxer JSON. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34dabed..1229a39 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -107,7 +107,7 @@ export default function App() { -
+
Rànquing diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index e21fc94..6b54d23 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -63,12 +63,18 @@ export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) } return ( -
+
{ + // Tanca el desplegable quan el focus surt d'aquest bloc (per exemple amb Tab). + if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); + }} + > {open && ( -
+

{email}

- setOpen(false)} - className={`${ITEM} text-slate-700`} - > + setOpen(false)} className={`${ITEM} text-slate-700`}> Recordatoris -
- From 53ee8580bb612475a9917643d096879cd96684b2 Mon Sep 17 00:00:00 2001 From: Miquel Date: Sun, 1 Nov 2026 13:05:02 +0100 Subject: [PATCH 07/19] feat: add administrator role and user blocking Users gain `is_admin` and `blocked_at`. A `CurrentAdmin` dependency guards the new `/api/admin` endpoints, which list users and block or unblock them. Blocking revokes the user's sessions and rejects further logins; account deletion removes the role. Co-Authored-By: Claude Sonnet 5.5 --- backend/README.md | 14 ++ backend/app/deps.py | 10 + backend/app/main.py | 13 +- backend/app/models.py | 4 + backend/app/routes/admin.py | 36 ++++ backend/app/schemas.py | 18 ++ backend/app/services/admin_service.py | 57 ++++++ backend/app/services/auth_service.py | 9 +- .../a1b2c3d4e5f6_add_admin_and_blocking.py | 29 +++ backend/tests/test_admin_api.py | 185 ++++++++++++++++++ backend/tests/test_migrations.py | 26 +++ docs/db_schema.md | 2 + docs/usuaris_autenticacio.md | 7 +- 13 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 backend/app/routes/admin.py create mode 100644 backend/app/services/admin_service.py create mode 100644 backend/migrations/versions/a1b2c3d4e5f6_add_admin_and_blocking.py create mode 100644 backend/tests/test_admin_api.py diff --git a/backend/README.md b/backend/README.md index 6556989..61ccda7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -90,6 +90,20 @@ Exposa els noms dels models i les metadades d'inferència; `/api/task` manté les respostes anònimes. La resposta porta `Cache-Control: no-store`. Podeu consultar-lo amb el [CLI de consulta remota](../scripts/README.md#consulta-de-les-dades-carregades-en-remot). +### `/api/admin/*` + +Reservats als usuaris amb `is_admin`, amb la mateixa cookie de sessió que la resta +de l'API: 401 sense sessió, 403 si no és administrador. Per fer algú administrador, +marqueu-lo a la base de dades: `UPDATE users SET is_admin = true WHERE email = '...'`. + +- `GET /api/admin/users?limit=50&offset=0`: usuaris per ordre d'`id` (`limit` de 1 a 200), + amb `total`. No inclou contrasenyes ni tokens. La resposta porta `Cache-Control: no-store`. +- `POST /api/admin/users/{id}/block`: bloqueja l'usuari i revoca totes les seves sessions. + Idempotent. 409 si és un mateix o un compte donat de baixa; 404 si no existeix. +- `POST /api/admin/users/{id}/unblock`: treu el bloqueig. Les sessions revocades no es recuperen. + +Un usuari bloquejat que encerta la contrasenya rep 403 en iniciar sessió. + ### `GET /api/qualification` i `POST /api/qualification` Requereixen una sessió vàlida i la verificació del correu, si està activada. diff --git a/backend/app/deps.py b/backend/app/deps.py index 39baa77..c38c4e6 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -56,3 +56,13 @@ def get_current_qualified_user(user: CurrentVerifiedUser) -> User: CurrentQualifiedUser = Annotated[User, Depends(get_current_qualified_user)] + + +def get_current_admin(user: CurrentVerifiedUser) -> User: + """Exigeix que l'usuari autenticat sigui administrador.""" + if not user.is_admin: + raise HTTPException(status_code=403, detail="Cal ser administrador") + return user + + +CurrentAdmin = Annotated[User, Depends(get_current_admin)] diff --git a/backend/app/main.py b/backend/app/main.py index 7928031..7ddc0b5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,17 @@ from fastapi.responses import JSONResponse from app.exceptions import TASK_TOKEN_INVALID, TaskTokenError -from app.routes import activity, auth, categories, dataset, qualification, ranking, task, vote +from app.routes import ( + activity, + admin, + auth, + categories, + dataset, + qualification, + ranking, + task, + vote, +) from app.telemetry.metrics import http_errors_total, http_requests_duration, http_requests_total logging.basicConfig( @@ -39,6 +49,7 @@ async def task_token_error_handler(_request: Request, exc: TaskTokenError) -> JS app.include_router(auth.router, prefix="/api", tags=["Auth"]) app.include_router(categories.router, prefix="/api", tags=["Categories"]) app.include_router(dataset.router, prefix="/api", tags=["Dataset"]) +app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(activity.router, prefix="/api", tags=["Activity"]) app.include_router(qualification.router, prefix="/api", tags=["Qualification"]) diff --git a/backend/app/models.py b/backend/app/models.py index 11b5120..ea37898 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -117,6 +117,7 @@ class User(Base): "password_hash IS NOT NULL AND consent_at IS NOT NULL)", name="ck_users_active_have_credentials", ), + CheckConstraint("NOT is_admin OR deleted_at IS NULL", name="ck_users_admin_not_deleted"), CheckConstraint("reminder_count BETWEEN 0 AND 3", name="ck_users_reminder_count"), CheckConstraint( "NOT reminder_enabled OR " @@ -140,6 +141,9 @@ class User(Base): DateTime(timezone=True), nullable=True ) qualified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_admin: Mapped[bool] = mapped_column(Boolean, server_default="false", nullable=False) + # Un usuari bloquejat no pot iniciar sessió ni conservar les sessions obertes. + blocked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Últim cop que s'ha enviat el correu de verificació: limita els reenviaments. verification_sent_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 0000000..e54c44f --- /dev/null +++ b/backend/app/routes/admin.py @@ -0,0 +1,36 @@ +"""Endpoints d'administració, reservats als usuaris amb el rol d'administrador.""" + +from typing import Annotated + +from fastapi import APIRouter, Query, Response + +from app.deps import CurrentAdmin, DbSession +from app.schemas import AdminUserResponse, AdminUsersResponse +from app.services import admin_service + +router = APIRouter() + + +@router.get("/users") +def list_users( + db: DbSession, + _admin: CurrentAdmin, + response: Response, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, +) -> AdminUsersResponse: + """Llista els usuaris per pàgines, sense contrasenyes ni tokens.""" + response.headers["Cache-Control"] = "no-store" + return admin_service.list_users(db, limit=limit, offset=offset) + + +@router.post("/users/{user_id}/block") +def block_user(user_id: int, db: DbSession, admin: CurrentAdmin) -> AdminUserResponse: + """Bloqueja un usuari: no pot entrar i perd les sessions obertes.""" + return AdminUserResponse.model_validate(admin_service.block_user(db, admin, user_id)) + + +@router.post("/users/{user_id}/unblock") +def unblock_user(user_id: int, db: DbSession, _admin: CurrentAdmin) -> AdminUserResponse: + """Desbloqueja un usuari, que podrà tornar a iniciar sessió.""" + return AdminUserResponse.model_validate(admin_service.unblock_user(db, user_id)) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 3d350f0..75014ee 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -334,3 +334,21 @@ class ReminderPreferences(BaseModel): class ReminderUnsubscribe(BaseModel): token: str = Field(min_length=1, max_length=64) + + +class AdminUserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: str | None + is_admin: bool + email_verified_at: datetime | None + qualified_at: datetime | None + created_at: datetime + blocked_at: datetime | None + deleted_at: datetime | None + + +class AdminUsersResponse(BaseModel): + total: int + users: list[AdminUserResponse] diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py new file mode 100644 index 0000000..5e5f215 --- /dev/null +++ b/backend/app/services/admin_service.py @@ -0,0 +1,57 @@ +"""Operacions d'administració sobre els usuaris.""" + +from datetime import UTC, datetime + +from fastapi import HTTPException +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session as OrmSession + +from app.models import Session, User +from app.schemas import AdminUsersResponse + + +def list_users(db: OrmSession, *, limit: int, offset: int) -> AdminUsersResponse: + """Retorna una pàgina d'usuaris per ordre d'identificador, amb el total.""" + users = db.scalars(select(User).order_by(User.id).limit(limit).offset(offset)).all() + total = db.scalar(select(func.count()).select_from(User)) or 0 + return AdminUsersResponse(total=total, users=users) + + +def _get_active_user(db: OrmSession, user_id: int) -> User: + user = db.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="Usuari no trobat") + if user.deleted_at is not None: + raise HTTPException(status_code=409, detail="El compte ja s'ha donat de baixa") + return user + + +def block_user(db: OrmSession, admin: User, user_id: int) -> User: + """Bloqueja un usuari i revoca totes les seves sessions obertes. + + És idempotent: si ja estava bloquejat, conserva la data original. + """ + if admin.id == user_id: + raise HTTPException(status_code=409, detail="No et pots bloquejar a tu mateix") + + user = _get_active_user(db, user_id) + now = datetime.now(UTC) + if user.blocked_at is None: + user.blocked_at = now + db.execute( + update(Session) + .where(Session.user_id == user.id, Session.revoked_at.is_(None)) + .values(revoked_at=now) + ) + db.commit() + db.refresh(user) + return user + + +def unblock_user(db: OrmSession, user_id: int) -> User: + """Treu el bloqueig d'un usuari; les sessions revocades no es recuperen.""" + user = _get_active_user(db, user_id) + user.blocked_at = None + db.commit() + db.refresh(user) + return user diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 3ac211d..617a94b 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -101,7 +101,7 @@ def resolve_session_user( raise HTTPException(status_code=401, detail="Sessió invàlida o caducada") user = db.get(User, active_session.user_id) - if user is None or user.deleted_at is not None: + if user is None or user.deleted_at is not None or user.blocked_at is not None: raise HTTPException(status_code=401, detail="Sessió invàlida o caducada") if ( @@ -324,7 +324,11 @@ def login_user(db: OrmSession, payload: LoginRequest) -> tuple[User, str]: if not verify_password(payload.password, verified_hash): raise HTTPException(status_code=401, detail="Correu electrònic o contrasenya incorrectes.") - # Després de la contrasenya: així només el propietari del compte veu que li cal verificar. + # Després de la contrasenya: així només el propietari del compte veu que el compte és + # bloquejat o que li cal verificar el correu. + if user.blocked_at is not None: + raise HTTPException(status_code=403, detail="Aquest compte està bloquejat.") + if get_settings().require_email_verification and user.email_verified_at is None: raise HTTPException( status_code=403, @@ -376,6 +380,7 @@ def anonymize_user_rgpd(user: User, now: datetime) -> None: user.password_hash = None user.email_verified_at = None user.qualified_at = None + user.is_admin = False user.consent_at = None user.deleted_at = now user.reminder_enabled = False diff --git a/backend/migrations/versions/a1b2c3d4e5f6_add_admin_and_blocking.py b/backend/migrations/versions/a1b2c3d4e5f6_add_admin_and_blocking.py new file mode 100644 index 0000000..a48e3be --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f6_add_admin_and_blocking.py @@ -0,0 +1,29 @@ +"""Afegeix el rol d'administrador i el bloqueig d'usuaris. + +Revision ID: a1b2c3d4e5f6 +Revises: d9f012345678 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "a1b2c3d4e5f6" +down_revision = "d9f012345678" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", sa.Column("is_admin", sa.Boolean(), server_default="false", nullable=False) + ) + op.add_column("users", sa.Column("blocked_at", sa.DateTime(timezone=True), nullable=True)) + op.create_check_constraint( + "ck_users_admin_not_deleted", "users", "NOT is_admin OR deleted_at IS NULL" + ) + + +def downgrade() -> None: + op.drop_constraint("ck_users_admin_not_deleted", "users", type_="check") + op.drop_column("users", "blocked_at") + op.drop_column("users", "is_admin") diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py new file mode 100644 index 0000000..2094c80 --- /dev/null +++ b/backend/tests/test_admin_api.py @@ -0,0 +1,185 @@ +"""Accés d'administració: rol d'administrador, llistat d'usuaris i bloqueig.""" + +from datetime import UTC, datetime + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.main import app +from app.models import Session, User + + +@pytest.fixture +def admin(create_user, login, session): + """Administrador amb la sessió oberta al client.""" + user = create_user("admin@example.com") + user.is_admin = True + session.commit() + login("admin@example.com") + return user + + +def test_admin_endpoints_require_a_session(client): + assert client.get("/api/admin/users").status_code == 401 + assert client.post("/api/admin/users/1/block").status_code == 401 + + +def test_regular_user_is_forbidden(client, logged_in_user): + logged_in_user("regular@example.com") + assert client.get("/api/admin/users").status_code == 403 + assert client.post("/api/admin/users/1/block").status_code == 403 + + +def test_admin_must_have_a_verified_email(client, create_user, login, session, monkeypatch): + monkeypatch.setenv("REQUIRE_EMAIL_VERIFICATION", "false") + from app.config import get_settings + + get_settings.cache_clear() + user = create_user("unverified@example.com", verified=False) + user.is_admin = True + session.commit() + login("unverified@example.com") + + monkeypatch.setenv("REQUIRE_EMAIL_VERIFICATION", "true") + get_settings.cache_clear() + assert client.get("/api/admin/users").status_code == 403 + + +def test_admin_lists_users(client, admin, create_user): + create_user("other@example.com", qualified=False) + + result = client.get("/api/admin/users") + + assert result.status_code == 200 + users = {user["email"]: user for user in result.json()["users"]} + assert users["admin@example.com"]["is_admin"] is True + assert users["other@example.com"]["is_admin"] is False + assert users["other@example.com"]["qualified_at"] is None + assert users["other@example.com"]["blocked_at"] is None + assert "password_hash" not in users["other@example.com"] + + +def test_admin_list_is_paginated_and_ordered_by_id(client, admin, create_user): + for index in range(3): + create_user(f"user{index}@example.com") + + first = client.get("/api/admin/users", params={"limit": 2}).json() + second = client.get("/api/admin/users", params={"limit": 2, "offset": 2}).json() + + ids = [user["id"] for user in first["users"] + second["users"]] + assert len(first["users"]) == 2 + assert ids == sorted(ids) + assert first["total"] == 4 + + +def test_block_rejects_login_and_revokes_sessions(client, admin, create_user, session): + target = create_user("target@example.com") + + other = TestClient(app, base_url="https://testserver") + other.post( + "/api/auth/login", + json={"email": "target@example.com", "password": "ContrasenyaSegura123!"}, + ) + assert other.get("/api/auth/session").json()["authenticated"] is True + + result = client.post(f"/api/admin/users/{target.id}/block") + + assert result.status_code == 200 + assert result.json()["blocked_at"] is not None + assert other.get("/api/auth/session").json()["authenticated"] is False + active = session.scalars( + select(Session).where(Session.user_id == target.id, Session.revoked_at.is_(None)) + ).all() + assert active == [] + denied = other.post( + "/api/auth/login", + json={"email": "target@example.com", "password": "ContrasenyaSegura123!"}, + ) + assert denied.status_code == 403 + + +def test_blocked_user_with_wrong_password_gets_the_generic_error(client, admin, create_user): + target = create_user("target@example.com") + client.post(f"/api/admin/users/{target.id}/block") + + denied = client.post( + "/api/auth/login", json={"email": "target@example.com", "password": "incorrecta"} + ) + + assert denied.status_code == 401 + + +def test_block_is_idempotent_and_keeps_the_first_date(client, admin, create_user): + target = create_user("target@example.com") + + first = client.post(f"/api/admin/users/{target.id}/block").json() + second = client.post(f"/api/admin/users/{target.id}/block").json() + + assert first["blocked_at"] == second["blocked_at"] + + +def test_unblock_allows_login_again(client, admin, create_user): + target = create_user("target@example.com") + client.post(f"/api/admin/users/{target.id}/block") + + result = client.post(f"/api/admin/users/{target.id}/unblock") + + assert result.status_code == 200 + assert result.json()["blocked_at"] is None + allowed = client.post( + "/api/auth/login", + json={"email": "target@example.com", "password": "ContrasenyaSegura123!"}, + ) + assert allowed.status_code == 200 + + +def test_admin_cannot_block_themselves(client, admin): + result = client.post(f"/api/admin/users/{admin.id}/block") + + assert result.status_code == 409 + assert client.get("/api/admin/users").status_code == 200 + + +def test_block_unknown_user_is_not_found(client, admin): + assert client.post("/api/admin/users/999999/block").status_code == 404 + assert client.post("/api/admin/users/999999/unblock").status_code == 404 + + +def test_deleted_account_cannot_be_blocked(client, admin, create_user, session): + target = create_user("gone@example.com") + target.deleted_at = datetime.now(UTC) + target.email = target.email_hash = target.password_hash = target.consent_at = None + session.commit() + + assert client.post(f"/api/admin/users/{target.id}/block").status_code == 409 + + +def test_account_deletion_removes_the_admin_role(client, admin, session): + result = client.post( + "/api/auth/delete-account", json={"current_password": "ContrasenyaSegura123!"} + ) + + assert result.status_code == 200 + session.refresh(admin) + assert admin.is_admin is False + + +def test_database_rejects_deleted_admins(create_user, session): + user = create_user("former@example.com") + user.is_admin = True + user.deleted_at = datetime.now(UTC) + user.email = user.email_hash = user.password_hash = user.consent_at = None + + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + +def test_new_users_are_not_admins(create_user): + user = create_user("new@example.com") + + assert user.is_admin is False + assert user.blocked_at is None + assert isinstance(user, User) diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 5895e47..d972289 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -132,3 +132,29 @@ def test_migrations_keep_application_loggers_enabled(): config.output_buffer = StringIO() command.upgrade(config, "base", sql=True) assert not logger.disabled + + +def test_admin_migration_defaults_existing_users_and_is_reversible(ephemeral_db_url): + """Els usuaris existents no són administradors ni estan bloquejats.""" + config = _alembic_config(ephemeral_db_url) + engine = create_engine(ephemeral_db_url) + try: + command.upgrade(config, "d9f012345678") + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO users " + "(email, email_hash, password_hash, consent_version, consent_at) " + "VALUES ('existing@example.com', 'hash', 'password', 'v1', now())" + ) + ) + command.upgrade(config, "head") + with engine.connect() as connection: + row = connection.execute(text("SELECT is_admin, blocked_at FROM users")).one() + assert row == (False, None) + command.check(config) + command.downgrade(config, "d9f012345678") + columns = {column["name"] for column in inspect(engine).get_columns("users")} + assert {"is_admin", "blocked_at"}.isdisjoint(columns) + finally: + engine.dispose() diff --git a/docs/db_schema.md b/docs/db_schema.md index 27508f0..45b77d9 100644 --- a/docs/db_schema.md +++ b/docs/db_schema.md @@ -45,6 +45,8 @@ erDiagram text password_hash timestamptz email_verified_at timestamptz qualified_at + boolean is_admin + timestamptz blocked_at timestamptz verification_sent_at timestamptz password_reset_sent_at varchar(32) consent_version diff --git a/docs/usuaris_autenticacio.md b/docs/usuaris_autenticacio.md index 3577117..e04c34e 100644 --- a/docs/usuaris_autenticacio.md +++ b/docs/usuaris_autenticacio.md @@ -39,6 +39,8 @@ restriccions. Els camps principals per al flux d’autenticació són: - `email` i `password_hash`: credencials del compte actiu; es buiden en donar-se de baixa. - `email_hash`: HMAC del correu normalitzat, es buida en donar-se de baixa. - `email_verified_at` i `qualified_at`: acrediten la verificació del correu i la superació de la prova lingüística. +- `is_admin`: dona accés a `/api/admin/*`. No es pot canviar des de cap endpoint; es marca directament a la base de dades. Una restricció `CHECK` impedeix que un compte donat de baixa sigui administrador. +- `blocked_at`: data del bloqueig. Un usuari bloquejat no pot iniciar sessió i `resolve_session_user` rebutja les sessions que li quedin. - `verification_sent_at` i `password_reset_sent_at`: limiten la freqüència d’enviament de correus. - `consent_version` i `consent_at`: registren el consentiment acceptat. - `deleted_at`: marca la baixa; la fila es conserva. @@ -301,6 +303,9 @@ en aquest ordre: Si totes les comprovacions passen, retorna l'objecte `User` per injectar-lo a l'endpoint. Els endpoints d'exportació i baixa reutilitzen la mateixa funció sense exigir verificació. +`CurrentAdmin` exigeix usuari verificat i `is_admin` → HTTP 403 altrament. El rol és +independent de `qualified_at`: un administrador ha de superar la prova per avaluar, com la resta. + La prova de competència (`GET` i `POST /api/qualification`) exigeix una sessió i la verificació segons la configuració anterior. Per obtenir tasques, consultar-ne el progrés, ometre-les o votar, `CurrentQualifiedUser` exigeix també `qualified_at` informat; si és nul, retorna @@ -359,7 +364,7 @@ L'endpoint `POST /auth/delete-account` fa les operacions següents: 1. Exigeix una sessió activa i la **contrasenya actual** (reautenticació) → HTTP 401 si falla qualsevol de les dues. 2. Anonimitza l'usuari amb `anonymize_user_rgpd`: buida `email`, `email_hash`, `password_hash`, - `email_verified_at`, `qualified_at` i `consent_at`, i estableix `deleted_at`. Es conserva `id`, però es pot + `email_verified_at`, `qualified_at`, `is_admin` i `consent_at`, i estableix `deleted_at`. Es conserva `id`, però es pot tornar a registrar el mateix correu. 3. Revoca **totes** les sessions actives de l'usuari. 4. S'esborra la cookie del client. From 210f26a6417ab7b4e648a2cfbef791478dcc3148 Mon Sep 17 00:00:00 2001 From: Miquel Date: Sun, 1 Nov 2026 13:25:16 +0100 Subject: [PATCH 08/19] feat: manage prompts from the admin API Admins can list, create and delete prompts under `/api/admin/prompts`. Each prompt stores the SHA-256 of its text, unique per version, so the same content cannot be published twice in one version. A prompt can only be deleted while it has no inferences. The inference loader reports repeated content as an error instead of failing. Co-Authored-By: Claude Sonnet 5.5 --- backend/README.md | 9 + backend/app/models.py | 16 +- backend/app/routes/admin.py | 43 ++++- backend/app/schemas.py | 33 +++- backend/app/services/admin_prompt_service.py | 122 +++++++++++++ .../b2c3d4e5f6a7_add_prompt_content_sha256.py | 33 ++++ backend/tests/conftest.py | 10 ++ backend/tests/test_admin_api.py | 10 -- backend/tests/test_admin_prompts_api.py | 165 ++++++++++++++++++ backend/tests/test_auth_api.py | 2 +- backend/tests/test_carrega_inferencies.py | 19 +- backend/tests/test_confidence.py | 2 +- backend/tests/test_migrations.py | 28 +++ backend/tests/test_prompt_versions.py | 4 +- backend/tests/test_task_api.py | 2 +- docs/db_schema.md | 1 + scripts/carrega_inferencies.py | 17 +- 17 files changed, 494 insertions(+), 22 deletions(-) create mode 100644 backend/app/services/admin_prompt_service.py create mode 100644 backend/migrations/versions/b2c3d4e5f6a7_add_prompt_content_sha256.py create mode 100644 backend/tests/test_admin_prompts_api.py diff --git a/backend/README.md b/backend/README.md index 61ccda7..db3dfa0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -104,6 +104,15 @@ marqueu-lo a la base de dades: `UPDATE users SET is_admin = true WHERE email = ' Un usuari bloquejat que encerta la contrasenya rep 403 en iniciar sessió. +- `GET /api/admin/prompts?limit=50&offset=0&version=v1&category_code=correccio`: prompts per + ordre d'`id`, amb `content_sha256`, `category_code` i `response_count` (inferències). +- `POST /api/admin/prompts` amb `{ version, code, category_code, text }`: crea un prompt (201). + El text s'escurça de blancs i se'n desa el SHA-256. 409 si ja existeix la parella + versió-codi o el mateix contingut dins la versió; 422 si la categoria no existeix o els + camps no són vàlids. El mateix text sí que es pot publicar en una versió nova. +- `DELETE /api/admin/prompts/{id}`: esborra un prompt (204). 409 si té inferències associades; + 404 si no existeix. + ### `GET /api/qualification` i `POST /api/qualification` Requereixen una sessió vàlida i la verificació del correu, si està activada. diff --git a/backend/app/models.py b/backend/app/models.py index ea37898..819f7c3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -8,6 +8,7 @@ """ import enum +import hashlib from datetime import datetime from decimal import Decimal @@ -34,6 +35,11 @@ from app.db import Base +def text_sha256(text: str) -> str: + """Empremta SHA-256 d'un text en UTF-8, en hexadecimal.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + class Winner(enum.Enum): """Resultat d'un vot: guanya A, guanya B, empat o cap de les dues.""" @@ -64,13 +70,21 @@ class Prompt(Base): """Tasca d'avaluació que es mostra a l'usuari.""" __tablename__ = "prompts" - __table_args__ = (UniqueConstraint("version", "code", name="uq_prompts_version_code"),) + __table_args__ = ( + UniqueConstraint("version", "code", name="uq_prompts_version_code"), + UniqueConstraint("version", "content_sha256", name="uq_prompts_version_content_sha256"), + CheckConstraint("content_sha256 ~ '^[0-9a-f]{64}$'", name="ck_prompts_content_sha256"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True) version: Mapped[str] = mapped_column(String(32), nullable=False) code: Mapped[str] = mapped_column(String(64), nullable=False) category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=False) text: Mapped[str] = mapped_column(Text, nullable=False) + # SHA-256 del text en UTF-8: impedeix repetir el mateix contingut dins d'una versió. + content_sha256: Mapped[str] = mapped_column( + String(64), default=lambda context: text_sha256(context.get_current_parameters()["text"]) + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index e54c44f..b2b4665 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -2,11 +2,17 @@ from typing import Annotated -from fastapi import APIRouter, Query, Response +from fastapi import APIRouter, Query, Response, status from app.deps import CurrentAdmin, DbSession -from app.schemas import AdminUserResponse, AdminUsersResponse -from app.services import admin_service +from app.schemas import ( + AdminPromptCreate, + AdminPromptResponse, + AdminPromptsResponse, + AdminUserResponse, + AdminUsersResponse, +) +from app.services import admin_prompt_service, admin_service router = APIRouter() @@ -34,3 +40,34 @@ def block_user(user_id: int, db: DbSession, admin: CurrentAdmin) -> AdminUserRes def unblock_user(user_id: int, db: DbSession, _admin: CurrentAdmin) -> AdminUserResponse: """Desbloqueja un usuari, que podrà tornar a iniciar sessió.""" return AdminUserResponse.model_validate(admin_service.unblock_user(db, user_id)) + + +@router.get("/prompts") +def list_prompts( + db: DbSession, + _admin: CurrentAdmin, + response: Response, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, + version: str | None = None, + category_code: str | None = None, +) -> AdminPromptsResponse: + """Llista els prompts per pàgines, amb el nombre d'inferències de cadascun.""" + response.headers["Cache-Control"] = "no-store" + return admin_prompt_service.list_prompts( + db, limit=limit, offset=offset, version=version, category_code=category_code + ) + + +@router.post("/prompts", status_code=status.HTTP_201_CREATED) +def create_prompt( + payload: AdminPromptCreate, db: DbSession, _admin: CurrentAdmin +) -> AdminPromptResponse: + """Crea un prompt; el seu contingut no es pot repetir.""" + return admin_prompt_service.create_prompt(db, payload) + + +@router.delete("/prompts/{prompt_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_prompt(prompt_id: int, db: DbSession, _admin: CurrentAdmin) -> None: + """Esborra un prompt sense inferències associades.""" + admin_prompt_service.delete_prompt(db, prompt_id) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 75014ee..d415fb3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -2,7 +2,7 @@ from datetime import date, datetime from typing import Annotated, Literal -from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field +from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator from pydantic_core import PydanticCustomError from app.models import Winner @@ -352,3 +352,34 @@ class AdminUserResponse(BaseModel): class AdminUsersResponse(BaseModel): total: int users: list[AdminUserResponse] + + +class AdminPromptCreate(BaseModel): + version: str = Field(pattern=r"^v[1-9][0-9]{0,30}$") + code: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$") + category_code: str + text: str = Field(min_length=1, max_length=20_000) + + @field_validator("text") + @classmethod + def strip_text(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("El text no pot ser buit") + return value + + +class AdminPromptResponse(BaseModel): + id: int + version: str + code: str + category_code: str + text: str + content_sha256: str + created_at: datetime + response_count: int + + +class AdminPromptsResponse(BaseModel): + total: int + prompts: list[AdminPromptResponse] diff --git a/backend/app/services/admin_prompt_service.py b/backend/app/services/admin_prompt_service.py new file mode 100644 index 0000000..4218180 --- /dev/null +++ b/backend/app/services/admin_prompt_service.py @@ -0,0 +1,122 @@ +"""Alta, llistat i baixa de prompts des de l'administració.""" + +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session as OrmSession + +from app.models import Category, Prompt, Response, text_sha256 +from app.schemas import ( + AdminPromptCreate, + AdminPromptResponse, + AdminPromptsResponse, +) + + +def _to_response(prompt: Prompt, category_code: str, response_count: int) -> AdminPromptResponse: + return AdminPromptResponse( + id=prompt.id, + version=prompt.version, + code=prompt.code, + category_code=category_code, + text=prompt.text, + content_sha256=prompt.content_sha256, + created_at=prompt.created_at, + response_count=response_count, + ) + + +def list_prompts( + db: OrmSession, + *, + limit: int, + offset: int, + version: str | None, + category_code: str | None, +) -> AdminPromptsResponse: + """Retorna una pàgina de prompts per ordre d'identificador, amb el recompte de respostes.""" + filters = [] + if version is not None: + filters.append(Prompt.version == version) + if category_code is not None: + filters.append(Category.code == category_code) + + responses = ( + select(Response.prompt_id, func.count(Response.id).label("n")) + .group_by(Response.prompt_id) + .subquery() + ) + rows = db.execute( + select(Prompt, Category.code, func.coalesce(responses.c.n, 0)) + .join(Category, Category.id == Prompt.category_id) + .outerjoin(responses, responses.c.prompt_id == Prompt.id) + .where(*filters) + .order_by(Prompt.id) + .limit(limit) + .offset(offset) + ).all() + total = db.scalar( + select(func.count(Prompt.id)) + .join(Category, Category.id == Prompt.category_id) + .where(*filters) + ) + return AdminPromptsResponse( + total=total or 0, + prompts=[_to_response(prompt, code, count) for prompt, code, count in rows], + ) + + +def create_prompt(db: OrmSession, payload: AdminPromptCreate) -> AdminPromptResponse: + """Crea un prompt nou; rebutja el contingut repetit dins la versió i la parella versió-codi.""" + category = db.scalar(select(Category).where(Category.code == payload.category_code)) + if category is None: + raise HTTPException(status_code=422, detail="Categoria desconeguda") + + duplicate = db.scalar( + select(Prompt.id).where( + Prompt.version == payload.version, Prompt.content_sha256 == text_sha256(payload.text) + ) + ) + if duplicate is not None: + raise HTTPException( + status_code=409, + detail=f"Ja existeix un prompt amb el mateix contingut (id {duplicate})", + ) + + prompt = Prompt( + version=payload.version, code=payload.code, category_id=category.id, text=payload.text + ) + db.add(prompt) + try: + db.commit() + except IntegrityError: + # Una altra petició s'ha avançat entre la comprovació i l'alta. + db.rollback() + raise HTTPException( + status_code=409, + detail="Ja existeix aquest prompt (versió i codi, o contingut a la versió)", + ) from None + db.refresh(prompt) + return _to_response(prompt, category.code, 0) + + +def delete_prompt(db: OrmSession, prompt_id: int) -> None: + """Esborra un prompt que no té cap inferència associada.""" + prompt = db.get(Prompt, prompt_id) + if prompt is None: + raise HTTPException(status_code=404, detail="Prompt no trobat") + + has_responses = db.scalar(select(Response.id).where(Response.prompt_id == prompt_id).limit(1)) + if has_responses is not None: + raise HTTPException( + status_code=409, detail="El prompt té inferències associades i no es pot esborrar" + ) + + db.delete(prompt) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=409, detail="El prompt encara està referenciat i no es pot esborrar" + ) from None diff --git a/backend/migrations/versions/b2c3d4e5f6a7_add_prompt_content_sha256.py b/backend/migrations/versions/b2c3d4e5f6a7_add_prompt_content_sha256.py new file mode 100644 index 0000000..ced8a36 --- /dev/null +++ b/backend/migrations/versions/b2c3d4e5f6a7_add_prompt_content_sha256.py @@ -0,0 +1,33 @@ +"""Afegeix l'empremta SHA-256 del text dels prompts. + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("prompts", sa.Column("content_sha256", sa.String(length=64), nullable=True)) + op.execute( + "UPDATE prompts SET content_sha256 = encode(sha256(convert_to(text, 'UTF8')), 'hex')" + ) + op.alter_column("prompts", "content_sha256", nullable=False) + op.create_unique_constraint( + "uq_prompts_version_content_sha256", "prompts", ["version", "content_sha256"] + ) + op.create_check_constraint( + "ck_prompts_content_sha256", "prompts", "content_sha256 ~ '^[0-9a-f]{64}$'" + ) + + +def downgrade() -> None: + op.drop_constraint("ck_prompts_content_sha256", "prompts", type_="check") + op.drop_constraint("uq_prompts_version_content_sha256", "prompts", type_="unique") + op.drop_column("prompts", "content_sha256") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cb44ec9..8d9a9d5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -144,3 +144,13 @@ def _make(email: str, password: str = DEFAULT_PASSWORD) -> User: return user return _make + + +@pytest.fixture +def admin(create_user, login, session) -> User: + """Administrador amb la sessió oberta al client.""" + user = create_user("admin@example.com") + user.is_admin = True + session.commit() + login("admin@example.com") + return user diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index 2094c80..ff6a33f 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -11,16 +11,6 @@ from app.models import Session, User -@pytest.fixture -def admin(create_user, login, session): - """Administrador amb la sessió oberta al client.""" - user = create_user("admin@example.com") - user.is_admin = True - session.commit() - login("admin@example.com") - return user - - def test_admin_endpoints_require_a_session(client): assert client.get("/api/admin/users").status_code == 401 assert client.post("/api/admin/users/1/block").status_code == 401 diff --git a/backend/tests/test_admin_prompts_api.py b/backend/tests/test_admin_prompts_api.py new file mode 100644 index 0000000..ba2af1c --- /dev/null +++ b/backend/tests/test_admin_prompts_api.py @@ -0,0 +1,165 @@ +"""Gestió de prompts des de l'administració: alta, llistat i baixa.""" + +import hashlib + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.models import Category, Prompt, Response + +NEW_PROMPT = { + "version": "v1", + "code": "correccio_90", + "category_code": "correccio", + "text": "Corregeix aquest text.", +} + + +def add_prompt(session, code="correccio_91", version="v1", text="Un altre text.", responses=0): + category = session.scalar(select(Category).where(Category.code == "correccio")) + prompt = Prompt(version=version, code=code, category_id=category.id, text=text) + session.add(prompt) + session.flush() + for index in range(responses): + session.add(Response(prompt_id=prompt.id, model=f"model-{index}", text="Resposta")) + session.commit() + return prompt + + +def test_prompt_endpoints_require_an_admin(client, logged_in_user): + logged_in_user("regular@example.com") + assert client.get("/api/admin/prompts").status_code == 403 + assert client.post("/api/admin/prompts", json=NEW_PROMPT).status_code == 403 + assert client.delete("/api/admin/prompts/1").status_code == 403 + + +def test_prompt_endpoints_require_a_session(client): + assert client.get("/api/admin/prompts").status_code == 401 + assert client.post("/api/admin/prompts", json=NEW_PROMPT).status_code == 401 + assert client.delete("/api/admin/prompts/1").status_code == 401 + + +def test_create_prompt_stores_the_sha256_of_its_text(client, admin, session): + result = client.post("/api/admin/prompts", json=NEW_PROMPT) + + assert result.status_code == 201 + body = result.json() + assert body["content_sha256"] == hashlib.sha256(b"Corregeix aquest text.").hexdigest() + assert body["category_code"] == "correccio" + assert body["response_count"] == 0 + stored = session.get(Prompt, body["id"]) + assert (stored.version, stored.code) == ("v1", "correccio_90") + + +def test_create_prompt_strips_the_text_before_hashing(client, admin): + result = client.post("/api/admin/prompts", json={**NEW_PROMPT, "text": " Hola.\n\n"}) + + assert result.json()["text"] == "Hola." + assert result.json()["content_sha256"] == hashlib.sha256(b"Hola.").hexdigest() + + +def test_create_rejects_a_duplicate_content(client, admin, session): + existing = add_prompt(session, text="Corregeix aquest text.") + + result = client.post("/api/admin/prompts", json=NEW_PROMPT) + + assert result.status_code == 409 + assert str(existing.id) in result.json()["detail"] + + +def test_create_allows_the_same_content_in_a_new_version(client, admin, session): + add_prompt(session, version="v1", text="Corregeix aquest text.") + + result = client.post("/api/admin/prompts", json={**NEW_PROMPT, "version": "v2"}) + + assert result.status_code == 201 + + +def test_create_rejects_a_duplicate_version_and_code(client, admin, session): + add_prompt(session, code="correccio_90", text="Un text diferent.") + + result = client.post("/api/admin/prompts", json=NEW_PROMPT) + + assert result.status_code == 409 + + +def test_create_rejects_an_unknown_category(client, admin): + result = client.post("/api/admin/prompts", json={**NEW_PROMPT, "category_code": "inexistent"}) + + assert result.status_code == 422 + + +@pytest.mark.parametrize( + "overrides", + [ + {"version": "1"}, + {"version": "v0"}, + {"code": "Correcció 1"}, + {"code": ""}, + {"text": " "}, + {"text": ""}, + ], +) +def test_create_validates_the_fields(client, admin, overrides): + result = client.post("/api/admin/prompts", json={**NEW_PROMPT, **overrides}) + + assert result.status_code == 422 + + +def test_list_prompts_with_response_counts(client, admin, session): + add_prompt(session, code="correccio_91", responses=2) + add_prompt(session, code="correccio_92", text="Un altre.", responses=0) + + result = client.get("/api/admin/prompts") + + assert result.status_code == 200 + prompts = {prompt["code"]: prompt for prompt in result.json()["prompts"]} + assert prompts["correccio_91"]["response_count"] == 2 + assert prompts["correccio_92"]["response_count"] == 0 + assert result.json()["total"] == 2 + + +def test_list_prompts_filters_and_paginates(client, admin, session): + add_prompt(session, code="correccio_91", version="v1", text="a") + add_prompt(session, code="correccio_92", version="v2", text="b") + add_prompt(session, code="correccio_93", version="v2", text="c") + + only_v2 = client.get("/api/admin/prompts", params={"version": "v2"}).json() + page = client.get("/api/admin/prompts", params={"limit": 1, "offset": 1}).json() + + assert [prompt["code"] for prompt in only_v2["prompts"]] == ["correccio_92", "correccio_93"] + assert only_v2["total"] == 2 + assert len(page["prompts"]) == 1 + assert page["total"] == 3 + + +def test_delete_prompt_without_inferences(client, admin, session): + prompt = add_prompt(session) + + result = client.delete(f"/api/admin/prompts/{prompt.id}") + + assert result.status_code == 204 + assert session.get(Prompt, prompt.id) is None + + +def test_delete_prompt_with_inferences_is_refused(client, admin, session): + prompt = add_prompt(session, responses=2) + + result = client.delete(f"/api/admin/prompts/{prompt.id}") + + assert result.status_code == 409 + assert session.get(Prompt, prompt.id) is not None + assert len(session.scalars(select(Response)).all()) == 2 + + +def test_delete_unknown_prompt_is_not_found(client, admin): + assert client.delete("/api/admin/prompts/999999").status_code == 404 + + +def test_database_rejects_a_duplicate_hash_in_a_version(session): + add_prompt(session, code="correccio_91", text="Repetit") + + with pytest.raises(IntegrityError): + add_prompt(session, code="correccio_92", text="Repetit") + session.rollback() diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index ba4c479..ae1592b 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -597,7 +597,7 @@ def test_get_ranking_status_depends_on_prompt_coverage_and_confidence( category = session.scalar(select(Category).where(Category.code == "correccio")) for index, winner in enumerate(winners): prompt = Prompt( - version="v1", code=f"opposing-{index}", category_id=category.id, text="Text" + version="v1", code=f"opposing-{index}", category_id=category.id, text=f"Text {index}" ) response_a = Response(prompt=prompt, model="model_1", text="Resposta A") response_b = Response(prompt=prompt, model="model_2", text="Resposta B") diff --git a/backend/tests/test_carrega_inferencies.py b/backend/tests/test_carrega_inferencies.py index 4da7f35..bb8b1e0 100644 --- a/backend/tests/test_carrega_inferencies.py +++ b/backend/tests/test_carrega_inferencies.py @@ -16,9 +16,13 @@ import carrega_inferencies as loader # noqa: E402 -def write_prompt(prompts_dir: Path, code: str, text: str = "Tradueix aquest text.") -> None: - """Escriu un prompt de text pla (l'única forma acceptada per la canonada).""" +def write_prompt(prompts_dir: Path, code: str, text: str | None = None) -> None: + """Escriu un prompt de text pla (l'única forma acceptada per la canonada). + + Sense `text`, en genera un que depèn del codi: el contingut dels prompts no es pot repetir. + """ prompts_dir.mkdir(parents=True, exist_ok=True) + text = text if text is not None else f"Tradueix aquest text ({code})." (prompts_dir / f"{code}.txt").write_text(text, encoding="utf-8") @@ -384,3 +388,14 @@ def test_summary_total_errors_drives_exit_code(session, dirs): summary = loader.run_load(session, prompts_dir, inferencies_dir) assert summary.total_errors == 1 + + +def test_prompt_with_repeated_content_is_an_error_not_a_crash(session, dirs): + prompts_dir, inferencies_dir = dirs + write_prompt(prompts_dir, "traduccio_1", "El mateix text.") + write_prompt(prompts_dir, "traduccio_2", "El mateix text.") + + summary = loader.run_load(session, prompts_dir, inferencies_dir) + + assert summary.prompts.inserted == 1 + assert summary.prompts.errors == 1 diff --git a/backend/tests/test_confidence.py b/backend/tests/test_confidence.py index e3c4f85..3591ac5 100644 --- a/backend/tests/test_confidence.py +++ b/backend/tests/test_confidence.py @@ -27,7 +27,7 @@ def _seed_prompts(session, category_code, n_prompts, models=MODELS): version="v1", code=f"{category_code}-conf-{i:02d}", category_id=cat.id, - text=f"Prompt {i}", + text=f"Prompt {category_code} {i}", ) session.add(prompt) session.flush() diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index d972289..95933c6 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -1,5 +1,6 @@ """Reversibilitat de la migració inicial contra una base de dades efímera.""" +import hashlib import logging import uuid from io import StringIO @@ -158,3 +159,30 @@ def test_admin_migration_defaults_existing_users_and_is_reversible(ephemeral_db_ assert {"is_admin", "blocked_at"}.isdisjoint(columns) finally: engine.dispose() + + +def test_prompt_hash_migration_backfills_existing_prompts(ephemeral_db_url): + """Els prompts existents reben l'empremta SHA-256 del seu text i la migració és reversible.""" + config = _alembic_config(ephemeral_db_url) + engine = create_engine(ephemeral_db_url) + try: + command.upgrade(config, "a1b2c3d4e5f6") + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO categories (code, name) VALUES ('correccio', 'Correcció');" + "INSERT INTO prompts (version, code, category_id, text) " + "SELECT 'v1', 'correccio_1', id, 'Hola' FROM categories" + ) + ) + command.upgrade(config, "head") + with engine.connect() as connection: + assert connection.scalar(text("SELECT content_sha256 FROM prompts")) == ( + hashlib.sha256(b"Hola").hexdigest() + ) + command.check(config) + command.downgrade(config, "a1b2c3d4e5f6") + columns = {column["name"] for column in inspect(engine).get_columns("prompts")} + assert "content_sha256" not in columns + finally: + engine.dispose() diff --git a/backend/tests/test_prompt_versions.py b/backend/tests/test_prompt_versions.py index bc8a3b1..731d099 100644 --- a/backend/tests/test_prompt_versions.py +++ b/backend/tests/test_prompt_versions.py @@ -15,7 +15,9 @@ def make_prompt(session): category_id = session.scalar(select(Category.id).where(Category.code == "correccio")) def create(code, version, n_responses=2): - prompt = Prompt(code=code, version=version, category_id=category_id, text=version) + prompt = Prompt( + code=code, version=version, category_id=category_id, text=f"{code} {version}" + ) session.add(prompt) session.flush() responses = [ diff --git a/backend/tests/test_task_api.py b/backend/tests/test_task_api.py index c99efaf..9b742cd 100644 --- a/backend/tests/test_task_api.py +++ b/backend/tests/test_task_api.py @@ -73,7 +73,7 @@ def category_tasks(session): category = Category(code=code, name=code) session.add(category) session.flush() - prompt = Prompt(version="v1", code=code, category_id=category.id, text="Text") + prompt = Prompt(version="v1", code=code, category_id=category.id, text=f"Text {code}") session.add(prompt) session.flush() responses = [Response(prompt_id=prompt.id, model=model, text=model) for model in ("a", "b")] diff --git a/docs/db_schema.md b/docs/db_schema.md index 45b77d9..196bc5b 100644 --- a/docs/db_schema.md +++ b/docs/db_schema.md @@ -20,6 +20,7 @@ erDiagram varchar(64) code integer category_id FK text text + varchar(64) content_sha256 timestamptz created_at } diff --git a/scripts/carrega_inferencies.py b/scripts/carrega_inferencies.py index 55fe082..a3a65e8 100644 --- a/scripts/carrega_inferencies.py +++ b/scripts/carrega_inferencies.py @@ -33,7 +33,7 @@ from sqlalchemy.orm import Session # noqa: E402 from app.db import get_sessionmaker # noqa: E402 -from app.models import Category, Prompt, Response # noqa: E402 +from app.models import Category, Prompt, Response, text_sha256 # noqa: E402 from app.prompt_versions import VERSION_PATTERN # noqa: E402 from scripts.lib.prompt_versions import inference_directory, version_directories # noqa: E402 @@ -312,6 +312,21 @@ def upsert_prompt( select(Prompt).where(Prompt.version == record.version, Prompt.code == record.code) ) if existing is None: + repeated = session.scalar( + select(Prompt).where( + Prompt.version == record.version, + Prompt.content_sha256 == text_sha256(record.text), + ) + ) + if repeated is not None: + LOGGER.error( + "%s: el contingut ja existeix al prompt (%s/%s)", + record.source, + repeated.version, + repeated.code, + ) + stats.errors += 1 + return session.add( Prompt( version=record.version, From a329d9f02af75cea83268b5ed3ece6ff0c9ac2c2 Mon Sep 17 00:00:00 2001 From: Miquel Date: Sun, 1 Nov 2026 13:35:21 +0100 Subject: [PATCH 09/19] feat: manage inferences from the admin API Admins can list, create, delete and patch responses under `/api/admin/responses`. A response can be deactivated, so it is no longer shown or counted in task progress, and excluded from the ranking, so votes involving it stop counting. A response with votes or skips cannot be deleted. The ranking cache is also invalidated when a response changes, since toggling a flag does not change any id. Co-Authored-By: Claude Sonnet 5.5 --- backend/README.md | 15 + backend/app/models.py | 13 + backend/app/ranking/confidence.py | 1 + backend/app/ranking/ranking.py | 1 + backend/app/ranking/sampler.py | 2 +- backend/app/routes/admin.py | 53 +++- backend/app/schemas.py | 53 +++- .../app/services/admin_response_service.py | 149 ++++++++++ backend/app/services/ranking_service.py | 7 +- backend/app/services/task_service.py | 4 +- .../c3d4e5f6a7b8_add_response_flags.py | 35 +++ backend/tests/test_admin_responses_api.py | 262 ++++++++++++++++++ backend/tests/test_migrations.py | 30 ++ docs/db_schema.md | 3 + 14 files changed, 622 insertions(+), 6 deletions(-) create mode 100644 backend/app/services/admin_response_service.py create mode 100644 backend/migrations/versions/c3d4e5f6a7b8_add_response_flags.py create mode 100644 backend/tests/test_admin_responses_api.py diff --git a/backend/README.md b/backend/README.md index db3dfa0..8441f88 100644 --- a/backend/README.md +++ b/backend/README.md @@ -113,6 +113,21 @@ Un usuari bloquejat que encerta la contrasenya rep 403 en iniciar sessió. - `DELETE /api/admin/prompts/{id}`: esborra un prompt (204). 409 si té inferències associades; 404 si no existeix. +Les inferències (`responses`) tenen dos indicadors independents: + +- `active`: si és fals, no es mostra als avaluadors ni compta per al progrés, però conserva + els vots. Un prompt amb menys de dues respostes actives deixa de servir-se. +- `included_in_ranking`: si és fals, els vots que la inclouen (com a A o B) no compten al + rànquing ni a la confiança. + +- `GET /api/admin/responses?prompt_id=&model=&active=&included_in_ranking=&limit=&offset=`: + inferències per ordre d'`id`, amb `vote_count`. +- `POST /api/admin/responses` amb `{ prompt_id, model, text, inference_metadata? }`: la crea + activa i inclosa (201). 404 si el prompt no existeix; 409 si el model ja hi té resposta. +- `PATCH /api/admin/responses/{id}` amb `{ active?, included_in_ranking? }`: canvia els + indicadors; cal indicar-ne almenys un (422 altrament). +- `DELETE /api/admin/responses/{id}`: esborra una inferència (204). 409 si té vots o omissions. + ### `GET /api/qualification` i `POST /api/qualification` Requereixen una sessió vàlida i la verificació del correu, si està activada. diff --git a/backend/app/models.py b/backend/app/models.py index 819f7c3..c33b587 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -113,9 +113,22 @@ class Response(Base): text: Mapped[str] = mapped_column(Text, nullable=False) # Metadades de la inferència (seed, temperatura, top_p, max_new_tokens, quantization...). inference_metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + # Una resposta inactiva no es mostra als avaluadors, però conserva els vots. + active: Mapped[bool] = mapped_column(Boolean, server_default="true", nullable=False) + # Els vots que impliquen una resposta exclosa no compten al rànquing. + included_in_ranking: Mapped[bool] = mapped_column( + Boolean, server_default="true", nullable=False + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) + # `clock_timestamp` canvia dins la transacció: invalida la memòria cau del rànquing. + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.clock_timestamp(), + nullable=False, + ) prompt: Mapped["Prompt"] = relationship(back_populates="responses") diff --git a/backend/app/ranking/confidence.py b/backend/app/ranking/confidence.py index 0cc0f4b..fc086a4 100644 --- a/backend/app/ranking/confidence.py +++ b/backend/app/ranking/confidence.py @@ -77,6 +77,7 @@ def _load_clustered_votes( .join(response_a, Vote.response_a_id == response_a.id) .join(response_b, Vote.response_b_id == response_b.id) .where(Prompt.id.in_(active_prompt_ids())) + .where(response_a.included_in_ranking, response_b.included_in_ranking) ) if category_code is not None: stmt = stmt.where(Category.code == category_code) diff --git a/backend/app/ranking/ranking.py b/backend/app/ranking/ranking.py index efcf7d4..ed83bef 100644 --- a/backend/app/ranking/ranking.py +++ b/backend/app/ranking/ranking.py @@ -125,6 +125,7 @@ def _load_votes(session: Session, category_code: str | None) -> list[tuple[Winne .join(response_a, Vote.response_a_id == response_a.id) .join(response_b, Vote.response_b_id == response_b.id) .where(Prompt.id.in_(active_prompt_ids())) + .where(response_a.included_in_ranking, response_b.included_in_ranking) ) if category_code is not None: stmt = stmt.where(Category.code == category_code) diff --git a/backend/app/ranking/sampler.py b/backend/app/ranking/sampler.py index e9de086..f39fd56 100644 --- a/backend/app/ranking/sampler.py +++ b/backend/app/ranking/sampler.py @@ -45,7 +45,7 @@ def _load_prompts_and_responses( prompts = session.scalars(stmt).all() out = [] for prompt in prompts: - responses_by_model = {r.model: r for r in prompt.responses} + responses_by_model = {r.model: r for r in prompt.responses if r.active} if len(responses_by_model) >= 2: out.append((prompt, responses_by_model)) return out diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index b2b4665..a78a106 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -9,10 +9,14 @@ AdminPromptCreate, AdminPromptResponse, AdminPromptsResponse, + AdminResponseCreate, + AdminResponseItem, + AdminResponsePatch, + AdminResponsesResponse, AdminUserResponse, AdminUsersResponse, ) -from app.services import admin_prompt_service, admin_service +from app.services import admin_prompt_service, admin_response_service, admin_service router = APIRouter() @@ -71,3 +75,50 @@ def create_prompt( def delete_prompt(prompt_id: int, db: DbSession, _admin: CurrentAdmin) -> None: """Esborra un prompt sense inferències associades.""" admin_prompt_service.delete_prompt(db, prompt_id) + + +@router.get("/responses") +def list_responses( + db: DbSession, + _admin: CurrentAdmin, + response: Response, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, + prompt_id: int | None = None, + model: str | None = None, + active: bool | None = None, + included_in_ranking: bool | None = None, +) -> AdminResponsesResponse: + """Llista les inferències per pàgines, amb el nombre de vots de cadascuna.""" + response.headers["Cache-Control"] = "no-store" + return admin_response_service.list_responses( + db, + limit=limit, + offset=offset, + prompt_id=prompt_id, + model=model, + active=active, + included_in_ranking=included_in_ranking, + ) + + +@router.post("/responses", status_code=status.HTTP_201_CREATED) +def create_response( + payload: AdminResponseCreate, db: DbSession, _admin: CurrentAdmin +) -> AdminResponseItem: + """Afegeix la inferència d'un model a un prompt, activa i inclosa al rànquing.""" + return admin_response_service.create_response(db, payload) + + +@router.patch("/responses/{response_id}") +def update_response( + response_id: int, payload: AdminResponsePatch, db: DbSession, _admin: CurrentAdmin +) -> AdminResponseItem: + """Activa o desactiva una inferència, o la inclou o exclou del rànquing.""" + return admin_response_service.update_response(db, response_id, payload) + + +@router.delete("/responses/{response_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_response(response_id: int, db: DbSession, _admin: CurrentAdmin) -> None: + """Esborra una inferència sense vots ni omissions.""" + admin_response_service.delete_response(db, response_id) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d415fb3..e2cfb89 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -2,7 +2,15 @@ from datetime import date, datetime from typing import Annotated, Literal -from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + EmailStr, + Field, + field_validator, + model_validator, +) from pydantic_core import PydanticCustomError from app.models import Winner @@ -383,3 +391,46 @@ class AdminPromptResponse(BaseModel): class AdminPromptsResponse(BaseModel): total: int prompts: list[AdminPromptResponse] + + +class AdminResponseCreate(BaseModel): + prompt_id: int + model: str = Field(min_length=1, max_length=128) + text: str = Field(min_length=1, max_length=50_000) + inference_metadata: dict | None = None + + @field_validator("text") + @classmethod + def strip_text(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("El text no pot ser buit") + return value + + +class AdminResponsePatch(BaseModel): + active: bool | None = None + included_in_ranking: bool | None = None + + @model_validator(mode="after") + def require_a_change(self) -> "AdminResponsePatch": + if self.active is None and self.included_in_ranking is None: + raise ValueError("Cal indicar almenys un camp a canviar") + return self + + +class AdminResponseItem(BaseModel): + id: int + prompt_id: int + model: str + text: str + inference_metadata: dict | None + active: bool + included_in_ranking: bool + created_at: datetime + vote_count: int + + +class AdminResponsesResponse(BaseModel): + total: int + responses: list[AdminResponseItem] diff --git a/backend/app/services/admin_response_service.py b/backend/app/services/admin_response_service.py new file mode 100644 index 0000000..e4622fd --- /dev/null +++ b/backend/app/services/admin_response_service.py @@ -0,0 +1,149 @@ +"""Alta, llistat, activació i baixa d'inferències (respostes de models) des de l'administració.""" + +from fastapi import HTTPException +from sqlalchemy import func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session as OrmSession + +from app.models import Prompt, Response, TaskSkip, Vote +from app.schemas import ( + AdminResponseCreate, + AdminResponseItem, + AdminResponsePatch, + AdminResponsesResponse, +) + + +def _vote_count(): + return ( + select(func.count(Vote.id)) + .where(or_(Vote.response_a_id == Response.id, Vote.response_b_id == Response.id)) + .correlate(Response) + .scalar_subquery() + ) + + +def _to_item(response: Response, vote_count: int) -> AdminResponseItem: + return AdminResponseItem( + id=response.id, + prompt_id=response.prompt_id, + model=response.model, + text=response.text, + inference_metadata=response.inference_metadata, + active=response.active, + included_in_ranking=response.included_in_ranking, + created_at=response.created_at, + vote_count=vote_count, + ) + + +def _get_item(db: OrmSession, response_id: int) -> AdminResponseItem: + row = db.execute( + select(Response, _vote_count()).where(Response.id == response_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Inferència no trobada") + return _to_item(*row) + + +def list_responses( + db: OrmSession, + *, + limit: int, + offset: int, + prompt_id: int | None, + model: str | None, + active: bool | None, + included_in_ranking: bool | None, +) -> AdminResponsesResponse: + """Retorna una pàgina d'inferències per ordre d'identificador, amb el recompte de vots.""" + filters = [] + if prompt_id is not None: + filters.append(Response.prompt_id == prompt_id) + if model is not None: + filters.append(Response.model == model) + if active is not None: + filters.append(Response.active == active) + if included_in_ranking is not None: + filters.append(Response.included_in_ranking == included_in_ranking) + + rows = db.execute( + select(Response, _vote_count()) + .where(*filters) + .order_by(Response.id) + .limit(limit) + .offset(offset) + ).all() + total = db.scalar(select(func.count(Response.id)).where(*filters)) + return AdminResponsesResponse( + total=total or 0, responses=[_to_item(response, count) for response, count in rows] + ) + + +def create_response(db: OrmSession, payload: AdminResponseCreate) -> AdminResponseItem: + """Afegeix la resposta d'un model a un prompt; cada model hi respon una sola vegada.""" + if db.get(Prompt, payload.prompt_id) is None: + raise HTTPException(status_code=404, detail="Prompt no trobat") + + response = Response( + prompt_id=payload.prompt_id, + model=payload.model, + text=payload.text, + inference_metadata=payload.inference_metadata, + ) + db.add(response) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=409, detail="Aquest model ja té una inferència per a aquest prompt" + ) from None + return _get_item(db, response.id) + + +def update_response( + db: OrmSession, response_id: int, payload: AdminResponsePatch +) -> AdminResponseItem: + """Canvia si una inferència es mostra i si els seus vots compten al rànquing.""" + response = db.get(Response, response_id) + if response is None: + raise HTTPException(status_code=404, detail="Inferència no trobada") + + if payload.active is not None: + response.active = payload.active + if payload.included_in_ranking is not None: + response.included_in_ranking = payload.included_in_ranking + db.commit() + return _get_item(db, response_id) + + +def delete_response(db: OrmSession, response_id: int) -> None: + """Esborra una inferència que no té cap vot ni omissió associats.""" + response = db.get(Response, response_id) + if response is None: + raise HTTPException(status_code=404, detail="Inferència no trobada") + + referenced = db.scalar( + select(Vote.id) + .where(or_(Vote.response_a_id == response_id, Vote.response_b_id == response_id)) + .limit(1) + ) or db.scalar( + select(TaskSkip.id) + .where(or_(TaskSkip.response_a_id == response_id, TaskSkip.response_b_id == response_id)) + .limit(1) + ) + if referenced is not None: + raise HTTPException( + status_code=409, + detail="La inferència té vots o omissions associats; desactiveu-la en lloc d'esborrar", + ) + + db.delete(response) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=409, detail="La inferència encara està referenciada i no es pot esborrar" + ) from None diff --git a/backend/app/services/ranking_service.py b/backend/app/services/ranking_service.py index e952e73..a62592f 100644 --- a/backend/app/services/ranking_service.py +++ b/backend/app/services/ranking_service.py @@ -20,9 +20,10 @@ def _cache_by_max_ids( públic. La clau d'invalidació és el màxim `id` de vots, prompts i respostes: les seqüències de Postgres no retrocedeixen, així que qualsevol inserció a qualsevol d'aquestes taules força el recàlcul. Cal incloure prompts i - respostes perquè `active_prompt_ids` en depèn. + respostes perquè `active_prompt_ids` en depèn. A això s'hi afegeix la data de + darrera modificació de les respostes, que canvia en exclourer-ne una del rànquing. """ - cache: dict[str | None, tuple[tuple[int, int, int], dict]] = {} + cache: dict[str | None, tuple[tuple, dict]] = {} @functools.wraps(fn) def wrapper(db: Session, category_code: str | None) -> dict: @@ -30,6 +31,8 @@ def wrapper(db: Session, category_code: str | None) -> dict: db.scalar(select(func.max(Vote.id))) or 0, db.scalar(select(func.max(Prompt.id))) or 0, db.scalar(select(func.max(Response.id))) or 0, + # Activar o excloure una resposta no canvia cap id: es detecta per la data. + db.scalar(select(func.max(Response.updated_at))), ) entry = cache.get(category_code) if entry is not None and entry[0] == invalidator: diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 6b69040..052daf8 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -95,7 +95,9 @@ def get_task_progress_for_user(user: User, db: Session) -> TaskProgressResponse: """Retorna el progrés global de tasques d'un usuari.""" responses_by_prompt = defaultdict(list) for prompt_id, response_id in db.execute( - select(Response.prompt_id, Response.id).where(Response.prompt_id.in_(active_prompt_ids())) + select(Response.prompt_id, Response.id).where( + Response.prompt_id.in_(active_prompt_ids()), Response.active + ) ).all(): responses_by_prompt[prompt_id].append(response_id) diff --git a/backend/migrations/versions/c3d4e5f6a7b8_add_response_flags.py b/backend/migrations/versions/c3d4e5f6a7b8_add_response_flags.py new file mode 100644 index 0000000..62fa064 --- /dev/null +++ b/backend/migrations/versions/c3d4e5f6a7b8_add_response_flags.py @@ -0,0 +1,35 @@ +"""Afegeix a les respostes els indicadors d'activació i de rànquing. + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c3d4e5f6a7b8" +down_revision = "b2c3d4e5f6a7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "responses", sa.Column("active", sa.Boolean(), server_default="true", nullable=False) + ) + op.add_column( + "responses", + sa.Column("included_in_ranking", sa.Boolean(), server_default="true", nullable=False), + ) + op.add_column( + "responses", + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + ) + + +def downgrade() -> None: + op.drop_column("responses", "updated_at") + op.drop_column("responses", "included_in_ranking") + op.drop_column("responses", "active") diff --git a/backend/tests/test_admin_responses_api.py b/backend/tests/test_admin_responses_api.py new file mode 100644 index 0000000..4f5d5b9 --- /dev/null +++ b/backend/tests/test_admin_responses_api.py @@ -0,0 +1,262 @@ +"""Gestió d'inferències des de l'administració i el seu efecte en tasques i rànquing.""" + +import pytest +from sqlalchemy import select + +from app.models import Category, Prompt, Response, TaskSkip, Vote, Winner + + +@pytest.fixture +def prompt(session) -> Prompt: + category = session.scalar(select(Category).where(Category.code == "correccio")) + prompt = Prompt(version="v1", code="correccio_80", category_id=category.id, text="Text base") + session.add(prompt) + session.commit() + return prompt + + +def add_response(session, prompt, model, text="Resposta") -> Response: + response = Response(prompt_id=prompt.id, model=model, text=text) + session.add(response) + session.commit() + return response + + +def vote(session, prompt, response_a, response_b, winner=Winner.a, user_id=None) -> Vote: + row = Vote( + prompt_id=prompt.id, + user_id=user_id, + response_a_id=response_a.id, + response_b_id=response_b.id, + winner=winner, + ) + session.add(row) + session.commit() + return row + + +def test_response_endpoints_require_a_session(client): + assert client.get("/api/admin/responses").status_code == 401 + assert client.post("/api/admin/responses", json={}).status_code == 401 + assert client.patch("/api/admin/responses/1", json={"active": False}).status_code == 401 + assert client.delete("/api/admin/responses/1").status_code == 401 + + +def test_response_endpoints_require_an_admin(client, logged_in_user): + logged_in_user("regular@example.com") + assert client.get("/api/admin/responses").status_code == 403 + assert client.post("/api/admin/responses", json={}).status_code == 403 + assert client.patch("/api/admin/responses/1", json={"active": False}).status_code == 403 + assert client.delete("/api/admin/responses/1").status_code == 403 + + +def test_create_response_is_active_and_counts_in_the_ranking(client, admin, session, prompt): + result = client.post( + "/api/admin/responses", + json={ + "prompt_id": prompt.id, + "model": "model-a", + "text": " Una resposta. ", + "inference_metadata": {"temperature": 0.7}, + }, + ) + + assert result.status_code == 201 + body = result.json() + assert body["active"] is True + assert body["included_in_ranking"] is True + assert body["text"] == "Una resposta." + assert body["vote_count"] == 0 + stored = session.get(Response, body["id"]) + assert stored.inference_metadata == {"temperature": 0.7} + + +def test_create_response_for_an_unknown_prompt(client, admin): + result = client.post( + "/api/admin/responses", json={"prompt_id": 999999, "model": "model-a", "text": "x"} + ) + + assert result.status_code == 404 + + +def test_create_rejects_a_second_response_of_the_same_model(client, admin, session, prompt): + add_response(session, prompt, "model-a") + + result = client.post( + "/api/admin/responses", + json={"prompt_id": prompt.id, "model": "model-a", "text": "Una altra"}, + ) + + assert result.status_code == 409 + + +@pytest.mark.parametrize("overrides", [{"model": ""}, {"text": " "}, {"text": ""}]) +def test_create_validates_the_fields(client, admin, prompt, overrides): + body = {"prompt_id": prompt.id, "model": "model-a", "text": "Resposta", **overrides} + + assert client.post("/api/admin/responses", json=body).status_code == 422 + + +def test_list_responses_with_vote_counts_and_filters(client, admin, session, prompt): + first = add_response(session, prompt, "model-a") + second = add_response(session, prompt, "model-b") + third = add_response(session, prompt, "model-c") + vote(session, prompt, first, second) + vote(session, prompt, third, first) + second.active = False + session.commit() + + everything = client.get("/api/admin/responses").json() + inactive = client.get("/api/admin/responses", params={"active": "false"}).json() + by_model = client.get("/api/admin/responses", params={"model": "model-a"}).json() + by_prompt = client.get("/api/admin/responses", params={"prompt_id": prompt.id}).json() + + counts = {item["model"]: item["vote_count"] for item in everything["responses"]} + assert counts == {"model-a": 2, "model-b": 1, "model-c": 1} + assert everything["total"] == 3 + assert [item["model"] for item in inactive["responses"]] == ["model-b"] + assert [item["model"] for item in by_model["responses"]] == ["model-a"] + assert by_prompt["total"] == 3 + + +def test_patch_changes_the_flags(client, admin, session, prompt): + response = add_response(session, prompt, "model-a") + + result = client.patch( + f"/api/admin/responses/{response.id}", + json={"active": False, "included_in_ranking": False}, + ) + + assert result.status_code == 200 + assert result.json()["active"] is False + assert result.json()["included_in_ranking"] is False + session.refresh(response) + assert (response.active, response.included_in_ranking) == (False, False) + + +def test_patch_changes_only_the_given_flag(client, admin, session, prompt): + response = add_response(session, prompt, "model-a") + + client.patch(f"/api/admin/responses/{response.id}", json={"active": False}) + + session.refresh(response) + assert (response.active, response.included_in_ranking) == (False, True) + + +def test_patch_requires_at_least_one_field(client, admin, session, prompt): + response = add_response(session, prompt, "model-a") + + assert client.patch(f"/api/admin/responses/{response.id}", json={}).status_code == 422 + + +def test_patch_unknown_response(client, admin): + assert client.patch("/api/admin/responses/999999", json={"active": False}).status_code == 404 + + +def test_delete_response_without_votes(client, admin, session, prompt): + response = add_response(session, prompt, "model-a") + + result = client.delete(f"/api/admin/responses/{response.id}") + + assert result.status_code == 204 + assert session.get(Response, response.id) is None + + +def test_delete_response_with_votes_is_refused(client, admin, session, prompt): + first = add_response(session, prompt, "model-a") + second = add_response(session, prompt, "model-b") + vote(session, prompt, first, second) + + result = client.delete(f"/api/admin/responses/{second.id}") + + assert result.status_code == 409 + assert session.get(Response, second.id) is not None + + +def test_delete_response_with_skips_is_refused(client, admin, session, prompt): + first = add_response(session, prompt, "model-a") + second = add_response(session, prompt, "model-b") + session.add( + TaskSkip( + prompt_id=prompt.id, + user_id=admin.id, + response_a_id=first.id, + response_b_id=second.id, + ) + ) + session.commit() + + assert client.delete(f"/api/admin/responses/{first.id}").status_code == 409 + + +def test_delete_unknown_response(client, admin): + assert client.delete("/api/admin/responses/999999").status_code == 404 + + +# --------------------------------------------------------------------------- +# Efecte en les tasques, el progrés i el rànquing. +# --------------------------------------------------------------------------- + + +def test_inactive_response_is_not_served(client, admin, session, prompt): + add_response(session, prompt, "model-a", "Resposta de A") + add_response(session, prompt, "model-b", "Resposta de B") + third = add_response(session, prompt, "model-c", "Resposta de C") + third.active = False + session.commit() + + for _ in range(10): + task = client.get("/api/task").json() + assert "Resposta de C" not in (task["response_a"], task["response_b"]) + + +def test_prompt_with_fewer_than_two_active_responses_is_not_served(client, admin, session, prompt): + add_response(session, prompt, "model-a") + second = add_response(session, prompt, "model-b") + second.active = False + session.commit() + + assert client.get("/api/task").status_code == 404 + + +def test_progress_ignores_inactive_responses(client, admin, session, prompt): + add_response(session, prompt, "model-a") + add_response(session, prompt, "model-b") + third = add_response(session, prompt, "model-c") + + assert client.get("/api/task/progress").json()["total"] == 3 + + third.active = False + session.commit() + + assert client.get("/api/task/progress").json()["total"] == 1 + + +def test_votes_on_excluded_responses_do_not_count_in_the_ranking(client, session, prompt): + first = add_response(session, prompt, "model-a") + second = add_response(session, prompt, "model-b") + third = add_response(session, prompt, "model-c") + vote(session, prompt, first, second) + vote(session, prompt, first, third) + + assert client.get("/api/ranking").json()["n_votes_total"] == 2 + + third.included_in_ranking = False + session.commit() + + assert client.get("/api/ranking").json()["n_votes_total"] == 1 + + +def test_ranking_cache_follows_the_inclusion_flag(client, admin, session, prompt): + first = add_response(session, prompt, "model-a") + second = add_response(session, prompt, "model-b") + vote(session, prompt, first, second) + assert client.get("/api/ranking").json()["n_votes_total"] == 1 + + client.patch(f"/api/admin/responses/{second.id}", json={"included_in_ranking": False}) + + assert client.get("/api/ranking").json()["n_votes_total"] == 0 + + client.patch(f"/api/admin/responses/{second.id}", json={"included_in_ranking": True}) + + assert client.get("/api/ranking").json()["n_votes_total"] == 1 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 95933c6..42140a1 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -186,3 +186,33 @@ def test_prompt_hash_migration_backfills_existing_prompts(ephemeral_db_url): assert "content_sha256" not in columns finally: engine.dispose() + + +def test_response_flags_migration_keeps_existing_responses_active(ephemeral_db_url): + """Les respostes existents queden actives i incloses al rànquing; la migració és reversible.""" + config = _alembic_config(ephemeral_db_url) + engine = create_engine(ephemeral_db_url) + try: + command.upgrade(config, "b2c3d4e5f6a7") + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO categories (code, name) VALUES ('correccio', 'Correcció');" + "INSERT INTO prompts (version, code, category_id, text, content_sha256) " + f"SELECT 'v1', 'correccio_1', id, 'Hola', '{'a' * 64}' FROM categories;" + "INSERT INTO responses (prompt_id, model, text) " + "SELECT id, 'model-a', 'Resposta' FROM prompts" + ) + ) + command.upgrade(config, "head") + with engine.connect() as connection: + row = connection.execute( + text("SELECT active, included_in_ranking FROM responses") + ).one() + assert row == (True, True) + command.check(config) + command.downgrade(config, "b2c3d4e5f6a7") + columns = {column["name"] for column in inspect(engine).get_columns("responses")} + assert {"active", "included_in_ranking", "updated_at"}.isdisjoint(columns) + finally: + engine.dispose() diff --git a/docs/db_schema.md b/docs/db_schema.md index 196bc5b..a7a8996 100644 --- a/docs/db_schema.md +++ b/docs/db_schema.md @@ -30,7 +30,10 @@ erDiagram varchar(128) model text text jsonb inference_metadata + boolean active + boolean included_in_ranking timestamptz created_at + timestamptz updated_at } users { From b2852a5d66bee4214b60e80fe82011fd8ee66951 Mon Sep 17 00:00:00 2001 From: Miquel Date: Sun, 1 Nov 2026 13:42:36 +0100 Subject: [PATCH 10/19] feat: add the admin web interface A new /admin page, shown only to administrators, lets them block users, manage prompts and manage inferences (activate, exclude from the ranking, delete). The session endpoint now reports `is_admin` so the header can link to it, and the API client handles empty 204 responses. Co-Authored-By: Claude Sonnet 5.5 --- backend/app/routes/auth.py | 1 + backend/app/schemas.py | 1 + backend/tests/test_admin_api.py | 16 ++ backend/tests/test_auth_api.py | 3 + frontend/README.md | 10 +- frontend/src/App.tsx | 13 + frontend/src/api.ts | 68 +++++ frontend/src/components/AccountMenu.tsx | 13 +- frontend/src/components/AdminView.tsx | 61 ++++ .../src/components/admin/AdminPrompts.tsx | 221 +++++++++++++++ .../src/components/admin/AdminResponses.tsx | 268 ++++++++++++++++++ frontend/src/components/admin/AdminUsers.tsx | 114 ++++++++ frontend/src/components/admin/shared.tsx | 140 +++++++++ frontend/src/types.ts | 42 +++ 14 files changed, 969 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/AdminView.tsx create mode 100644 frontend/src/components/admin/AdminPrompts.tsx create mode 100644 frontend/src/components/admin/AdminResponses.tsx create mode 100644 frontend/src/components/admin/AdminUsers.tsx create mode 100644 frontend/src/components/admin/shared.tsx diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index c0f3bf4..c363f3d 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -124,6 +124,7 @@ def get_session(current_user: OptionalUser) -> SessionResponse: email=current_user.email, email_verified=current_user.email_verified_at is not None, qualified=current_user.qualified_at is not None, + is_admin=current_user.is_admin, ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e2cfb89..854d4c8 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -197,6 +197,7 @@ class SessionResponse(BaseModel): email: str | None = None email_verified: bool = False qualified: bool = False + is_admin: bool = False QualificationChoice = Literal["A", "B", "C"] diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index ff6a33f..87d04a9 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -173,3 +173,19 @@ def test_new_users_are_not_admins(create_user): assert user.is_admin is False assert user.blocked_at is None assert isinstance(user, User) + + +def test_session_exposes_the_admin_role(client, admin, create_user, login): + assert client.get("/api/auth/session").json()["is_admin"] is True + + other = TestClient(app, base_url="https://testserver") + create_user("plain@example.com") + other.post( + "/api/auth/login", + json={"email": "plain@example.com", "password": "ContrasenyaSegura123!"}, + ) + assert other.get("/api/auth/session").json()["is_admin"] is False + + +def test_anonymous_session_is_not_admin(client): + assert client.get("/api/auth/session").json()["is_admin"] is False diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index ae1592b..f909c68 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -388,6 +388,7 @@ def test_session_returns_authenticated_user(client, logged_in_user): "email": "session_ok@example.com", "email_verified": True, "qualified": True, + "is_admin": False, } @@ -400,6 +401,7 @@ def test_session_without_cookie_returns_unauthenticated(client): "email": None, "email_verified": False, "qualified": False, + "is_admin": False, } @@ -449,6 +451,7 @@ def test_session_reports_unverified_email(client, create_user, login): "email": "session_unverified@example.com", "email_verified": False, "qualified": True, + "is_admin": False, } diff --git a/frontend/README.md b/frontend/README.md index 9cf553a..8202e80 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,12 +25,20 @@ Les preguntes i el llindar provenen del backend; l'acreditació queda desada a `users.qualified_at` i evita repetir la prova en sessions posteriors. La capçalera mostra el logotip i el nom, que duen a l'avaluació (`/`), i l'enllaç al rànquing. -Amb sessió hi apareix també el **menú del compte** (recordatoris, descarregar les dades personals, +Amb sessió hi apareix també el **menú del compte** (recordatoris, administració si s'escau, +descarregar les dades personals, donar de baixa el compte i tancar la sessió). El **Rànquing** (`/ranking`) és accessible des de la capçalera amb sessió o sense. Amb sessió, el botó **Avalua** permet tornar a l’avaluació. +La pàgina **Administració** (`/admin`) és només per a comptes amb el rol d'administrador; +la resta són redirigits a l'inici. L'enllaç apareix al menú del compte quan +`GET /api/auth/session` retorna `is_admin`. Té tres pestanyes: **Usuaris** (bloquejar i +desbloquejar), **Prompts** (crear, filtrar i esborrar els que no tenen inferències) i +**Inferències** (afegir, activar o desactivar, incloure o excloure del rànquing, i esborrar les +que no tenen vots). Per fer algú administrador, vegeu l'[API d'administració](../backend/README.md#apiadmin). + La pàgina **Activitat** (`/activitat`) requereix sessió iniciada i s’hi accedeix directament per la URL, sense enllaç al menú. Mostra els [comptadors globals i recomptes diaris](../docs/sistema.md#activitat-diària). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1229a39..1a5fbf3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import arenaLogo from "./assets/arena-cat-logo.svg"; import logotip from "./assets/softcatala-logotip.png"; import AccountMenu from "./components/AccountMenu"; import ActivityView from "./components/ActivityView"; +import AdminView from "./components/AdminView"; import DeleteAccountDialog from "./components/DeleteAccountDialog"; import ForgotPasswordView from "./components/ForgotPasswordView"; import HowItWorksView from "./components/HowItWorksView"; @@ -24,6 +25,7 @@ const UNKNOWN: SessionState = { email: null, email_verified: false, qualified: false, + is_admin: false, }; export default function App() { @@ -114,6 +116,7 @@ export default function App() { {session?.authenticated && ( setShowDeleteAccount(true)} /> @@ -263,6 +266,16 @@ export default function App() { ) } /> + + ) : ( + + ) + } + /> } /> )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7b0ecdf..b7bb6f5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -11,6 +11,9 @@ import { readDetail, readErrorCode, TASK_TOKEN_INVALID } from "./errors"; import type { Activity, + AdminPrompt, + AdminResponse, + AdminUser, Category, CategoryFilter, ExportData, @@ -19,6 +22,7 @@ import type { QualificationResult, Questionnaire, Ranking, + Page, SessionState, Task, Winner, @@ -68,10 +72,74 @@ async function request(path: string, init?: RequestInit, notifyUnauthorized = throw new ApiError(response.status, detail ?? `Error HTTP ${response.status}`); } + // 204: l'esborrat d'administració no retorna cos. + if (response.status === 204) return undefined as T; return response.json() as Promise; } +function query(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== "") search.set(key, String(value)); + } + const text = search.toString(); + return text ? `?${text}` : ""; +} + +const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }); + +/** Endpoints d'administració (`/api/admin/*`): 403 si el compte no és administrador. */ +const admin = { + users: async (limit: number, offset: number): Promise> => { + const data = await request<{ total: number; users: AdminUser[] }>( + `/admin/users${query({ limit, offset })}`, + ); + return { total: data.total, items: data.users }; + }, + blockUser: (id: number) => request(`/admin/users/${id}/block`, { method: "POST" }), + unblockUser: (id: number) => request(`/admin/users/${id}/unblock`, { method: "POST" }), + + prompts: async ( + limit: number, + offset: number, + filters: { version?: string; category_code?: string }, + ): Promise> => { + const data = await request<{ total: number; prompts: AdminPrompt[] }>( + `/admin/prompts${query({ limit, offset, ...filters })}`, + ); + return { total: data.total, items: data.prompts }; + }, + createPrompt: (prompt: { version: string; code: string; category_code: string; text: string }) => + request("/admin/prompts", post(prompt)), + deletePrompt: (id: number) => request(`/admin/prompts/${id}`, { method: "DELETE" }), + + responses: async ( + limit: number, + offset: number, + filters: { + prompt_id?: number | ""; + model?: string; + active?: boolean | ""; + included_in_ranking?: boolean | ""; + }, + ): Promise> => { + const data = await request<{ total: number; responses: AdminResponse[] }>( + `/admin/responses${query({ limit, offset, ...filters })}`, + ); + return { total: data.total, items: data.responses }; + }, + createResponse: (response: { prompt_id: number; model: string; text: string }) => + request("/admin/responses", post(response)), + updateResponse: (id: number, changes: { active?: boolean; included_in_ranking?: boolean }) => + request(`/admin/responses/${id}`, { + method: "PATCH", + body: JSON.stringify(changes), + }), + deleteResponse: (id: number) => request(`/admin/responses/${id}`, { method: "DELETE" }), +}; + export const api = { + admin, reminderInvitation: () => request<{ show: boolean }>("/auth/reminders/invitation", { method: "POST" }), reminders: () => request<{ enabled: boolean }>("/auth/reminders"), diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index 6b54d23..71751ec 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -6,6 +6,7 @@ import { downloadJson } from "../download"; interface Props { email: string | null; + isAdmin: boolean; onLogout: () => void; onDeleteAccount: () => void; } @@ -14,7 +15,7 @@ const ITEM = "flex w-full items-center gap-2 px-4 py-2 text-left text-sm hover:bg-brand-100 focus-visible:bg-brand-100 focus-visible:outline-none"; /** Menú desplegable amb les accions del compte de la persona autenticada. */ -export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) { +export default function AccountMenu({ email, isAdmin, onLogout, onDeleteAccount }: Props) { const [open, setOpen] = useState(false); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(null); @@ -94,6 +95,16 @@ export default function AccountMenu({ email, onLogout, onDeleteAccount }: Props) Recordatoris + {isAdmin && ( + setOpen(false)} + className={`${ITEM} text-slate-700`} + > + Administració + + )} + ))} +
+
+ {tab === "users" && } + {tab === "prompts" && } + {tab === "responses" && } +
+
+ ); +} diff --git a/frontend/src/components/admin/AdminPrompts.tsx b/frontend/src/components/admin/AdminPrompts.tsx new file mode 100644 index 0000000..d3cc7fe --- /dev/null +++ b/frontend/src/components/admin/AdminPrompts.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState } from "react"; + +import { api } from "../../api"; +import type { AdminPrompt, Category } from "../../types"; +import { Field, INPUT } from "../Field"; +import { ConfirmDelete, ErrorNote, messageOf, PAGE_SIZE, Pager, TABLE, TD, TH } from "./shared"; + +const EMPTY = { version: "v1", code: "", category_code: "", text: "" }; + +export default function AdminPrompts({ categories }: { categories: Category[] }) { + const [prompts, setPrompts] = useState(null); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [version, setVersion] = useState(""); + const [category, setCategory] = useState(""); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(null); + const [form, setForm] = useState(EMPTY); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + const page = await api.admin.prompts(PAGE_SIZE, offset, { + version: version.trim(), + category_code: category, + }); + setPrompts(page.items); + setTotal(page.total); + setError(null); + } catch (err) { + setError(messageOf(err)); + } + }, [offset, version, category]); + + useEffect(() => { + void load(); + }, [load]); + + async function create(event: React.FormEvent) { + event.preventDefault(); + if (busy) return; + setBusy(true); + try { + await api.admin.createPrompt({ + ...form, + category_code: form.category_code || categories[0]?.code, + }); + setForm({ ...EMPTY, version: form.version, category_code: form.category_code }); + setOffset(0); + await load(); + } catch (err) { + setError(messageOf(err)); + } finally { + setBusy(false); + } + } + + async function remove(prompt: AdminPrompt) { + try { + await api.admin.deletePrompt(prompt.id); + setConfirming(null); + await load(); + } catch (err) { + setConfirming(null); + setError(messageOf(err)); + } + } + + return ( +
+
+

+ Prompts +

+

+ El contingut no es pot repetir dins d'una versió. Només s'esborren els prompts sense + inferències. +

+
+ +
void create(event)} + className="space-y-3 rounded-lg border border-slate-200 bg-white p-4" + > +

Nou prompt

+
+ + setForm({ ...form, version: event.target.value })} + required + /> + + + setForm({ ...form, code: event.target.value })} + required + /> + + + + +
+ +