Ny POST /orgs/{id}/players/bulk (samme e-post-match-dedup-mønster som
selvregistreringens ADR-017 Beslutning B, utvidet til alle felt).
CSV parses klient-side (papaparse), enkel kolonnegjetting, redigerbar
forhåndsvisning før lagring. Dekker både lagturneringer (lagtildeling)
og individuelle turneringer (klassetilknytning) i samme flyt -- begge
steg samlet (org-pool + turneringspåmelding).
Midlertidig hånd-kodet UI (player-import-panel.tsx) bygget for å bevise
hele kjeden ende-til-ende -- venter på V0-eksport for den polerte
visningen før samlet utrulling, samme rekkefølge som FlagPlantSheet.
5 nye backend-tester (111/111 totalt), tsc rent, 45/45 vitest, full
scratch-verifisert i begge turneringsformater.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""
|
|
Spillerpool på organisasjonsnivå (ADR-001: gjenbrukbar på tvers av turneringer).
|
|
|
|
Kun org-medlemskap kreves (get_authorized_org) — å opprette en spiller i poolen
|
|
er organisator-arbeid, ikke noe som forutsetter rostring på et lag.
|
|
|
|
Utvidet profil (ADR-017): kontakt-/identitetsfelt brukt til å matche en
|
|
selvregistrerende spiller mot en allerede organisator-opprettet rad på
|
|
e-post (se app/routers/registration.py), og senere til å koble
|
|
app_user.id på (app/routers/auth.py sin verify_magic_link).
|
|
"""
|
|
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
|
|
from ..auth import get_authorized_org
|
|
from ..db import org_connection
|
|
from ..errors import app_error, translate_db_errors
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class PlayerCreate(BaseModel):
|
|
display_name: str = Field(min_length=1, max_length=200)
|
|
# Samme grense som ProfileUpdate.handicap_index i auth.py (WHS-maksimum).
|
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
|
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
|
mobile: str | None = Field(default=None, max_length=30)
|
|
email: EmailStr | None = None
|
|
birth_date: date | None = None
|
|
nickname: str | None = Field(default=None, max_length=100)
|
|
country: str | None = Field(default=None, max_length=100)
|
|
club: str | None = Field(default=None, max_length=200)
|
|
club_member_number: str | None = Field(default=None, max_length=50)
|
|
# Utvidet registreringsskjema (2026-08-14, migrasjon 074) -- for-/
|
|
# etternavn er en VALGFRI, separat kilde ved siden av display_name
|
|
# (som fortsatt er det som faktisk vises overalt ellers, uendret) --
|
|
# se migrasjonens header. paid/comment er ren registreringsadministrasjon.
|
|
first_name: str | None = Field(default=None, max_length=100)
|
|
last_name: str | None = Field(default=None, max_length=100)
|
|
paid: bool = False
|
|
comment: str | None = Field(default=None, max_length=2000)
|
|
|
|
|
|
class Player(BaseModel):
|
|
id: str
|
|
display_name: str
|
|
handicap_index: float | None
|
|
gender: str | None
|
|
mobile: str | None
|
|
email: str | None
|
|
birth_date: date | None
|
|
nickname: str | None
|
|
country: str | None
|
|
club: str | None
|
|
club_member_number: str | None
|
|
first_name: str | None
|
|
last_name: str | None
|
|
paid: bool
|
|
comment: str | None
|
|
|
|
|
|
_PLAYER_COLUMNS = """
|
|
id::text, display_name, handicap_index::float AS handicap_index, gender,
|
|
mobile, email, birth_date, nickname, country, club, club_member_number,
|
|
first_name, last_name, paid, comment
|
|
"""
|
|
|
|
|
|
@router.get("/orgs/{organization_id}/players", response_model=list[Player])
|
|
async def list_players(
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[Player]:
|
|
async with org_connection(organization_id) as conn:
|
|
rows = await conn.fetch(
|
|
f"SELECT {_PLAYER_COLUMNS} FROM player ORDER BY display_name"
|
|
)
|
|
return [Player(**dict(r)) for r in rows]
|
|
|
|
|
|
@router.post("/orgs/{organization_id}/players", response_model=Player, status_code=201)
|
|
async def create_player(
|
|
body: PlayerCreate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Player:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
INSERT INTO player
|
|
(organization_id, display_name, handicap_index, gender,
|
|
mobile, email, birth_date, nickname, country, club, club_member_number,
|
|
first_name, last_name, paid, comment)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
RETURNING {_PLAYER_COLUMNS}
|
|
""",
|
|
organization_id,
|
|
body.display_name,
|
|
body.handicap_index,
|
|
body.gender,
|
|
body.mobile,
|
|
body.email.lower() if body.email else None,
|
|
body.birth_date,
|
|
body.nickname,
|
|
body.country,
|
|
body.club,
|
|
body.club_member_number,
|
|
body.first_name,
|
|
body.last_name,
|
|
body.paid,
|
|
body.comment,
|
|
)
|
|
return Player(**dict(row))
|
|
|
|
|
|
class PlayerBulkResult(BaseModel):
|
|
player: Player
|
|
status: str # "created" | "updated"
|
|
|
|
|
|
@router.post("/orgs/{organization_id}/players/bulk", response_model=list[PlayerBulkResult], status_code=201)
|
|
async def create_players_bulk(
|
|
body: list[PlayerCreate],
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[PlayerBulkResult]:
|
|
"""Massimport (CSV-basert, se FEATURE_BACKLOG.md). Samme "match på
|
|
e-post, fyll KUN tomme felt, ellers opprett ny"-mønster som
|
|
`registration.py` sin selvregistrering (ADR-017 Beslutning B) -- her
|
|
utvidet til ALLE `PlayerCreate`-felt (registration.py sin variant
|
|
mangler first_name/last_name/paid/comment, som ikke fantes da den ble
|
|
skrevet). `display_name` og `paid` røres ALDRI på en eksisterende
|
|
match -- førstnevnte er organisators kanoniske navn, sistnevnte er en
|
|
ikke-nullbar boolean (COALESCE mot NULL er da alltid en no-op, som er
|
|
riktig oppførsel: en import skal ikke stille kunne endre en allerede
|
|
satt betalt-status). Hele lista i én transaksjon (org_connection sin
|
|
egen) -- ingen delvis-suksess-håndtering, brukeren har allerede
|
|
validert/rettet radene i tabellen før "Lagre" trykkes."""
|
|
results: list[PlayerBulkResult] = []
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
for p in body:
|
|
existing = None
|
|
if p.email:
|
|
existing = await conn.fetchrow(
|
|
"SELECT id FROM player WHERE lower(email) = lower($1)", p.email
|
|
)
|
|
if existing is not None:
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
UPDATE player SET
|
|
handicap_index = COALESCE(handicap_index, $2),
|
|
gender = COALESCE(gender, $3),
|
|
mobile = COALESCE(mobile, $4),
|
|
birth_date = COALESCE(birth_date, $5),
|
|
nickname = COALESCE(nickname, $6),
|
|
country = COALESCE(country, $7),
|
|
club = COALESCE(club, $8),
|
|
club_member_number = COALESCE(club_member_number, $9),
|
|
first_name = COALESCE(first_name, $10),
|
|
last_name = COALESCE(last_name, $11),
|
|
comment = COALESCE(comment, $12)
|
|
WHERE id = $1
|
|
RETURNING {_PLAYER_COLUMNS}
|
|
""",
|
|
existing["id"],
|
|
p.handicap_index,
|
|
p.gender,
|
|
p.mobile,
|
|
p.birth_date,
|
|
p.nickname,
|
|
p.country,
|
|
p.club,
|
|
p.club_member_number,
|
|
p.first_name,
|
|
p.last_name,
|
|
p.comment,
|
|
)
|
|
results.append(PlayerBulkResult(player=Player(**dict(row)), status="updated"))
|
|
else:
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
INSERT INTO player
|
|
(organization_id, display_name, handicap_index, gender,
|
|
mobile, email, birth_date, nickname, country, club, club_member_number,
|
|
first_name, last_name, paid, comment)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
RETURNING {_PLAYER_COLUMNS}
|
|
""",
|
|
organization_id,
|
|
p.display_name,
|
|
p.handicap_index,
|
|
p.gender,
|
|
p.mobile,
|
|
p.email.lower() if p.email else None,
|
|
p.birth_date,
|
|
p.nickname,
|
|
p.country,
|
|
p.club,
|
|
p.club_member_number,
|
|
p.first_name,
|
|
p.last_name,
|
|
p.paid,
|
|
p.comment,
|
|
)
|
|
results.append(PlayerBulkResult(player=Player(**dict(row)), status="created"))
|
|
return results
|
|
|
|
|
|
class PlayerUpdate(BaseModel):
|
|
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
|
(PATCH-semantikk via Pydantic sin exclude_unset, se update_player).
|
|
|
|
Endrer KUN player-raden (spillerpoolen) -- rører ALDRI en allerede
|
|
frosset team_roster.handicap_index_snapshot (ADR-007): en HCP-
|
|
korrigering her slår derfor ikke inn på et lag spilleren allerede er
|
|
rostret på i en turnering, kun ved FREMTIDIG rostring. Fjern og legg
|
|
til spilleren på nytt på laget for å oppdatere en allerede frosset
|
|
snapshot-verdi.
|
|
"""
|
|
|
|
display_name: str | None = Field(default=None, min_length=1, max_length=200)
|
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
|
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
|
mobile: str | None = Field(default=None, max_length=30)
|
|
email: EmailStr | None = None
|
|
birth_date: date | None = None
|
|
nickname: str | None = Field(default=None, max_length=100)
|
|
country: str | None = Field(default=None, max_length=100)
|
|
club: str | None = Field(default=None, max_length=200)
|
|
club_member_number: str | None = Field(default=None, max_length=50)
|
|
first_name: str | None = Field(default=None, max_length=100)
|
|
last_name: str | None = Field(default=None, max_length=100)
|
|
paid: bool | None = None
|
|
comment: str | None = Field(default=None, max_length=2000)
|
|
|
|
|
|
@router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player)
|
|
async def update_player(
|
|
player_id: str,
|
|
body: PlayerUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Player:
|
|
updates = body.model_dump(exclude_unset=True)
|
|
if not updates:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
|
if updates.get("email") is not None:
|
|
updates["email"] = updates["email"].lower()
|
|
|
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
|
values = list(updates.values())
|
|
values.append(player_id)
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
UPDATE player SET {", ".join(set_clauses)}
|
|
WHERE id = ${len(values)}
|
|
RETURNING {_PLAYER_COLUMNS}
|
|
""",
|
|
*values,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
|
|
return Player(**dict(row))
|