Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions backend/app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,10 @@ 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."""
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)


Expand Down
11 changes: 11 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -269,6 +279,7 @@ class ExportUserResponse(BaseModel):
class ExportDataResponse(BaseModel):
user: ExportUserResponse
votes: list[ExportVoteResponse]
skips: list[ExportSkipResponse]


class PairwiseStat(BaseModel):
Expand Down
9 changes: 7 additions & 2 deletions backend/app/services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
)
64 changes: 63 additions & 1 deletion backend/tests/test_auth_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -309,6 +309,68 @@ 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_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")

Expand Down
2 changes: 1 addition & 1 deletion docs/auth_flow_manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down
11 changes: 8 additions & 3 deletions docs/usuaris_autenticacio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -340,12 +340,17 @@ 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.
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.

### 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.

Expand Down
8 changes: 7 additions & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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ó.

Expand Down Expand Up @@ -118,10 +122,12 @@ 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. |
| `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. |
Expand Down Expand Up @@ -153,4 +159,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ó.
82 changes: 20 additions & 62 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -93,52 +95,28 @@ export default function App() {
<div className="min-h-screen bg-slate-50 pb-44 text-slate-900 md:pb-0">
<header className="border-b-4 border-brand-500 bg-white">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-3 px-4 py-4">
<div>
<h1 className="text-lg font-bold text-brand-600">Arena Cat</h1>
<p className="text-sm text-slate-500">Avaluació humana de models d'IA en català</p>
</div>
<div className="flex items-center gap-2">
<Link
to="/"
className="flex items-center gap-3 rounded focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:outline-none"
>
<img src={arenaLogo} alt="" width={44} height={44} className="h-11 w-11" />
<span>
<span className="block text-lg font-bold text-brand-600">Arena Cat</span>
<span className="block text-sm text-slate-500">
Avaluació humana de models d'IA en català
</span>
</span>
</Link>
<div className="ml-auto flex items-center gap-2">
<Link to="/ranking" className="text-sm text-brand-600 hover:underline">
Rànquing
</Link>
{session?.authenticated && (
<>
<Link to="/reminders" className="text-sm text-brand-600 hover:underline">
Recordatoris
</Link>
<span className="hidden text-sm text-slate-500 sm:inline">{session.email}</span>
<button
type="button"
onClick={() => setShowDeleteAccount(true)}
title="Dona de baixa el compte"
aria-label="Dona de baixa el compte"
className="rounded-md p-2 text-slate-500 hover:bg-brand-100 hover:text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:outline-none"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
</svg>
</button>
{/* Icona sola: `title` per al ratolí i `aria-label` per al lector de
pantalla, que altrament només trobaria un botó sense nom. */}
<button
type="button"
onClick={logout}
title="Tanca la sessió"
aria-label="Tanca la sessió"
className="rounded-md p-2 text-slate-500 hover:bg-brand-100 hover:text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:outline-none"
>
<LogoutIcon />
</button>
</>
<AccountMenu
email={session.email}
onLogout={logout}
onDeleteAccount={() => setShowDeleteAccount(true)}
/>
)}
</div>
</div>
Expand Down Expand Up @@ -326,23 +304,3 @@ function CategoriesStatus({ error, onRetry }: { error: boolean; onRetry: () => v
</div>
);
}

/** Porta amb una fletxa cap enfora: el gest habitual per a «surt». */
function LogoutIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
);
}
4 changes: 4 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
Activity,
Category,
CategoryFilter,
ExportData,
Progress,
QualificationChoice,
QualificationResult,
Expand Down Expand Up @@ -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<ExportData>("/auth/export"),

deleteAccount: async (password: string) => {
try {
return await request<{ status: string }>(
Expand Down
Loading
Loading