""" 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" | "overwritten" class PlayerBulkRow(BaseModel): """Én rad i en masseimport. `data` er selve spillerfeltene (uendret `PlayerCreate`). `match_player_id`/`duplicate_action` er NYE (2026-08-18) -- se docstringen på `create_players_bulk` for hvorfor.""" data: PlayerCreate # Satt av frontend NÅR organisatoren har blitt bedt om, og svart på, et # "dette ser ut som en duplikat"-varsel for denne raden (ingen e-post på # raden, men navnet traff en eksisterende spiller i poolen) -- IKKE satt # for rader uten et slikt varsel, som fortsatt går via den opprinnelige # e-post-matchingen under. match_player_id: str | None = None duplicate_action: str | None = Field(default=None, pattern="^(merge|overwrite|create_new)$") @router.post("/orgs/{organization_id}/players/bulk", response_model=list[PlayerBulkResult], status_code=201) async def create_players_bulk( body: list[PlayerBulkRow], 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 via COALESCE-stien -- 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. `match_player_id`/`duplicate_action` (2026-08-18) -- ekte bug funnet i produksjon: rader UTEN e-post har ingen matchingsnøkkel i det hele tatt, så reimport av samme CSV la dem inn på nytt hver gang i stedet for å oppdatere (ni spillere dublert i org 8db22cb9 ved reimport). Fikset ved å la FRONTEND oppdage mulige duplikater (ingen e-post + navnetreff mot en eksisterende spiller i poolen) og SPØRRE organisator eksplisitt om hva som skal skje -- IKKE en stille automatisk sammenslåing på navn alene (for risikabelt: to ulike ekte personer kan dele navn). Svaret sendes tilbake her som `match_player_id` (hvilken eksisterende spiller raden gjelder) + `duplicate_action`: - "merge": samme COALESCE-oppførsel som e-post-matching allerede har (fyll kun tomme felt på den eksisterende raden). - "overwrite": erstatt ALLE feltene på den eksisterende raden med verdiene fra denne importraden, også der den eksisterende raden allerede hadde en verdi -- eneste sted i denne funksjonen som gjør det, og KUN når organisator eksplisitt har bedt om det for akkurat denne raden. - "create_new" (eller `match_player_id` ikke satt i det hele tatt): uendret -- opprett en ny rad, som før. Rader MED e-post er upåvirket -- e-post-matching er entydig nok til at den fortsatt skjer automatisk, ingen spørring nødvendig der.""" results: list[PlayerBulkResult] = [] async with org_connection(organization_id) as conn, translate_db_errors(): for item in body: p = item.data existing = None if p.email: existing = await conn.fetchrow( "SELECT id FROM player WHERE lower(email) = lower($1)", p.email ) elif item.match_player_id and item.duplicate_action in ("merge", "overwrite"): existing = {"id": item.match_player_id} if existing is not None and item.duplicate_action == "overwrite": row = await conn.fetchrow( f""" UPDATE player SET handicap_index = $2, gender = $3, mobile = $4, birth_date = $5, nickname = $6, country = $7, club = $8, club_member_number = $9, first_name = $10, last_name = $11, 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="overwritten")) elif 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))