Spør organisator om merge/overskriv/dobbel ved e-postløs spillerimport
create_players_bulk matchet kun på e-post -- rader uten e-post ble alltid satt inn på nytt selv om en identisk spiller allerede fantes (ni ekte dubletter funnet i produksjon). Bulk-endepunktet tar nå et eksplisitt match_player_id/duplicate_action per rad; frontend oppdager mulige duplikater (navnetreff, ingen e-post) og spør organisator eksplisitt før lagring, i stedet for å gjette.
This commit is contained in:
parent
54795065ef
commit
accf5efd41
4 changed files with 429 additions and 34 deletions
68
CHANGELOG.md
68
CHANGELOG.md
|
|
@ -12558,3 +12558,71 @@ Neste steg:
|
|||
igjen etter verifisering.
|
||||
|
||||
**Rullet ut:** venter på bekreftelse.
|
||||
|
||||
114. **Duplikat-spørring ved spillerimport uten e-post — 2026-08-18.**
|
||||
Bruker meldte fra, med skjermbilde av org 8db22cb9 sin spillerpool:
|
||||
"Dobbel-import erstatter ikke eksisterende, men blir liggende to
|
||||
ganger." Undersøkt (les-only mot ekte `teecup_db`, ingen skriving):
|
||||
ekte bug -- ni spillere i den organisasjonen (Anders Sperre, Daniel
|
||||
Rypdal, Einar Vegheim, m.fl.) dublert, alle med IDENTISKE felt i
|
||||
begge kopiene, alle uten e-post. Rotårsak i `create_players_bulk`
|
||||
(`app/routers/players.py`): matching mot en eksisterende spiller
|
||||
skjedde UTELUKKENDE på e-post -- en rad uten e-post hadde derfor
|
||||
INGEN matchingsnøkkel og ble alltid satt inn på nytt, uansett om
|
||||
en identisk spiller allerede fantes.
|
||||
|
||||
Bruker presiserte eksplisitt hvordan dette skal løses: "Det bør
|
||||
spørres om den enkelte av de det gjelder skal merges, overskrives
|
||||
eller importeres som en tilsynelatende dobbeloppføring" -- IKKE en
|
||||
stille automatisk sammenslåing på navn alene (for risikabelt: to
|
||||
ulike ekte personer kan dele navn).
|
||||
|
||||
**Backend:** `/orgs/{id}/players/bulk` sitt request-format endret
|
||||
fra `list[PlayerCreate]` til `list[PlayerBulkRow]` (`data` + nye,
|
||||
valgfrie `match_player_id`/`duplicate_action`). E-post-matching
|
||||
UENDRET (fortsatt automatisk, entydig nok til at spørring ikke
|
||||
trengs der). Når frontend har spurt organisator og fått svar for en
|
||||
e-postløs rad, sendes svaret som `match_player_id`/`duplicate_
|
||||
action`: "merge" = samme COALESCE-oppførsel som e-post-matching
|
||||
alltid har hatt (fyll kun tomme felt), "overwrite" = NY oppførsel,
|
||||
eneste sti som erstatter allerede-satte felt, kun for denne ene
|
||||
raden, "create_new" = uendret opprinnelig oppførsel (bevisst
|
||||
dobbeloppføring, ikke en utilsiktet bug lenger). Ubesvarte/uten-
|
||||
e-post-rader uten noe svar oppfører seg akkurat som før (opprett ny)
|
||||
-- ingen regresjon for eksisterende kallere.
|
||||
|
||||
**Frontend (`player-import-panel.tsx`):** "Lagre" i redigerings-
|
||||
tabellen sjekker nå FØRST om noen rader mangler e-post OG
|
||||
navnetreffer en spiller som allerede finnes i org-poolen (egen
|
||||
`GET .../players`-sjekk). Finnes slike, stoppes lagringen og en ny
|
||||
`DuplicateStep` vises -- én radiogruppe (Slå sammen/Overskriv/
|
||||
Importer som ny) per funnet duplikat, forhåndsvalgt "Slå sammen"
|
||||
(tryggeste, ikke-destruktive standardvalg dersom organisator ikke
|
||||
endrer det). Bevisst HÅNDKODET av Claude, ikke V0-eksportert som
|
||||
resten av import-visningen (`player-import-view.tsx`) -- vurdert
|
||||
som en liten, funksjonell, avgrenset spørre-skjerm satt inn i en
|
||||
eksisterende lagre-flyt, ikke en ny selvstendig UI-flate (se
|
||||
[[feedback_frontend_via_v0]]), og bygget med de samme globale
|
||||
design-tokenene (border/bg-card/bg-primary) som resten av appen.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend:
|
||||
`tests/test_players_bulk.py` utvidet med tre nye tester (merge/
|
||||
overwrite/create_new), alle eksisterende oppdatert til ny
|
||||
`PlayerBulkRow`-form -- 120/120 grønt (opp fra 117), kjørt via
|
||||
`scripts/run_backend_tests.sh`. Frontend: midlertidig lokal
|
||||
`/dupsteppreviewtmp`-side (kun i `next dev`, ALDRI committet)
|
||||
rendret `DuplicateStep` med to falske duplikater -- skjermbilde +
|
||||
a11y-snapshot bekreftet ekte radiogruppe-semantikk (label/legend,
|
||||
ikke bare visuell styling), og at å klikke "Overskriv" faktisk
|
||||
endrer valgt tilstand for riktig rad uendret for den andre.
|
||||
Midlertidig side slettet igjen etter verifisering.
|
||||
|
||||
**Produksjonsopprydding (de ni allerede dublerte spillerne i org
|
||||
8db22cb9):** IKKE utført ennå -- krever eksplisitt bekreftelse
|
||||
(skriving mot ekte `teecup_db`), se egen plan lagt frem for bruker
|
||||
samme dag. Alle ni par er byte-identiske i alle felt; den ene
|
||||
kopien av hvert par er allerede meldt på turneringen (`tournament_
|
||||
participant`), den andre er helt ubrukt -- planen er å slette KUN
|
||||
den ubrukte kopien av hvert par.
|
||||
|
||||
**Rullet ut:** venter på bekreftelse.
|
||||
|
|
|
|||
|
|
@ -116,12 +116,27 @@ async def create_player(
|
|||
|
||||
class PlayerBulkResult(BaseModel):
|
||||
player: Player
|
||||
status: str # "created" | "updated"
|
||||
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[PlayerCreate],
|
||||
body: list[PlayerBulkRow],
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[PlayerBulkResult]:
|
||||
"""Massimport (CSV-basert, se FEATURE_BACKLOG.md). Samme "match på
|
||||
|
|
@ -130,21 +145,71 @@ async def create_players_bulk(
|
|||
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."""
|
||||
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 p in body:
|
||||
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
|
||||
)
|
||||
if existing is not None:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import { useState } from "react"
|
||||
import Papa from "papaparse"
|
||||
import { TriangleAlert } from "lucide-react"
|
||||
import {
|
||||
PlayerImportView,
|
||||
type DraftRow,
|
||||
|
|
@ -150,6 +151,20 @@ type ApiPlayer = {
|
|||
comment: string | null
|
||||
}
|
||||
|
||||
type DuplicateAction = "merge" | "overwrite" | "create_new"
|
||||
|
||||
// Mulig duplikat (2026-08-18, ekte produksjonsbug): en importrad UTEN
|
||||
// e-post har ingen pålitelig matchingsnøkkel i seg selv (se players.py
|
||||
// sin create_players_bulk-docstring for hele historikken), så et
|
||||
// navnetreff mot en spiller som allerede finnes i poolen blir aldri
|
||||
// slått sammen automatisk -- for risikabelt, to ulike ekte personer kan
|
||||
// dele navn. Organisator spørres eksplisitt i stedet, se DuplicateStep.
|
||||
type DuplicateCandidate = {
|
||||
row: DraftRow
|
||||
existing: ApiPlayer
|
||||
action: DuplicateAction
|
||||
}
|
||||
|
||||
export function PlayerImportPanel({
|
||||
organizationId,
|
||||
tournamentId,
|
||||
|
|
@ -163,13 +178,14 @@ export function PlayerImportPanel({
|
|||
onClose: () => void
|
||||
onImportComplete: (createdOrUpdated: { player: ApiPlayer; status: string }[]) => void
|
||||
}) {
|
||||
const [step, setStep] = useState<"upload" | "map" | "edit" | "saving" | "done">("upload")
|
||||
const [step, setStep] = useState<"upload" | "map" | "edit" | "duplicates" | "saving" | "done">("upload")
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
|
||||
const [csvRows, setCsvRows] = useState<string[][]>([])
|
||||
const [mapping, setMapping] = useState<Record<number, FieldKey | "">>({})
|
||||
const [rows, setRows] = useState<DraftRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saveWarnings, setSaveWarnings] = useState<string[]>([])
|
||||
const [duplicates, setDuplicates] = useState<DuplicateCandidate[]>([])
|
||||
|
||||
function handleFile(file: File) {
|
||||
setError(null)
|
||||
|
|
@ -235,17 +251,52 @@ export function PlayerImportPanel({
|
|||
return match?.id ?? null
|
||||
}
|
||||
|
||||
// Trykk på "Lagre" i redigeringstabellen -- sjekker FØRST om noen av
|
||||
// radene ser ut som mulige duplikater (ingen e-post + navnetreff mot en
|
||||
// spiller som allerede finnes i poolen). Finnes det slike, stopper vi
|
||||
// her og spør organisator eksplisitt (DuplicateStep) i stedet for å
|
||||
// stille velge -- se DuplicateCandidate-kommentaren over. Ellers går vi
|
||||
// rett til doSave().
|
||||
async function handleSave() {
|
||||
const validRows = rows.filter((r) => r.display_name.trim().length > 0)
|
||||
if (validRows.length === 0) {
|
||||
setError("Ingen rader med navn å lagre.")
|
||||
return
|
||||
}
|
||||
setStep("saving")
|
||||
setError(null)
|
||||
|
||||
const noEmailRows = validRows.filter((r) => !r.email.trim())
|
||||
if (noEmailRows.length > 0) {
|
||||
const existingRes = await fetch(`/orgs/${organizationId}/players`, { credentials: "include" })
|
||||
if (existingRes.ok) {
|
||||
const existingPlayers: ApiPlayer[] = await existingRes.json()
|
||||
const byName = new Map<string, ApiPlayer>()
|
||||
for (const p of existingPlayers) byName.set(p.display_name.trim().toLowerCase(), p)
|
||||
const candidates: DuplicateCandidate[] = noEmailRows
|
||||
.map((row) => {
|
||||
const existing = byName.get(row.display_name.trim().toLowerCase())
|
||||
return existing ? { row, existing, action: "merge" as DuplicateAction } : null
|
||||
})
|
||||
.filter((c): c is DuplicateCandidate => c !== null)
|
||||
if (candidates.length > 0) {
|
||||
setDuplicates(candidates)
|
||||
setStep("duplicates")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
await doSave(validRows, [])
|
||||
}
|
||||
|
||||
async function doSave(validRows: DraftRow[], resolvedDuplicates: DuplicateCandidate[]) {
|
||||
setStep("saving")
|
||||
setSaveWarnings([])
|
||||
try {
|
||||
const payload = validRows.map((r) => ({
|
||||
const resolutionByRowKey = new Map(resolvedDuplicates.map((d) => [d.row.key, d]))
|
||||
const payload = validRows.map((r) => {
|
||||
const resolution = resolutionByRowKey.get(r.key)
|
||||
return {
|
||||
data: {
|
||||
display_name: r.display_name.trim(),
|
||||
email: r.email.trim() || null,
|
||||
handicap_index: r.handicap_index.trim() ? Number(r.handicap_index) : null,
|
||||
|
|
@ -259,7 +310,11 @@ export function PlayerImportPanel({
|
|||
first_name: r.first_name.trim() || null,
|
||||
last_name: r.last_name.trim() || null,
|
||||
comment: r.comment.trim() || null,
|
||||
}))
|
||||
},
|
||||
match_player_id: resolution && resolution.action !== "create_new" ? resolution.existing.id : null,
|
||||
duplicate_action: resolution?.action ?? null,
|
||||
}
|
||||
})
|
||||
const bulkRes = await fetch(`/orgs/${organizationId}/players/bulk`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -313,6 +368,19 @@ export function PlayerImportPanel({
|
|||
}
|
||||
}
|
||||
|
||||
if (step === "duplicates") {
|
||||
return (
|
||||
<DuplicateStep
|
||||
duplicates={duplicates}
|
||||
onChangeAction={(rowKey, action) =>
|
||||
setDuplicates((prev) => prev.map((d) => (d.row.key === rowKey ? { ...d, action } : d)))
|
||||
}
|
||||
onCancel={() => setStep("edit")}
|
||||
onContinue={() => void doSave(rows.filter((r) => r.display_name.trim().length > 0), duplicates)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PlayerImportView
|
||||
mode={mode}
|
||||
|
|
@ -333,3 +401,105 @@ export function PlayerImportPanel({
|
|||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Duplikat-varsel (2026-08-18, se DuplicateCandidate-kommentaren over) --
|
||||
// funksjonell, håndkodet av Claude (ikke V0-eksportert som resten av
|
||||
// import-visningen): en liten, avgrenset spørre-skjerm satt inn FØR selve
|
||||
// lagringen, ikke en ny selvstendig UI-flate. Bruker de samme globale
|
||||
// design-tokenene (border/bg-card/bg-primary osv.) som resten av appen.
|
||||
function DuplicateStep({
|
||||
duplicates,
|
||||
onChangeAction,
|
||||
onCancel,
|
||||
onContinue,
|
||||
}: {
|
||||
duplicates: DuplicateCandidate[]
|
||||
onChangeAction: (rowKey: string, action: DuplicateAction) => void
|
||||
onCancel: () => void
|
||||
onContinue: () => void
|
||||
}) {
|
||||
const ACTIONS: { value: DuplicateAction; label: string; hint: string }[] = [
|
||||
{ value: "merge", label: "Slå sammen", hint: "Fyll kun inn tomme felt på den eksisterende spilleren" },
|
||||
{ value: "overwrite", label: "Overskriv", hint: "Erstatt ALLE felt på den eksisterende spilleren" },
|
||||
{ value: "create_new", label: "Importer som ny", hint: "Behold begge -- bevisst dobbeloppføring" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
|
||||
<div className="flex items-start gap-3 rounded-xl border border-border bg-card p-4 sm:p-5">
|
||||
<TriangleAlert aria-hidden="true" className="mt-0.5 size-6 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-bold text-foreground">
|
||||
{duplicates.length === 1 ? "1 mulig duplikat funnet" : `${duplicates.length} mulige duplikater funnet`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-muted-foreground text-pretty">
|
||||
Disse radene mangler e-post og har samme navn som en spiller som allerede finnes i
|
||||
spillerpoolen. Velg hva som skal skje med hver av dem før import fortsetter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-4">
|
||||
{duplicates.map((d) => (
|
||||
<li key={d.row.key} className="rounded-xl border border-border bg-card p-4 sm:p-5">
|
||||
<p className="text-base font-bold text-foreground">{d.row.display_name.trim()}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Finnes allerede i spillerpoolen
|
||||
{d.existing.club ? ` -- ${d.existing.club}` : ""}
|
||||
{d.existing.handicap_index != null ? `, HCP ${d.existing.handicap_index}` : ""}
|
||||
</p>
|
||||
<fieldset className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<legend className="sr-only">Hva skal skje med {d.row.display_name.trim()}?</legend>
|
||||
{ACTIONS.map((a) => {
|
||||
const id = `dup-${d.row.key}-${a.value}`
|
||||
const checked = d.action === a.value
|
||||
return (
|
||||
<label
|
||||
key={a.value}
|
||||
htmlFor={id}
|
||||
className={`flex min-h-11 flex-1 cursor-pointer items-start gap-2 rounded-lg border p-3 transition-colors ${
|
||||
checked
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border bg-background hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="radio"
|
||||
name={`dup-${d.row.key}`}
|
||||
value={a.value}
|
||||
checked={checked}
|
||||
onChange={() => onChangeAction(d.row.key, a.value)}
|
||||
className="mt-1 size-4 shrink-0 accent-primary"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-bold text-foreground">{a.label}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{a.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</fieldset>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-card px-5 font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
Tilbake til tabellen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
Fortsett importen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,33 @@ Massimport av spillere (`POST /orgs/{id}/players/bulk`, se FEATURE_
|
|||
BACKLOG.md "Massimport av spillere til organisasjon/turnering"). Samme
|
||||
dedup-mønster som registration.py sin selvregistrering (ADR-017
|
||||
Beslutning B), utvidet til alle PlayerCreate-felt.
|
||||
|
||||
`match_player_id`/`duplicate_action` (2026-08-18) -- ekte produksjonsbug:
|
||||
rader uten e-post har ingen matchingsnøkkel, så reimport av samme CSV
|
||||
dublerte dem i stedet for å oppdatere. Frontend oppdager nå slike mulige
|
||||
duplikater selv (navnetreff, ingen e-post) og spør organisator eksplisitt
|
||||
-- svaret sendes hit som disse to feltene per rad.
|
||||
"""
|
||||
|
||||
import app.db as app_db
|
||||
from app.routers.players import PlayerCreate, create_players_bulk
|
||||
from app.routers.players import PlayerBulkRow, PlayerCreate, create_players_bulk
|
||||
|
||||
from tests.conftest import create_org, create_player
|
||||
|
||||
|
||||
def _row(**kwargs) -> PlayerBulkRow:
|
||||
match_player_id = kwargs.pop("match_player_id", None)
|
||||
duplicate_action = kwargs.pop("duplicate_action", None)
|
||||
return PlayerBulkRow(
|
||||
data=PlayerCreate(**kwargs), match_player_id=match_player_id, duplicate_action=duplicate_action
|
||||
)
|
||||
|
||||
|
||||
async def test_bulk_creates_new_players_without_email(pool):
|
||||
org_id = await create_org()
|
||||
rows = [
|
||||
PlayerCreate(display_name="Ny Spiller A"),
|
||||
PlayerCreate(display_name="Ny Spiller B", handicap_index=12.5),
|
||||
_row(display_name="Ny Spiller A"),
|
||||
_row(display_name="Ny Spiller B", handicap_index=12.5),
|
||||
]
|
||||
results = await create_players_bulk(rows, organization_id=org_id)
|
||||
assert len(results) == 2
|
||||
|
|
@ -35,7 +49,7 @@ async def test_bulk_matches_existing_player_by_email_and_fills_only_empty_fields
|
|||
)
|
||||
|
||||
rows = [
|
||||
PlayerCreate(
|
||||
_row(
|
||||
display_name="Annet Navn Fra CSV", # skal IKKE overskrive display_name
|
||||
email="match@example.com",
|
||||
country="Sverige", # skal IKKE overskrive et allerede satt felt
|
||||
|
|
@ -55,10 +69,14 @@ async def test_bulk_matches_existing_player_by_email_and_fills_only_empty_fields
|
|||
|
||||
|
||||
async def test_bulk_without_email_never_matches_existing_and_always_creates(pool):
|
||||
"""Uendret standardoppførsel når INGEN duplikat-varsel er besvart
|
||||
(match_player_id ikke satt) -- ren e-postløs rad oppretter fortsatt
|
||||
alltid en ny rad, akkurat som før denne funksjonen fikk
|
||||
duplikat-håndtering."""
|
||||
org_id = await create_org()
|
||||
await create_player(org_id, display_name="Har Ikke E-post")
|
||||
|
||||
rows = [PlayerCreate(display_name="Har Ikke E-post")]
|
||||
rows = [_row(display_name="Har Ikke E-post")]
|
||||
results = await create_players_bulk(rows, organization_id=org_id)
|
||||
assert results[0].status == "created"
|
||||
|
||||
|
|
@ -77,7 +95,7 @@ async def test_bulk_paid_flag_never_overwritten_on_existing_match(pool):
|
|||
"UPDATE player SET email = 'paid@example.com', paid = true WHERE id = $1", existing_id
|
||||
)
|
||||
|
||||
rows = [PlayerCreate(display_name="X", email="paid@example.com", paid=False)]
|
||||
rows = [_row(display_name="X", email="paid@example.com", paid=False)]
|
||||
results = await create_players_bulk(rows, organization_id=org_id)
|
||||
assert results[0].player.paid is True # uendret, COALESCE mot ikke-nullbar kolonne er en no-op
|
||||
|
||||
|
|
@ -87,7 +105,81 @@ async def test_bulk_is_transactional_across_rows(pool):
|
|||
partial-suksess-forventning her, men bekreft at flere rader i samme
|
||||
kall faktisk lander i samme, konsistente organisasjon."""
|
||||
org_id = await create_org()
|
||||
rows = [PlayerCreate(display_name=f"Spiller {i}") for i in range(5)]
|
||||
rows = [_row(display_name=f"Spiller {i}") for i in range(5)]
|
||||
results = await create_players_bulk(rows, organization_id=org_id)
|
||||
assert len(results) == 5
|
||||
assert len({r.player.id for r in results}) == 5
|
||||
|
||||
|
||||
async def test_bulk_duplicate_merge_fills_only_empty_fields_like_email_match(pool):
|
||||
"""Organisator svarte "Slå sammen" på et duplikat-varsel -- COALESCE-
|
||||
oppførsel, identisk til e-post-matchingen, men rettet mot en
|
||||
eksplisitt oppgitt match_player_id i stedet."""
|
||||
org_id = await create_org()
|
||||
existing_id = await create_player(org_id, display_name="Anders Sperre")
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute("UPDATE player SET club = 'Gamle Klubb' WHERE id = $1", existing_id)
|
||||
|
||||
rows = [
|
||||
_row(
|
||||
display_name="Anders Sperre",
|
||||
club="Ny Klubb", # skal IKKE overskrive (allerede satt)
|
||||
handicap_index=14.0, # skal FYLLES INN (var tomt)
|
||||
match_player_id=existing_id,
|
||||
duplicate_action="merge",
|
||||
)
|
||||
]
|
||||
results = await create_players_bulk(rows, organization_id=org_id)
|
||||
assert results[0].status == "updated"
|
||||
assert results[0].player.id == existing_id
|
||||
assert results[0].player.club == "Gamle Klubb" # uendret
|
||||
assert results[0].player.handicap_index == 14.0 # fylt inn
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
count = await conn.fetchval("SELECT count(*) FROM player WHERE display_name = 'Anders Sperre'")
|
||||
assert count == 1 # ingen ny duplikat opprettet
|
||||
|
||||
|
||||
async def test_bulk_duplicate_overwrite_replaces_already_set_fields(pool):
|
||||
"""Organisator svarte "Overskriv" -- eneste sti i endepunktet som
|
||||
erstatter et allerede satt felt, og kun for akkurat denne raden."""
|
||||
org_id = await create_org()
|
||||
existing_id = await create_player(org_id, display_name="Anders Sperre")
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute("UPDATE player SET club = 'Gamle Klubb' WHERE id = $1", existing_id)
|
||||
|
||||
rows = [
|
||||
_row(
|
||||
display_name="Anders Sperre",
|
||||
club="Ny Klubb",
|
||||
match_player_id=existing_id,
|
||||
duplicate_action="overwrite",
|
||||
)
|
||||
]
|
||||
results = await create_players_bulk(rows, organization_id=org_id)
|
||||
assert results[0].status == "overwritten"
|
||||
assert results[0].player.id == existing_id
|
||||
assert results[0].player.club == "Ny Klubb" # overskrevet, ikke bevart
|
||||
|
||||
|
||||
async def test_bulk_duplicate_create_new_still_creates_a_second_row(pool):
|
||||
"""Organisator svarte "Importer som ny" (bevisst duplikat) --
|
||||
match_player_id kan følge med, men skal IGNORERES når
|
||||
duplicate_action er create_new."""
|
||||
org_id = await create_org()
|
||||
existing_id = await create_player(org_id, display_name="Anders Sperre")
|
||||
|
||||
rows = [
|
||||
_row(
|
||||
display_name="Anders Sperre",
|
||||
match_player_id=existing_id,
|
||||
duplicate_action="create_new",
|
||||
)
|
||||
]
|
||||
results = await create_players_bulk(rows, organization_id=org_id)
|
||||
assert results[0].status == "created"
|
||||
assert results[0].player.id != existing_id
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
count = await conn.fetchval("SELECT count(*) FROM player WHERE display_name = 'Anders Sperre'")
|
||||
assert count == 2
|
||||
|
|
|
|||
Loading…
Reference in a new issue