diff --git a/_audit/data_integrity_20260505_0836/A_HNS_RECONCILE.md b/_audit/data_integrity_20260505_0836/A_HNS_RECONCILE.md new file mode 100644 index 0000000..9b3192f --- /dev/null +++ b/_audit/data_integrity_20260505_0836/A_HNS_RECONCILE.md @@ -0,0 +1,38 @@ +# Subagent A — HNS Player ID Reconciliation + +Timestamp: 2026-05-05 + +## Counters + +```json +{ + "dup_groups": 3, + "merged": 3, + "soft_deleted": 3, + "errors": 0, + "fk_rows_reparented_total": 0, + "before_clanovi": 3243, + "after_clanovi": 3240, + "clanovi_purged_total": 3 +} +``` + +## Per-group resolutions + +### hns_key=209352 +- auth_id: **301** +- dup_ids: [2454] +- reason: auth=301 (igraci_url=yes, non_null=30, created=1777448115) +- fk_moves: `{"clan_nagrada": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_sezona": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_utakmica": {"now_on_auth": 0, "skipped_conflicts": 0}, "clanarine": {"now_on_auth": 0, "skipped_conflicts": 0}, "lijecnicki_pregledi": {"now_on_auth": 0, "skipped_conflicts": 0}, "sportas_specifika": {"now_on_auth": 0, "skipped_conflicts": 0}, "user_klub_links": {"now_on_auth": 0, "skipped_conflicts": 0}, "expense_reports": {"now_on_auth": 0, "skipped_conflicts": 0}, "form_submissions": {"now_on_auth": 0, "skipped_conflicts": 0}, "utakmice_log": {"now_on_auth": 18, "skipped_conflicts": 0}}` + +### hns_key=395328 +- auth_id: **233** +- dup_ids: [2596] +- reason: auth=233 (igraci_url=yes, non_null=30, created=1777448092) +- fk_moves: `{"clan_nagrada": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_sezona": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_utakmica": {"now_on_auth": 0, "skipped_conflicts": 0}, "clanarine": {"now_on_auth": 0, "skipped_conflicts": 0}, "lijecnicki_pregledi": {"now_on_auth": 0, "skipped_conflicts": 0}, "sportas_specifika": {"now_on_auth": 0, "skipped_conflicts": 0}, "user_klub_links": {"now_on_auth": 0, "skipped_conflicts": 0}, "expense_reports": {"now_on_auth": 0, "skipped_conflicts": 0}, "form_submissions": {"now_on_auth": 0, "skipped_conflicts": 0}, "utakmice_log": {"now_on_auth": 14, "skipped_conflicts": 0}}` + +### hns_key=436387 +- auth_id: **481** +- dup_ids: [2600] +- reason: auth=481 (igraci_url=yes, non_null=29, created=1777451018) +- fk_moves: `{"clan_nagrada": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_sezona": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_utakmica": {"now_on_auth": 0, "skipped_conflicts": 0}, "clanarine": {"now_on_auth": 0, "skipped_conflicts": 0}, "lijecnicki_pregledi": {"now_on_auth": 0, "skipped_conflicts": 0}, "sportas_specifika": {"now_on_auth": 0, "skipped_conflicts": 0}, "user_klub_links": {"now_on_auth": 0, "skipped_conflicts": 0}, "expense_reports": {"now_on_auth": 0, "skipped_conflicts": 0}, "form_submissions": {"now_on_auth": 0, "skipped_conflicts": 0}, "utakmice_log": {"now_on_auth": 1, "skipped_conflicts": 0}}` diff --git a/_audit/data_integrity_20260505_0836/A_counters.json b/_audit/data_integrity_20260505_0836/A_counters.json new file mode 100644 index 0000000..dad3fad --- /dev/null +++ b/_audit/data_integrity_20260505_0836/A_counters.json @@ -0,0 +1,13 @@ +{ + "dup_groups": 3, + "merged": 3, + "soft_deleted": 3, + "errors": 0, + "fk_rows_reparented_total": 0, + "fk_rows_already_on_auth": 33, + "fk_conflicts_skipped": 0, + "before_clanovi": 3243, + "after_clanovi": 3240, + "clanovi_purged_total": 3, + "remaining_dup_groups": 0 +} \ No newline at end of file diff --git a/_audit/data_integrity_20260505_0836/A_sql_transcript.sql b/_audit/data_integrity_20260505_0836/A_sql_transcript.sql new file mode 100644 index 0000000..868ce67 --- /dev/null +++ b/_audit/data_integrity_20260505_0836/A_sql_transcript.sql @@ -0,0 +1,111 @@ +SELECT count(*) FROM pgz_sport.clanovi_backup_20260505_0836; +SELECT count(*) FROM pgz_sport.clanovi; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM ( + WITH keyed AS ( + SELECT id, hns_igrac_id, source_url, scrape_url, created_at, + COALESCE( + NULLIF(hns_igrac_id,''), + substring(source_url FROM 'igraci/(\d+)'), + substring(scrape_url FROM 'igraci/(\d+)') + ) AS hns_key + FROM pgz_sport.clanovi + WHERE source = 'hns_semafor' + ) + SELECT hns_key, + array_agg(id ORDER BY id) AS ids, + array_agg(source_url ORDER BY id) AS urls, + array_agg(EXTRACT(EPOCH FROM created_at)::bigint ORDER BY id) AS createds + FROM keyed + WHERE hns_key IS NOT NULL + GROUP BY hns_key + HAVING count(*) > 1 + ORDER BY hns_key) t; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT id, source_url, (SELECT count(*) FROM jsonb_each(to_jsonb(c)) WHERE value::text NOT IN ('null','""')) AS nn, EXTRACT(EPOCH FROM created_at)::bigint AS cre FROM pgz_sport.clanovi c WHERE id=301) t; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT id, source_url, (SELECT count(*) FROM jsonb_each(to_jsonb(c)) WHERE value::text NOT IN ('null','""')) AS nn, EXTRACT(EPOCH FROM created_at)::bigint AS cre FROM pgz_sport.clanovi c WHERE id=2454) t; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT row_to_json(c) FROM pgz_sport.clanovi c WHERE id=2454) t; +UPDATE pgz_sport.clanovi auth SET "klub_id" = CASE WHEN auth."klub_id" IS NOT NULL THEN auth."klub_id" WHEN c2454."klub_id" IS NOT NULL THEN c2454."klub_id" ELSE auth."klub_id" END, "ime" = CASE WHEN auth."ime" IS NOT NULL THEN auth."ime" WHEN c2454."ime" IS NOT NULL THEN c2454."ime" ELSE auth."ime" END, "prezime" = CASE WHEN auth."prezime" IS NOT NULL THEN auth."prezime" WHEN c2454."prezime" IS NOT NULL THEN c2454."prezime" ELSE auth."prezime" END, "oib" = CASE WHEN auth."oib" IS NOT NULL THEN auth."oib" WHEN c2454."oib" IS NOT NULL THEN c2454."oib" ELSE auth."oib" END, "datum_rodenja" = CASE WHEN auth."datum_rodenja" IS NOT NULL THEN auth."datum_rodenja" WHEN c2454."datum_rodenja" IS NOT NULL THEN c2454."datum_rodenja" ELSE auth."datum_rodenja" END, "spol" = CASE WHEN auth."spol" IS NOT NULL THEN auth."spol" WHEN c2454."spol" IS NOT NULL THEN c2454."spol" ELSE auth."spol" END, "adresa" = CASE WHEN auth."adresa" IS NOT NULL THEN auth."adresa" WHEN c2454."adresa" IS NOT NULL THEN c2454."adresa" ELSE auth."adresa" END, "grad" = CASE WHEN auth."grad" IS NOT NULL THEN auth."grad" WHEN c2454."grad" IS NOT NULL THEN c2454."grad" ELSE auth."grad" END, "postanski_broj" = CASE WHEN auth."postanski_broj" IS NOT NULL THEN auth."postanski_broj" WHEN c2454."postanski_broj" IS NOT NULL THEN c2454."postanski_broj" ELSE auth."postanski_broj" END, "email" = CASE WHEN auth."email" IS NOT NULL THEN auth."email" WHEN c2454."email" IS NOT NULL THEN c2454."email" ELSE auth."email" END, "telefon" = CASE WHEN auth."telefon" IS NOT NULL THEN auth."telefon" WHEN c2454."telefon" IS NOT NULL THEN c2454."telefon" ELSE auth."telefon" END, "kategorija" = CASE WHEN auth."kategorija" IS NOT NULL THEN auth."kategorija" WHEN c2454."kategorija" IS NOT NULL THEN c2454."kategorija" ELSE auth."kategorija" END, "podkategorija" = CASE WHEN auth."podkategorija" IS NOT NULL THEN auth."podkategorija" WHEN c2454."podkategorija" IS NOT NULL THEN c2454."podkategorija" ELSE auth."podkategorija" END, "pozicija" = CASE WHEN auth."pozicija" IS NOT NULL THEN auth."pozicija" WHEN c2454."pozicija" IS NOT NULL THEN c2454."pozicija" ELSE auth."pozicija" END, "licenca_broj" = CASE WHEN auth."licenca_broj" IS NOT NULL THEN auth."licenca_broj" WHEN c2454."licenca_broj" IS NOT NULL THEN c2454."licenca_broj" ELSE auth."licenca_broj" END, "licenca_vrijedi_do" = CASE WHEN auth."licenca_vrijedi_do" IS NOT NULL THEN auth."licenca_vrijedi_do" WHEN c2454."licenca_vrijedi_do" IS NOT NULL THEN c2454."licenca_vrijedi_do" ELSE auth."licenca_vrijedi_do" END, "reprezentativac" = CASE WHEN auth."reprezentativac" IS NOT NULL THEN auth."reprezentativac" WHEN c2454."reprezentativac" IS NOT NULL THEN c2454."reprezentativac" ELSE auth."reprezentativac" END, "kategoriziran" = CASE WHEN auth."kategoriziran" IS NOT NULL THEN auth."kategoriziran" WHEN c2454."kategoriziran" IS NOT NULL THEN c2454."kategoriziran" ELSE auth."kategoriziran" END, "kategorija_hoo" = CASE WHEN auth."kategorija_hoo" IS NOT NULL THEN auth."kategorija_hoo" WHEN c2454."kategorija_hoo" IS NOT NULL THEN c2454."kategorija_hoo" ELSE auth."kategorija_hoo" END, "stipendiran" = CASE WHEN auth."stipendiran" IS NOT NULL THEN auth."stipendiran" WHEN c2454."stipendiran" IS NOT NULL THEN c2454."stipendiran" ELSE auth."stipendiran" END, "stipendija_iznos" = CASE WHEN auth."stipendija_iznos" IS NOT NULL THEN auth."stipendija_iznos" WHEN c2454."stipendija_iznos" IS NOT NULL THEN c2454."stipendija_iznos" ELSE auth."stipendija_iznos" END, "radno_pravni_status" = CASE WHEN auth."radno_pravni_status" IS NOT NULL THEN auth."radno_pravni_status" WHEN c2454."radno_pravni_status" IS NOT NULL THEN c2454."radno_pravni_status" ELSE auth."radno_pravni_status" END, "aktivan" = CASE WHEN auth."aktivan" IS NOT NULL THEN auth."aktivan" WHEN c2454."aktivan" IS NOT NULL THEN c2454."aktivan" ELSE auth."aktivan" END, "datum_pristupa" = CASE WHEN auth."datum_pristupa" IS NOT NULL THEN auth."datum_pristupa" WHEN c2454."datum_pristupa" IS NOT NULL THEN c2454."datum_pristupa" ELSE auth."datum_pristupa" END, "datum_napustanja" = CASE WHEN auth."datum_napustanja" IS NOT NULL THEN auth."datum_napustanja" WHEN c2454."datum_napustanja" IS NOT NULL THEN c2454."datum_napustanja" ELSE auth."datum_napustanja" END, "napomena" = CASE WHEN auth."napomena" IS NOT NULL THEN auth."napomena" WHEN c2454."napomena" IS NOT NULL THEN c2454."napomena" ELSE auth."napomena" END, "slug" = CASE WHEN auth."slug" IS NOT NULL THEN auth."slug" WHEN c2454."slug" IS NOT NULL THEN c2454."slug" ELSE auth."slug" END, "slika_url" = CASE WHEN auth."slika_url" IS NOT NULL THEN auth."slika_url" WHEN c2454."slika_url" IS NOT NULL THEN c2454."slika_url" ELSE auth."slika_url" END, "source" = CASE WHEN auth."source" IS NOT NULL THEN auth."source" WHEN c2454."source" IS NOT NULL THEN c2454."source" ELSE auth."source" END, "source_id" = CASE WHEN auth."source_id" IS NOT NULL THEN auth."source_id" WHEN c2454."source_id" IS NOT NULL THEN c2454."source_id" ELSE auth."source_id" END, "source_url" = CASE WHEN auth."source_url" IS NOT NULL THEN auth."source_url" WHEN c2454."source_url" IS NOT NULL THEN c2454."source_url" ELSE auth."source_url" END, "source_synced_at" = CASE WHEN auth."source_synced_at" IS NOT NULL THEN auth."source_synced_at" WHEN c2454."source_synced_at" IS NOT NULL THEN c2454."source_synced_at" ELSE auth."source_synced_at" END, "dominantna_noga" = CASE WHEN auth."dominantna_noga" IS NOT NULL THEN auth."dominantna_noga" WHEN c2454."dominantna_noga" IS NOT NULL THEN c2454."dominantna_noga" ELSE auth."dominantna_noga" END, "visina_cm" = CASE WHEN auth."visina_cm" IS NOT NULL THEN auth."visina_cm" WHEN c2454."visina_cm" IS NOT NULL THEN c2454."visina_cm" ELSE auth."visina_cm" END, "tezina_kg" = CASE WHEN auth."tezina_kg" IS NOT NULL THEN auth."tezina_kg" WHEN c2454."tezina_kg" IS NOT NULL THEN c2454."tezina_kg" ELSE auth."tezina_kg" END, "broj_dresa" = CASE WHEN auth."broj_dresa" IS NOT NULL THEN auth."broj_dresa" WHEN c2454."broj_dresa" IS NOT NULL THEN c2454."broj_dresa" ELSE auth."broj_dresa" END, "reprezentacija_kategorija" = CASE WHEN auth."reprezentacija_kategorija" IS NOT NULL THEN auth."reprezentacija_kategorija" WHEN c2454."reprezentacija_kategorija" IS NOT NULL THEN c2454."reprezentacija_kategorija" ELSE auth."reprezentacija_kategorija" END, "biografija" = CASE WHEN auth."biografija" IS NOT NULL THEN auth."biografija" WHEN c2454."biografija" IS NOT NULL THEN c2454."biografija" ELSE auth."biografija" END, "mjesto_rodenja" = CASE WHEN auth."mjesto_rodenja" IS NOT NULL THEN auth."mjesto_rodenja" WHEN c2454."mjesto_rodenja" IS NOT NULL THEN c2454."mjesto_rodenja" ELSE auth."mjesto_rodenja" END, "kategorije" = CASE WHEN auth."kategorije" IS NOT NULL THEN auth."kategorije" WHEN c2454."kategorije" IS NOT NULL THEN c2454."kategorije" ELSE auth."kategorije" END, "promocija_kategorije" = CASE WHEN auth."promocija_kategorije" IS NOT NULL THEN auth."promocija_kategorije" WHEN c2454."promocija_kategorije" IS NOT NULL THEN c2454."promocija_kategorije" ELSE auth."promocija_kategorije" END, "sport" = CASE WHEN auth."sport" IS NOT NULL THEN auth."sport" WHEN c2454."sport" IS NOT NULL THEN c2454."sport" ELSE auth."sport" END, "auto_kategorija_calc_at" = CASE WHEN auth."auto_kategorija_calc_at" IS NOT NULL THEN auth."auto_kategorija_calc_at" WHEN c2454."auto_kategorija_calc_at" IS NOT NULL THEN c2454."auto_kategorija_calc_at" ELSE auth."auto_kategorija_calc_at" END, "hoo_kategorija" = CASE WHEN auth."hoo_kategorija" IS NOT NULL THEN auth."hoo_kategorija" WHEN c2454."hoo_kategorija" IS NOT NULL THEN c2454."hoo_kategorija" ELSE auth."hoo_kategorija" END, "hoo_kategorija_od" = CASE WHEN auth."hoo_kategorija_od" IS NOT NULL THEN auth."hoo_kategorija_od" WHEN c2454."hoo_kategorija_od" IS NOT NULL THEN c2454."hoo_kategorija_od" ELSE auth."hoo_kategorija_od" END, "hoo_kategorija_do" = CASE WHEN auth."hoo_kategorija_do" IS NOT NULL THEN auth."hoo_kategorija_do" WHEN c2454."hoo_kategorija_do" IS NOT NULL THEN c2454."hoo_kategorija_do" ELSE auth."hoo_kategorija_do" END, "hoo_kat_vrijedi_od" = CASE WHEN auth."hoo_kat_vrijedi_od" IS NOT NULL THEN auth."hoo_kat_vrijedi_od" WHEN c2454."hoo_kat_vrijedi_od" IS NOT NULL THEN c2454."hoo_kat_vrijedi_od" ELSE auth."hoo_kat_vrijedi_od" END, "hoo_kat_vrijedi_do" = CASE WHEN auth."hoo_kat_vrijedi_do" IS NOT NULL THEN auth."hoo_kat_vrijedi_do" WHEN c2454."hoo_kat_vrijedi_do" IS NOT NULL THEN c2454."hoo_kat_vrijedi_do" ELSE auth."hoo_kat_vrijedi_do" END, "hoo_vrijedi_od" = CASE WHEN auth."hoo_vrijedi_od" IS NOT NULL THEN auth."hoo_vrijedi_od" WHEN c2454."hoo_vrijedi_od" IS NOT NULL THEN c2454."hoo_vrijedi_od" ELSE auth."hoo_vrijedi_od" END, "hoo_vrijedi_do" = CASE WHEN auth."hoo_vrijedi_do" IS NOT NULL THEN auth."hoo_vrijedi_do" WHEN c2454."hoo_vrijedi_do" IS NOT NULL THEN c2454."hoo_vrijedi_do" ELSE auth."hoo_vrijedi_do" END, "klub_naziv_godisnjak" = CASE WHEN auth."klub_naziv_godisnjak" IS NOT NULL THEN auth."klub_naziv_godisnjak" WHEN c2454."klub_naziv_godisnjak" IS NOT NULL THEN c2454."klub_naziv_godisnjak" ELSE auth."klub_naziv_godisnjak" END, "datum_rodjenja" = CASE WHEN auth."datum_rodjenja" IS NOT NULL THEN auth."datum_rodjenja" WHEN c2454."datum_rodjenja" IS NOT NULL THEN c2454."datum_rodjenja" ELSE auth."datum_rodjenja" END, "mjesto_rodjenja" = CASE WHEN auth."mjesto_rodjenja" IS NOT NULL THEN auth."mjesto_rodjenja" WHEN c2454."mjesto_rodjenja" IS NOT NULL THEN c2454."mjesto_rodjenja" ELSE auth."mjesto_rodjenja" END, "broj_dres" = CASE WHEN auth."broj_dres" IS NOT NULL THEN auth."broj_dres" WHEN c2454."broj_dres" IS NOT NULL THEN c2454."broj_dres" ELSE auth."broj_dres" END, "vanjski_id" = CASE WHEN auth."vanjski_id" IS NOT NULL THEN auth."vanjski_id" WHEN c2454."vanjski_id" IS NOT NULL THEN c2454."vanjski_id" ELSE auth."vanjski_id" END, "aktivni_status" = CASE WHEN auth."aktivni_status" IS NOT NULL THEN auth."aktivni_status" WHEN c2454."aktivni_status" IS NOT NULL THEN c2454."aktivni_status" ELSE auth."aktivni_status" END, "scrape_source" = CASE WHEN auth."scrape_source" IS NOT NULL THEN auth."scrape_source" WHEN c2454."scrape_source" IS NOT NULL THEN c2454."scrape_source" ELSE auth."scrape_source" END, "scrape_url" = CASE WHEN auth."scrape_url" IS NOT NULL THEN auth."scrape_url" WHEN c2454."scrape_url" IS NOT NULL THEN c2454."scrape_url" ELSE auth."scrape_url" END, "last_scraped_at" = CASE WHEN auth."last_scraped_at" IS NOT NULL THEN auth."last_scraped_at" WHEN c2454."last_scraped_at" IS NOT NULL THEN c2454."last_scraped_at" ELSE auth."last_scraped_at" END, "godina_rodenja" = CASE WHEN auth."godina_rodenja" IS NOT NULL THEN auth."godina_rodenja" WHEN c2454."godina_rodenja" IS NOT NULL THEN c2454."godina_rodenja" ELSE auth."godina_rodenja" END, "uloga" = CASE WHEN auth."uloga" IS NOT NULL THEN auth."uloga" WHEN c2454."uloga" IS NOT NULL THEN c2454."uloga" ELSE auth."uloga" END, "hns_igrac_id" = CASE WHEN auth."hns_igrac_id" IS NOT NULL THEN auth."hns_igrac_id" WHEN c2454."hns_igrac_id" IS NOT NULL THEN c2454."hns_igrac_id" ELSE auth."hns_igrac_id" END, "verified" = CASE WHEN auth."verified" IS NOT NULL THEN auth."verified" WHEN c2454."verified" IS NOT NULL THEN c2454."verified" ELSE auth."verified" END, "godisnjak_godine" = CASE WHEN auth."godisnjak_godine" IS NOT NULL THEN auth."godisnjak_godine" WHEN c2454."godisnjak_godine" IS NOT NULL THEN c2454."godisnjak_godine" ELSE auth."godisnjak_godine" END, "godisnjak_prvi" = CASE WHEN auth."godisnjak_prvi" IS NOT NULL THEN auth."godisnjak_prvi" WHEN c2454."godisnjak_prvi" IS NOT NULL THEN c2454."godisnjak_prvi" ELSE auth."godisnjak_prvi" END, "godisnjak_zadnji" = CASE WHEN auth."godisnjak_zadnji" IS NOT NULL THEN auth."godisnjak_zadnji" WHEN c2454."godisnjak_zadnji" IS NOT NULL THEN c2454."godisnjak_zadnji" ELSE auth."godisnjak_zadnji" END, "dob_age" = CASE WHEN auth."dob_age" IS NOT NULL THEN auth."dob_age" WHEN c2454."dob_age" IS NOT NULL THEN c2454."dob_age" ELSE auth."dob_age" END, "savez_kod" = CASE WHEN auth."savez_kod" IS NOT NULL THEN auth."savez_kod" WHEN c2454."savez_kod" IS NOT NULL THEN c2454."savez_kod" ELSE auth."savez_kod" END, "grand_metadata" = CASE WHEN auth."grand_metadata" IS NOT NULL THEN auth."grand_metadata" WHEN c2454."grand_metadata" IS NOT NULL THEN c2454."grand_metadata" ELSE auth."grand_metadata" END, "external_id" = CASE WHEN auth."external_id" IS NOT NULL THEN auth."external_id" WHEN c2454."external_id" IS NOT NULL THEN c2454."external_id" ELSE auth."external_id" END, "savez_izvor" = CASE WHEN auth."savez_izvor" IS NOT NULL THEN auth."savez_izvor" WHEN c2454."savez_izvor" IS NOT NULL THEN c2454."savez_izvor" ELSE auth."savez_izvor" END, "profile_url" = CASE WHEN auth."profile_url" IS NOT NULL THEN auth."profile_url" WHEN c2454."profile_url" IS NOT NULL THEN c2454."profile_url" ELSE auth."profile_url" END, "uloga_detalj" = CASE WHEN auth."uloga_detalj" IS NOT NULL THEN auth."uloga_detalj" WHEN c2454."uloga_detalj" IS NOT NULL THEN c2454."uloga_detalj" ELSE auth."uloga_detalj" END, "metadata" = CASE WHEN auth."metadata" IS NOT NULL THEN auth."metadata" WHEN c2454."metadata" IS NOT NULL THEN c2454."metadata" ELSE auth."metadata" END, updated_at = now() FROM pgz_sport.clanovi c2454 WHERE auth.id = 301 AND c2454.id = 2454; +UPDATE pgz_sport.clan_nagrada SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.clan_nagrada WHERE clan_id=301; +UPDATE pgz_sport.clan_sezona SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.clan_sezona WHERE clan_id=301; +UPDATE pgz_sport.clan_utakmica SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.clan_utakmica WHERE clan_id=301; +UPDATE pgz_sport.clanarine SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.clanarine WHERE clan_id=301; +UPDATE pgz_sport.lijecnicki_pregledi SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=301; +UPDATE pgz_sport.sportas_specifika SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.sportas_specifika WHERE clan_id=301; +UPDATE pgz_sport.user_klub_links SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.user_klub_links WHERE clan_id=301; +UPDATE pgz_sport.expense_reports SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.expense_reports WHERE clan_id=301; +UPDATE pgz_sport.form_submissions SET clan_id=301 WHERE clan_id=2454; +SELECT count(*) FROM pgz_sport.form_submissions WHERE clan_id=301; +SELECT count(*) FROM pgz_sport.utakmice_log d WHERE d.clan_id=2454 AND EXISTS (SELECT 1 FROM pgz_sport.utakmice_log a WHERE a.clan_id=301 AND a.source=d.source AND a.source_match_id IS NOT DISTINCT FROM d.source_match_id); +UPDATE pgz_sport.utakmice_log d SET clan_id=301 WHERE d.clan_id=2454 AND NOT EXISTS (SELECT 1 FROM pgz_sport.utakmice_log a WHERE a.clan_id=301 AND a.source=d.source AND a.source_match_id IS NOT DISTINCT FROM d.source_match_id); +SELECT count(*) FROM pgz_sport.utakmice_log WHERE clan_id=301; +INSERT INTO pgz_sport.clanovi_purged SELECT *, now(), 'hns_dup_merge', 301 FROM pgz_sport.clanovi WHERE id=2454; +INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload, user_email) VALUES ('CLANOVI_PURGE', 'clanovi', 2454, 'merged into 301', '{"auth_id": 301, "dup_id": 2454, "hns_key": "209352", "fk_moves": {"clan_nagrada": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_sezona": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_utakmica": {"now_on_auth": 0, "skipped_conflicts": 0}, "clanarine": {"now_on_auth": 0, "skipped_conflicts": 0}, "lijecnicki_pregledi": {"now_on_auth": 0, "skipped_conflicts": 0}, "sportas_specifika": {"now_on_auth": 0, "skipped_conflicts": 0}, "user_klub_links": {"now_on_auth": 0, "skipped_conflicts": 0}, "expense_reports": {"now_on_auth": 0, "skipped_conflicts": 0}, "form_submissions": {"now_on_auth": 0, "skipped_conflicts": 0}, "utakmice_log": {"now_on_auth": 18, "skipped_conflicts": 0}}, "dup_row_json": {"id": 2454, "klub_id": 2211, "ime": "Allan", "prezime": "PatrikGrbi\u0107", "oib": null, "datum_rodenja": null, "spol": "M", "adresa": null, "grad": null, "postanski_broj": null, "email": null, "telefon": null, "kategorija": null, "podkategorija": null, "pozicija": "", "licenca_broj": null, "licenca_vrijedi_do": null, "reprezentativac": false, "kategoriziran": false, "kategorija_hoo": null, "stipendiran": false, "stipendija_iznos": null, "radno_pravni_status": null, "aktivan": true, "datum_pristupa": null, "datum_napustanja": null, "napomena": null, "created_at": "2026-04-30T21:39:59.309879+02:00", "updated_at": "2026-04-30T21:39:59.309879+02:00", "fts": "''allan'':1A ''patrikgrbi\u0107'':2A", "slug": null, "slika_url": null, "source": "hns_semafor", "source_id": null, "source_url": "https://semafor.hns.family/klubovi/1589/nk-zamet/", "source_synced_at": null, "dominantna_noga": null, "visina_cm": null, "tezina_kg": null, "broj_dresa": null, "reprezentacija_kategorija": null, "biografija": null, "mjesto_rodenja": "", "kategorije": [], "promocija_kategorije": [], "sport": "nogomet", "auto_kategorija_calc_at": null, "hoo_kategorija": null, "hoo_kategorija_od": null, "hoo_kategorija_do": null, "hoo_kat_vrijedi_od": null, "hoo_kat_vrijedi_do": null, "hoo_vrijedi_od": null, "hoo_vrijedi_do": null, "klub_naziv_godisnjak": null, "datum_rodjenja": null, "mjesto_rodjenja": null, "broj_dres": null, "vanjski_id": {}, "aktivni_status": "aktivan", "scrape_source": null, "scrape_url": null, "last_scraped_at": null, "godina_rodenja": null, "uloga": "clan", "hns_igrac_id": "209352", "verified": true, "last_updated": "2026-05-02T15:47:51.754652+02:00", "godisnjak_godine": null, "godisnjak_prvi": null, "godisnjak_zadnji": null, "dob_age": null, "savez_kod": null, "grand_metadata": null, "external_id": null, "savez_izvor": null, "profile_url": null, "uloga_detalj": null, "metadata": null}}'::jsonb, 'subagent-A@pgz-sport'); +DELETE FROM pgz_sport.clanovi WHERE id=2454; +COMMIT; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT id, source_url, (SELECT count(*) FROM jsonb_each(to_jsonb(c)) WHERE value::text NOT IN ('null','""')) AS nn, EXTRACT(EPOCH FROM created_at)::bigint AS cre FROM pgz_sport.clanovi c WHERE id=233) t; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT id, source_url, (SELECT count(*) FROM jsonb_each(to_jsonb(c)) WHERE value::text NOT IN ('null','""')) AS nn, EXTRACT(EPOCH FROM created_at)::bigint AS cre FROM pgz_sport.clanovi c WHERE id=2596) t; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT row_to_json(c) FROM pgz_sport.clanovi c WHERE id=2596) t; +UPDATE pgz_sport.clanovi auth SET "klub_id" = CASE WHEN auth."klub_id" IS NOT NULL THEN auth."klub_id" WHEN c2596."klub_id" IS NOT NULL THEN c2596."klub_id" ELSE auth."klub_id" END, "ime" = CASE WHEN auth."ime" IS NOT NULL THEN auth."ime" WHEN c2596."ime" IS NOT NULL THEN c2596."ime" ELSE auth."ime" END, "prezime" = CASE WHEN auth."prezime" IS NOT NULL THEN auth."prezime" WHEN c2596."prezime" IS NOT NULL THEN c2596."prezime" ELSE auth."prezime" END, "oib" = CASE WHEN auth."oib" IS NOT NULL THEN auth."oib" WHEN c2596."oib" IS NOT NULL THEN c2596."oib" ELSE auth."oib" END, "datum_rodenja" = CASE WHEN auth."datum_rodenja" IS NOT NULL THEN auth."datum_rodenja" WHEN c2596."datum_rodenja" IS NOT NULL THEN c2596."datum_rodenja" ELSE auth."datum_rodenja" END, "spol" = CASE WHEN auth."spol" IS NOT NULL THEN auth."spol" WHEN c2596."spol" IS NOT NULL THEN c2596."spol" ELSE auth."spol" END, "adresa" = CASE WHEN auth."adresa" IS NOT NULL THEN auth."adresa" WHEN c2596."adresa" IS NOT NULL THEN c2596."adresa" ELSE auth."adresa" END, "grad" = CASE WHEN auth."grad" IS NOT NULL THEN auth."grad" WHEN c2596."grad" IS NOT NULL THEN c2596."grad" ELSE auth."grad" END, "postanski_broj" = CASE WHEN auth."postanski_broj" IS NOT NULL THEN auth."postanski_broj" WHEN c2596."postanski_broj" IS NOT NULL THEN c2596."postanski_broj" ELSE auth."postanski_broj" END, "email" = CASE WHEN auth."email" IS NOT NULL THEN auth."email" WHEN c2596."email" IS NOT NULL THEN c2596."email" ELSE auth."email" END, "telefon" = CASE WHEN auth."telefon" IS NOT NULL THEN auth."telefon" WHEN c2596."telefon" IS NOT NULL THEN c2596."telefon" ELSE auth."telefon" END, "kategorija" = CASE WHEN auth."kategorija" IS NOT NULL THEN auth."kategorija" WHEN c2596."kategorija" IS NOT NULL THEN c2596."kategorija" ELSE auth."kategorija" END, "podkategorija" = CASE WHEN auth."podkategorija" IS NOT NULL THEN auth."podkategorija" WHEN c2596."podkategorija" IS NOT NULL THEN c2596."podkategorija" ELSE auth."podkategorija" END, "pozicija" = CASE WHEN auth."pozicija" IS NOT NULL THEN auth."pozicija" WHEN c2596."pozicija" IS NOT NULL THEN c2596."pozicija" ELSE auth."pozicija" END, "licenca_broj" = CASE WHEN auth."licenca_broj" IS NOT NULL THEN auth."licenca_broj" WHEN c2596."licenca_broj" IS NOT NULL THEN c2596."licenca_broj" ELSE auth."licenca_broj" END, "licenca_vrijedi_do" = CASE WHEN auth."licenca_vrijedi_do" IS NOT NULL THEN auth."licenca_vrijedi_do" WHEN c2596."licenca_vrijedi_do" IS NOT NULL THEN c2596."licenca_vrijedi_do" ELSE auth."licenca_vrijedi_do" END, "reprezentativac" = CASE WHEN auth."reprezentativac" IS NOT NULL THEN auth."reprezentativac" WHEN c2596."reprezentativac" IS NOT NULL THEN c2596."reprezentativac" ELSE auth."reprezentativac" END, "kategoriziran" = CASE WHEN auth."kategoriziran" IS NOT NULL THEN auth."kategoriziran" WHEN c2596."kategoriziran" IS NOT NULL THEN c2596."kategoriziran" ELSE auth."kategoriziran" END, "kategorija_hoo" = CASE WHEN auth."kategorija_hoo" IS NOT NULL THEN auth."kategorija_hoo" WHEN c2596."kategorija_hoo" IS NOT NULL THEN c2596."kategorija_hoo" ELSE auth."kategorija_hoo" END, "stipendiran" = CASE WHEN auth."stipendiran" IS NOT NULL THEN auth."stipendiran" WHEN c2596."stipendiran" IS NOT NULL THEN c2596."stipendiran" ELSE auth."stipendiran" END, "stipendija_iznos" = CASE WHEN auth."stipendija_iznos" IS NOT NULL THEN auth."stipendija_iznos" WHEN c2596."stipendija_iznos" IS NOT NULL THEN c2596."stipendija_iznos" ELSE auth."stipendija_iznos" END, "radno_pravni_status" = CASE WHEN auth."radno_pravni_status" IS NOT NULL THEN auth."radno_pravni_status" WHEN c2596."radno_pravni_status" IS NOT NULL THEN c2596."radno_pravni_status" ELSE auth."radno_pravni_status" END, "aktivan" = CASE WHEN auth."aktivan" IS NOT NULL THEN auth."aktivan" WHEN c2596."aktivan" IS NOT NULL THEN c2596."aktivan" ELSE auth."aktivan" END, "datum_pristupa" = CASE WHEN auth."datum_pristupa" IS NOT NULL THEN auth."datum_pristupa" WHEN c2596."datum_pristupa" IS NOT NULL THEN c2596."datum_pristupa" ELSE auth."datum_pristupa" END, "datum_napustanja" = CASE WHEN auth."datum_napustanja" IS NOT NULL THEN auth."datum_napustanja" WHEN c2596."datum_napustanja" IS NOT NULL THEN c2596."datum_napustanja" ELSE auth."datum_napustanja" END, "napomena" = CASE WHEN auth."napomena" IS NOT NULL THEN auth."napomena" WHEN c2596."napomena" IS NOT NULL THEN c2596."napomena" ELSE auth."napomena" END, "slug" = CASE WHEN auth."slug" IS NOT NULL THEN auth."slug" WHEN c2596."slug" IS NOT NULL THEN c2596."slug" ELSE auth."slug" END, "slika_url" = CASE WHEN auth."slika_url" IS NOT NULL THEN auth."slika_url" WHEN c2596."slika_url" IS NOT NULL THEN c2596."slika_url" ELSE auth."slika_url" END, "source" = CASE WHEN auth."source" IS NOT NULL THEN auth."source" WHEN c2596."source" IS NOT NULL THEN c2596."source" ELSE auth."source" END, "source_id" = CASE WHEN auth."source_id" IS NOT NULL THEN auth."source_id" WHEN c2596."source_id" IS NOT NULL THEN c2596."source_id" ELSE auth."source_id" END, "source_url" = CASE WHEN auth."source_url" IS NOT NULL THEN auth."source_url" WHEN c2596."source_url" IS NOT NULL THEN c2596."source_url" ELSE auth."source_url" END, "source_synced_at" = CASE WHEN auth."source_synced_at" IS NOT NULL THEN auth."source_synced_at" WHEN c2596."source_synced_at" IS NOT NULL THEN c2596."source_synced_at" ELSE auth."source_synced_at" END, "dominantna_noga" = CASE WHEN auth."dominantna_noga" IS NOT NULL THEN auth."dominantna_noga" WHEN c2596."dominantna_noga" IS NOT NULL THEN c2596."dominantna_noga" ELSE auth."dominantna_noga" END, "visina_cm" = CASE WHEN auth."visina_cm" IS NOT NULL THEN auth."visina_cm" WHEN c2596."visina_cm" IS NOT NULL THEN c2596."visina_cm" ELSE auth."visina_cm" END, "tezina_kg" = CASE WHEN auth."tezina_kg" IS NOT NULL THEN auth."tezina_kg" WHEN c2596."tezina_kg" IS NOT NULL THEN c2596."tezina_kg" ELSE auth."tezina_kg" END, "broj_dresa" = CASE WHEN auth."broj_dresa" IS NOT NULL THEN auth."broj_dresa" WHEN c2596."broj_dresa" IS NOT NULL THEN c2596."broj_dresa" ELSE auth."broj_dresa" END, "reprezentacija_kategorija" = CASE WHEN auth."reprezentacija_kategorija" IS NOT NULL THEN auth."reprezentacija_kategorija" WHEN c2596."reprezentacija_kategorija" IS NOT NULL THEN c2596."reprezentacija_kategorija" ELSE auth."reprezentacija_kategorija" END, "biografija" = CASE WHEN auth."biografija" IS NOT NULL THEN auth."biografija" WHEN c2596."biografija" IS NOT NULL THEN c2596."biografija" ELSE auth."biografija" END, "mjesto_rodenja" = CASE WHEN auth."mjesto_rodenja" IS NOT NULL THEN auth."mjesto_rodenja" WHEN c2596."mjesto_rodenja" IS NOT NULL THEN c2596."mjesto_rodenja" ELSE auth."mjesto_rodenja" END, "kategorije" = CASE WHEN auth."kategorije" IS NOT NULL THEN auth."kategorije" WHEN c2596."kategorije" IS NOT NULL THEN c2596."kategorije" ELSE auth."kategorije" END, "promocija_kategorije" = CASE WHEN auth."promocija_kategorije" IS NOT NULL THEN auth."promocija_kategorije" WHEN c2596."promocija_kategorije" IS NOT NULL THEN c2596."promocija_kategorije" ELSE auth."promocija_kategorije" END, "sport" = CASE WHEN auth."sport" IS NOT NULL THEN auth."sport" WHEN c2596."sport" IS NOT NULL THEN c2596."sport" ELSE auth."sport" END, "auto_kategorija_calc_at" = CASE WHEN auth."auto_kategorija_calc_at" IS NOT NULL THEN auth."auto_kategorija_calc_at" WHEN c2596."auto_kategorija_calc_at" IS NOT NULL THEN c2596."auto_kategorija_calc_at" ELSE auth."auto_kategorija_calc_at" END, "hoo_kategorija" = CASE WHEN auth."hoo_kategorija" IS NOT NULL THEN auth."hoo_kategorija" WHEN c2596."hoo_kategorija" IS NOT NULL THEN c2596."hoo_kategorija" ELSE auth."hoo_kategorija" END, "hoo_kategorija_od" = CASE WHEN auth."hoo_kategorija_od" IS NOT NULL THEN auth."hoo_kategorija_od" WHEN c2596."hoo_kategorija_od" IS NOT NULL THEN c2596."hoo_kategorija_od" ELSE auth."hoo_kategorija_od" END, "hoo_kategorija_do" = CASE WHEN auth."hoo_kategorija_do" IS NOT NULL THEN auth."hoo_kategorija_do" WHEN c2596."hoo_kategorija_do" IS NOT NULL THEN c2596."hoo_kategorija_do" ELSE auth."hoo_kategorija_do" END, "hoo_kat_vrijedi_od" = CASE WHEN auth."hoo_kat_vrijedi_od" IS NOT NULL THEN auth."hoo_kat_vrijedi_od" WHEN c2596."hoo_kat_vrijedi_od" IS NOT NULL THEN c2596."hoo_kat_vrijedi_od" ELSE auth."hoo_kat_vrijedi_od" END, "hoo_kat_vrijedi_do" = CASE WHEN auth."hoo_kat_vrijedi_do" IS NOT NULL THEN auth."hoo_kat_vrijedi_do" WHEN c2596."hoo_kat_vrijedi_do" IS NOT NULL THEN c2596."hoo_kat_vrijedi_do" ELSE auth."hoo_kat_vrijedi_do" END, "hoo_vrijedi_od" = CASE WHEN auth."hoo_vrijedi_od" IS NOT NULL THEN auth."hoo_vrijedi_od" WHEN c2596."hoo_vrijedi_od" IS NOT NULL THEN c2596."hoo_vrijedi_od" ELSE auth."hoo_vrijedi_od" END, "hoo_vrijedi_do" = CASE WHEN auth."hoo_vrijedi_do" IS NOT NULL THEN auth."hoo_vrijedi_do" WHEN c2596."hoo_vrijedi_do" IS NOT NULL THEN c2596."hoo_vrijedi_do" ELSE auth."hoo_vrijedi_do" END, "klub_naziv_godisnjak" = CASE WHEN auth."klub_naziv_godisnjak" IS NOT NULL THEN auth."klub_naziv_godisnjak" WHEN c2596."klub_naziv_godisnjak" IS NOT NULL THEN c2596."klub_naziv_godisnjak" ELSE auth."klub_naziv_godisnjak" END, "datum_rodjenja" = CASE WHEN auth."datum_rodjenja" IS NOT NULL THEN auth."datum_rodjenja" WHEN c2596."datum_rodjenja" IS NOT NULL THEN c2596."datum_rodjenja" ELSE auth."datum_rodjenja" END, "mjesto_rodjenja" = CASE WHEN auth."mjesto_rodjenja" IS NOT NULL THEN auth."mjesto_rodjenja" WHEN c2596."mjesto_rodjenja" IS NOT NULL THEN c2596."mjesto_rodjenja" ELSE auth."mjesto_rodjenja" END, "broj_dres" = CASE WHEN auth."broj_dres" IS NOT NULL THEN auth."broj_dres" WHEN c2596."broj_dres" IS NOT NULL THEN c2596."broj_dres" ELSE auth."broj_dres" END, "vanjski_id" = CASE WHEN auth."vanjski_id" IS NOT NULL THEN auth."vanjski_id" WHEN c2596."vanjski_id" IS NOT NULL THEN c2596."vanjski_id" ELSE auth."vanjski_id" END, "aktivni_status" = CASE WHEN auth."aktivni_status" IS NOT NULL THEN auth."aktivni_status" WHEN c2596."aktivni_status" IS NOT NULL THEN c2596."aktivni_status" ELSE auth."aktivni_status" END, "scrape_source" = CASE WHEN auth."scrape_source" IS NOT NULL THEN auth."scrape_source" WHEN c2596."scrape_source" IS NOT NULL THEN c2596."scrape_source" ELSE auth."scrape_source" END, "scrape_url" = CASE WHEN auth."scrape_url" IS NOT NULL THEN auth."scrape_url" WHEN c2596."scrape_url" IS NOT NULL THEN c2596."scrape_url" ELSE auth."scrape_url" END, "last_scraped_at" = CASE WHEN auth."last_scraped_at" IS NOT NULL THEN auth."last_scraped_at" WHEN c2596."last_scraped_at" IS NOT NULL THEN c2596."last_scraped_at" ELSE auth."last_scraped_at" END, "godina_rodenja" = CASE WHEN auth."godina_rodenja" IS NOT NULL THEN auth."godina_rodenja" WHEN c2596."godina_rodenja" IS NOT NULL THEN c2596."godina_rodenja" ELSE auth."godina_rodenja" END, "uloga" = CASE WHEN auth."uloga" IS NOT NULL THEN auth."uloga" WHEN c2596."uloga" IS NOT NULL THEN c2596."uloga" ELSE auth."uloga" END, "hns_igrac_id" = CASE WHEN auth."hns_igrac_id" IS NOT NULL THEN auth."hns_igrac_id" WHEN c2596."hns_igrac_id" IS NOT NULL THEN c2596."hns_igrac_id" ELSE auth."hns_igrac_id" END, "verified" = CASE WHEN auth."verified" IS NOT NULL THEN auth."verified" WHEN c2596."verified" IS NOT NULL THEN c2596."verified" ELSE auth."verified" END, "godisnjak_godine" = CASE WHEN auth."godisnjak_godine" IS NOT NULL THEN auth."godisnjak_godine" WHEN c2596."godisnjak_godine" IS NOT NULL THEN c2596."godisnjak_godine" ELSE auth."godisnjak_godine" END, "godisnjak_prvi" = CASE WHEN auth."godisnjak_prvi" IS NOT NULL THEN auth."godisnjak_prvi" WHEN c2596."godisnjak_prvi" IS NOT NULL THEN c2596."godisnjak_prvi" ELSE auth."godisnjak_prvi" END, "godisnjak_zadnji" = CASE WHEN auth."godisnjak_zadnji" IS NOT NULL THEN auth."godisnjak_zadnji" WHEN c2596."godisnjak_zadnji" IS NOT NULL THEN c2596."godisnjak_zadnji" ELSE auth."godisnjak_zadnji" END, "dob_age" = CASE WHEN auth."dob_age" IS NOT NULL THEN auth."dob_age" WHEN c2596."dob_age" IS NOT NULL THEN c2596."dob_age" ELSE auth."dob_age" END, "savez_kod" = CASE WHEN auth."savez_kod" IS NOT NULL THEN auth."savez_kod" WHEN c2596."savez_kod" IS NOT NULL THEN c2596."savez_kod" ELSE auth."savez_kod" END, "grand_metadata" = CASE WHEN auth."grand_metadata" IS NOT NULL THEN auth."grand_metadata" WHEN c2596."grand_metadata" IS NOT NULL THEN c2596."grand_metadata" ELSE auth."grand_metadata" END, "external_id" = CASE WHEN auth."external_id" IS NOT NULL THEN auth."external_id" WHEN c2596."external_id" IS NOT NULL THEN c2596."external_id" ELSE auth."external_id" END, "savez_izvor" = CASE WHEN auth."savez_izvor" IS NOT NULL THEN auth."savez_izvor" WHEN c2596."savez_izvor" IS NOT NULL THEN c2596."savez_izvor" ELSE auth."savez_izvor" END, "profile_url" = CASE WHEN auth."profile_url" IS NOT NULL THEN auth."profile_url" WHEN c2596."profile_url" IS NOT NULL THEN c2596."profile_url" ELSE auth."profile_url" END, "uloga_detalj" = CASE WHEN auth."uloga_detalj" IS NOT NULL THEN auth."uloga_detalj" WHEN c2596."uloga_detalj" IS NOT NULL THEN c2596."uloga_detalj" ELSE auth."uloga_detalj" END, "metadata" = CASE WHEN auth."metadata" IS NOT NULL THEN auth."metadata" WHEN c2596."metadata" IS NOT NULL THEN c2596."metadata" ELSE auth."metadata" END, updated_at = now() FROM pgz_sport.clanovi c2596 WHERE auth.id = 233 AND c2596.id = 2596; +UPDATE pgz_sport.clan_nagrada SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.clan_nagrada WHERE clan_id=233; +UPDATE pgz_sport.clan_sezona SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.clan_sezona WHERE clan_id=233; +UPDATE pgz_sport.clan_utakmica SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.clan_utakmica WHERE clan_id=233; +UPDATE pgz_sport.clanarine SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.clanarine WHERE clan_id=233; +UPDATE pgz_sport.lijecnicki_pregledi SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=233; +UPDATE pgz_sport.sportas_specifika SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.sportas_specifika WHERE clan_id=233; +UPDATE pgz_sport.user_klub_links SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.user_klub_links WHERE clan_id=233; +UPDATE pgz_sport.expense_reports SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.expense_reports WHERE clan_id=233; +UPDATE pgz_sport.form_submissions SET clan_id=233 WHERE clan_id=2596; +SELECT count(*) FROM pgz_sport.form_submissions WHERE clan_id=233; +SELECT count(*) FROM pgz_sport.utakmice_log d WHERE d.clan_id=2596 AND EXISTS (SELECT 1 FROM pgz_sport.utakmice_log a WHERE a.clan_id=233 AND a.source=d.source AND a.source_match_id IS NOT DISTINCT FROM d.source_match_id); +UPDATE pgz_sport.utakmice_log d SET clan_id=233 WHERE d.clan_id=2596 AND NOT EXISTS (SELECT 1 FROM pgz_sport.utakmice_log a WHERE a.clan_id=233 AND a.source=d.source AND a.source_match_id IS NOT DISTINCT FROM d.source_match_id); +SELECT count(*) FROM pgz_sport.utakmice_log WHERE clan_id=233; +INSERT INTO pgz_sport.clanovi_purged SELECT *, now(), 'hns_dup_merge', 233 FROM pgz_sport.clanovi WHERE id=2596; +INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload, user_email) VALUES ('CLANOVI_PURGE', 'clanovi', 2596, 'merged into 233', '{"auth_id": 233, "dup_id": 2596, "hns_key": "395328", "fk_moves": {"clan_nagrada": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_sezona": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_utakmica": {"now_on_auth": 0, "skipped_conflicts": 0}, "clanarine": {"now_on_auth": 0, "skipped_conflicts": 0}, "lijecnicki_pregledi": {"now_on_auth": 0, "skipped_conflicts": 0}, "sportas_specifika": {"now_on_auth": 0, "skipped_conflicts": 0}, "user_klub_links": {"now_on_auth": 0, "skipped_conflicts": 0}, "expense_reports": {"now_on_auth": 0, "skipped_conflicts": 0}, "form_submissions": {"now_on_auth": 0, "skipped_conflicts": 0}, "utakmice_log": {"now_on_auth": 14, "skipped_conflicts": 0}}, "dup_row_json": {"id": 2596, "klub_id": 782, "ime": "FranPetru\u0161anec", "prezime": "Pe\u010dar", "oib": null, "datum_rodenja": null, "spol": null, "adresa": null, "grad": null, "postanski_broj": null, "email": null, "telefon": null, "kategorija": null, "podkategorija": null, "pozicija": "", "licenca_broj": null, "licenca_vrijedi_do": null, "reprezentativac": false, "kategoriziran": false, "kategorija_hoo": null, "stipendiran": false, "stipendija_iznos": null, "radno_pravni_status": null, "aktivan": true, "datum_pristupa": null, "datum_napustanja": null, "napomena": null, "created_at": "2026-04-30T21:42:06.433271+02:00", "updated_at": "2026-04-30T21:42:06.433271+02:00", "fts": "''franpetru\u0161anec'':1A ''pe\u010dar'':2A", "slug": null, "slika_url": null, "source": "hns_semafor", "source_id": null, "source_url": "https://semafor.hns.family/klubovi/1565/hnk-goranin/", "source_synced_at": null, "dominantna_noga": null, "visina_cm": null, "tezina_kg": null, "broj_dresa": null, "reprezentacija_kategorija": null, "biografija": null, "mjesto_rodenja": "", "kategorije": [], "promocija_kategorije": [], "sport": "nogomet", "auto_kategorija_calc_at": null, "hoo_kategorija": null, "hoo_kategorija_od": null, "hoo_kategorija_do": null, "hoo_kat_vrijedi_od": null, "hoo_kat_vrijedi_do": null, "hoo_vrijedi_od": null, "hoo_vrijedi_do": null, "klub_naziv_godisnjak": null, "datum_rodjenja": null, "mjesto_rodjenja": null, "broj_dres": null, "vanjski_id": {}, "aktivni_status": "aktivan", "scrape_source": null, "scrape_url": null, "last_scraped_at": null, "godina_rodenja": null, "uloga": "clan", "hns_igrac_id": "395328", "verified": true, "last_updated": "2026-05-02T15:47:51.754652+02:00", "godisnjak_godine": null, "godisnjak_prvi": null, "godisnjak_zadnji": null, "dob_age": null, "savez_kod": null, "grand_metadata": null, "external_id": null, "savez_izvor": null, "profile_url": null, "uloga_detalj": null, "metadata": null}}'::jsonb, 'subagent-A@pgz-sport'); +DELETE FROM pgz_sport.clanovi WHERE id=2596; +COMMIT; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT id, source_url, (SELECT count(*) FROM jsonb_each(to_jsonb(c)) WHERE value::text NOT IN ('null','""')) AS nn, EXTRACT(EPOCH FROM created_at)::bigint AS cre FROM pgz_sport.clanovi c WHERE id=481) t; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT id, source_url, (SELECT count(*) FROM jsonb_each(to_jsonb(c)) WHERE value::text NOT IN ('null','""')) AS nn, EXTRACT(EPOCH FROM created_at)::bigint AS cre FROM pgz_sport.clanovi c WHERE id=2600) t; +SELECT coalesce(json_agg(t), '[]'::json)::text FROM (SELECT row_to_json(c) FROM pgz_sport.clanovi c WHERE id=2600) t; +UPDATE pgz_sport.clanovi auth SET "klub_id" = CASE WHEN auth."klub_id" IS NOT NULL THEN auth."klub_id" WHEN c2600."klub_id" IS NOT NULL THEN c2600."klub_id" ELSE auth."klub_id" END, "ime" = CASE WHEN auth."ime" IS NOT NULL THEN auth."ime" WHEN c2600."ime" IS NOT NULL THEN c2600."ime" ELSE auth."ime" END, "prezime" = CASE WHEN auth."prezime" IS NOT NULL THEN auth."prezime" WHEN c2600."prezime" IS NOT NULL THEN c2600."prezime" ELSE auth."prezime" END, "oib" = CASE WHEN auth."oib" IS NOT NULL THEN auth."oib" WHEN c2600."oib" IS NOT NULL THEN c2600."oib" ELSE auth."oib" END, "datum_rodenja" = CASE WHEN auth."datum_rodenja" IS NOT NULL THEN auth."datum_rodenja" WHEN c2600."datum_rodenja" IS NOT NULL THEN c2600."datum_rodenja" ELSE auth."datum_rodenja" END, "spol" = CASE WHEN auth."spol" IS NOT NULL THEN auth."spol" WHEN c2600."spol" IS NOT NULL THEN c2600."spol" ELSE auth."spol" END, "adresa" = CASE WHEN auth."adresa" IS NOT NULL THEN auth."adresa" WHEN c2600."adresa" IS NOT NULL THEN c2600."adresa" ELSE auth."adresa" END, "grad" = CASE WHEN auth."grad" IS NOT NULL THEN auth."grad" WHEN c2600."grad" IS NOT NULL THEN c2600."grad" ELSE auth."grad" END, "postanski_broj" = CASE WHEN auth."postanski_broj" IS NOT NULL THEN auth."postanski_broj" WHEN c2600."postanski_broj" IS NOT NULL THEN c2600."postanski_broj" ELSE auth."postanski_broj" END, "email" = CASE WHEN auth."email" IS NOT NULL THEN auth."email" WHEN c2600."email" IS NOT NULL THEN c2600."email" ELSE auth."email" END, "telefon" = CASE WHEN auth."telefon" IS NOT NULL THEN auth."telefon" WHEN c2600."telefon" IS NOT NULL THEN c2600."telefon" ELSE auth."telefon" END, "kategorija" = CASE WHEN auth."kategorija" IS NOT NULL THEN auth."kategorija" WHEN c2600."kategorija" IS NOT NULL THEN c2600."kategorija" ELSE auth."kategorija" END, "podkategorija" = CASE WHEN auth."podkategorija" IS NOT NULL THEN auth."podkategorija" WHEN c2600."podkategorija" IS NOT NULL THEN c2600."podkategorija" ELSE auth."podkategorija" END, "pozicija" = CASE WHEN auth."pozicija" IS NOT NULL THEN auth."pozicija" WHEN c2600."pozicija" IS NOT NULL THEN c2600."pozicija" ELSE auth."pozicija" END, "licenca_broj" = CASE WHEN auth."licenca_broj" IS NOT NULL THEN auth."licenca_broj" WHEN c2600."licenca_broj" IS NOT NULL THEN c2600."licenca_broj" ELSE auth."licenca_broj" END, "licenca_vrijedi_do" = CASE WHEN auth."licenca_vrijedi_do" IS NOT NULL THEN auth."licenca_vrijedi_do" WHEN c2600."licenca_vrijedi_do" IS NOT NULL THEN c2600."licenca_vrijedi_do" ELSE auth."licenca_vrijedi_do" END, "reprezentativac" = CASE WHEN auth."reprezentativac" IS NOT NULL THEN auth."reprezentativac" WHEN c2600."reprezentativac" IS NOT NULL THEN c2600."reprezentativac" ELSE auth."reprezentativac" END, "kategoriziran" = CASE WHEN auth."kategoriziran" IS NOT NULL THEN auth."kategoriziran" WHEN c2600."kategoriziran" IS NOT NULL THEN c2600."kategoriziran" ELSE auth."kategoriziran" END, "kategorija_hoo" = CASE WHEN auth."kategorija_hoo" IS NOT NULL THEN auth."kategorija_hoo" WHEN c2600."kategorija_hoo" IS NOT NULL THEN c2600."kategorija_hoo" ELSE auth."kategorija_hoo" END, "stipendiran" = CASE WHEN auth."stipendiran" IS NOT NULL THEN auth."stipendiran" WHEN c2600."stipendiran" IS NOT NULL THEN c2600."stipendiran" ELSE auth."stipendiran" END, "stipendija_iznos" = CASE WHEN auth."stipendija_iznos" IS NOT NULL THEN auth."stipendija_iznos" WHEN c2600."stipendija_iznos" IS NOT NULL THEN c2600."stipendija_iznos" ELSE auth."stipendija_iznos" END, "radno_pravni_status" = CASE WHEN auth."radno_pravni_status" IS NOT NULL THEN auth."radno_pravni_status" WHEN c2600."radno_pravni_status" IS NOT NULL THEN c2600."radno_pravni_status" ELSE auth."radno_pravni_status" END, "aktivan" = CASE WHEN auth."aktivan" IS NOT NULL THEN auth."aktivan" WHEN c2600."aktivan" IS NOT NULL THEN c2600."aktivan" ELSE auth."aktivan" END, "datum_pristupa" = CASE WHEN auth."datum_pristupa" IS NOT NULL THEN auth."datum_pristupa" WHEN c2600."datum_pristupa" IS NOT NULL THEN c2600."datum_pristupa" ELSE auth."datum_pristupa" END, "datum_napustanja" = CASE WHEN auth."datum_napustanja" IS NOT NULL THEN auth."datum_napustanja" WHEN c2600."datum_napustanja" IS NOT NULL THEN c2600."datum_napustanja" ELSE auth."datum_napustanja" END, "napomena" = CASE WHEN auth."napomena" IS NOT NULL THEN auth."napomena" WHEN c2600."napomena" IS NOT NULL THEN c2600."napomena" ELSE auth."napomena" END, "slug" = CASE WHEN auth."slug" IS NOT NULL THEN auth."slug" WHEN c2600."slug" IS NOT NULL THEN c2600."slug" ELSE auth."slug" END, "slika_url" = CASE WHEN auth."slika_url" IS NOT NULL THEN auth."slika_url" WHEN c2600."slika_url" IS NOT NULL THEN c2600."slika_url" ELSE auth."slika_url" END, "source" = CASE WHEN auth."source" IS NOT NULL THEN auth."source" WHEN c2600."source" IS NOT NULL THEN c2600."source" ELSE auth."source" END, "source_id" = CASE WHEN auth."source_id" IS NOT NULL THEN auth."source_id" WHEN c2600."source_id" IS NOT NULL THEN c2600."source_id" ELSE auth."source_id" END, "source_url" = CASE WHEN auth."source_url" IS NOT NULL THEN auth."source_url" WHEN c2600."source_url" IS NOT NULL THEN c2600."source_url" ELSE auth."source_url" END, "source_synced_at" = CASE WHEN auth."source_synced_at" IS NOT NULL THEN auth."source_synced_at" WHEN c2600."source_synced_at" IS NOT NULL THEN c2600."source_synced_at" ELSE auth."source_synced_at" END, "dominantna_noga" = CASE WHEN auth."dominantna_noga" IS NOT NULL THEN auth."dominantna_noga" WHEN c2600."dominantna_noga" IS NOT NULL THEN c2600."dominantna_noga" ELSE auth."dominantna_noga" END, "visina_cm" = CASE WHEN auth."visina_cm" IS NOT NULL THEN auth."visina_cm" WHEN c2600."visina_cm" IS NOT NULL THEN c2600."visina_cm" ELSE auth."visina_cm" END, "tezina_kg" = CASE WHEN auth."tezina_kg" IS NOT NULL THEN auth."tezina_kg" WHEN c2600."tezina_kg" IS NOT NULL THEN c2600."tezina_kg" ELSE auth."tezina_kg" END, "broj_dresa" = CASE WHEN auth."broj_dresa" IS NOT NULL THEN auth."broj_dresa" WHEN c2600."broj_dresa" IS NOT NULL THEN c2600."broj_dresa" ELSE auth."broj_dresa" END, "reprezentacija_kategorija" = CASE WHEN auth."reprezentacija_kategorija" IS NOT NULL THEN auth."reprezentacija_kategorija" WHEN c2600."reprezentacija_kategorija" IS NOT NULL THEN c2600."reprezentacija_kategorija" ELSE auth."reprezentacija_kategorija" END, "biografija" = CASE WHEN auth."biografija" IS NOT NULL THEN auth."biografija" WHEN c2600."biografija" IS NOT NULL THEN c2600."biografija" ELSE auth."biografija" END, "mjesto_rodenja" = CASE WHEN auth."mjesto_rodenja" IS NOT NULL THEN auth."mjesto_rodenja" WHEN c2600."mjesto_rodenja" IS NOT NULL THEN c2600."mjesto_rodenja" ELSE auth."mjesto_rodenja" END, "kategorije" = CASE WHEN auth."kategorije" IS NOT NULL THEN auth."kategorije" WHEN c2600."kategorije" IS NOT NULL THEN c2600."kategorije" ELSE auth."kategorije" END, "promocija_kategorije" = CASE WHEN auth."promocija_kategorije" IS NOT NULL THEN auth."promocija_kategorije" WHEN c2600."promocija_kategorije" IS NOT NULL THEN c2600."promocija_kategorije" ELSE auth."promocija_kategorije" END, "sport" = CASE WHEN auth."sport" IS NOT NULL THEN auth."sport" WHEN c2600."sport" IS NOT NULL THEN c2600."sport" ELSE auth."sport" END, "auto_kategorija_calc_at" = CASE WHEN auth."auto_kategorija_calc_at" IS NOT NULL THEN auth."auto_kategorija_calc_at" WHEN c2600."auto_kategorija_calc_at" IS NOT NULL THEN c2600."auto_kategorija_calc_at" ELSE auth."auto_kategorija_calc_at" END, "hoo_kategorija" = CASE WHEN auth."hoo_kategorija" IS NOT NULL THEN auth."hoo_kategorija" WHEN c2600."hoo_kategorija" IS NOT NULL THEN c2600."hoo_kategorija" ELSE auth."hoo_kategorija" END, "hoo_kategorija_od" = CASE WHEN auth."hoo_kategorija_od" IS NOT NULL THEN auth."hoo_kategorija_od" WHEN c2600."hoo_kategorija_od" IS NOT NULL THEN c2600."hoo_kategorija_od" ELSE auth."hoo_kategorija_od" END, "hoo_kategorija_do" = CASE WHEN auth."hoo_kategorija_do" IS NOT NULL THEN auth."hoo_kategorija_do" WHEN c2600."hoo_kategorija_do" IS NOT NULL THEN c2600."hoo_kategorija_do" ELSE auth."hoo_kategorija_do" END, "hoo_kat_vrijedi_od" = CASE WHEN auth."hoo_kat_vrijedi_od" IS NOT NULL THEN auth."hoo_kat_vrijedi_od" WHEN c2600."hoo_kat_vrijedi_od" IS NOT NULL THEN c2600."hoo_kat_vrijedi_od" ELSE auth."hoo_kat_vrijedi_od" END, "hoo_kat_vrijedi_do" = CASE WHEN auth."hoo_kat_vrijedi_do" IS NOT NULL THEN auth."hoo_kat_vrijedi_do" WHEN c2600."hoo_kat_vrijedi_do" IS NOT NULL THEN c2600."hoo_kat_vrijedi_do" ELSE auth."hoo_kat_vrijedi_do" END, "hoo_vrijedi_od" = CASE WHEN auth."hoo_vrijedi_od" IS NOT NULL THEN auth."hoo_vrijedi_od" WHEN c2600."hoo_vrijedi_od" IS NOT NULL THEN c2600."hoo_vrijedi_od" ELSE auth."hoo_vrijedi_od" END, "hoo_vrijedi_do" = CASE WHEN auth."hoo_vrijedi_do" IS NOT NULL THEN auth."hoo_vrijedi_do" WHEN c2600."hoo_vrijedi_do" IS NOT NULL THEN c2600."hoo_vrijedi_do" ELSE auth."hoo_vrijedi_do" END, "klub_naziv_godisnjak" = CASE WHEN auth."klub_naziv_godisnjak" IS NOT NULL THEN auth."klub_naziv_godisnjak" WHEN c2600."klub_naziv_godisnjak" IS NOT NULL THEN c2600."klub_naziv_godisnjak" ELSE auth."klub_naziv_godisnjak" END, "datum_rodjenja" = CASE WHEN auth."datum_rodjenja" IS NOT NULL THEN auth."datum_rodjenja" WHEN c2600."datum_rodjenja" IS NOT NULL THEN c2600."datum_rodjenja" ELSE auth."datum_rodjenja" END, "mjesto_rodjenja" = CASE WHEN auth."mjesto_rodjenja" IS NOT NULL THEN auth."mjesto_rodjenja" WHEN c2600."mjesto_rodjenja" IS NOT NULL THEN c2600."mjesto_rodjenja" ELSE auth."mjesto_rodjenja" END, "broj_dres" = CASE WHEN auth."broj_dres" IS NOT NULL THEN auth."broj_dres" WHEN c2600."broj_dres" IS NOT NULL THEN c2600."broj_dres" ELSE auth."broj_dres" END, "vanjski_id" = CASE WHEN auth."vanjski_id" IS NOT NULL THEN auth."vanjski_id" WHEN c2600."vanjski_id" IS NOT NULL THEN c2600."vanjski_id" ELSE auth."vanjski_id" END, "aktivni_status" = CASE WHEN auth."aktivni_status" IS NOT NULL THEN auth."aktivni_status" WHEN c2600."aktivni_status" IS NOT NULL THEN c2600."aktivni_status" ELSE auth."aktivni_status" END, "scrape_source" = CASE WHEN auth."scrape_source" IS NOT NULL THEN auth."scrape_source" WHEN c2600."scrape_source" IS NOT NULL THEN c2600."scrape_source" ELSE auth."scrape_source" END, "scrape_url" = CASE WHEN auth."scrape_url" IS NOT NULL THEN auth."scrape_url" WHEN c2600."scrape_url" IS NOT NULL THEN c2600."scrape_url" ELSE auth."scrape_url" END, "last_scraped_at" = CASE WHEN auth."last_scraped_at" IS NOT NULL THEN auth."last_scraped_at" WHEN c2600."last_scraped_at" IS NOT NULL THEN c2600."last_scraped_at" ELSE auth."last_scraped_at" END, "godina_rodenja" = CASE WHEN auth."godina_rodenja" IS NOT NULL THEN auth."godina_rodenja" WHEN c2600."godina_rodenja" IS NOT NULL THEN c2600."godina_rodenja" ELSE auth."godina_rodenja" END, "uloga" = CASE WHEN auth."uloga" IS NOT NULL THEN auth."uloga" WHEN c2600."uloga" IS NOT NULL THEN c2600."uloga" ELSE auth."uloga" END, "hns_igrac_id" = CASE WHEN auth."hns_igrac_id" IS NOT NULL THEN auth."hns_igrac_id" WHEN c2600."hns_igrac_id" IS NOT NULL THEN c2600."hns_igrac_id" ELSE auth."hns_igrac_id" END, "verified" = CASE WHEN auth."verified" IS NOT NULL THEN auth."verified" WHEN c2600."verified" IS NOT NULL THEN c2600."verified" ELSE auth."verified" END, "godisnjak_godine" = CASE WHEN auth."godisnjak_godine" IS NOT NULL THEN auth."godisnjak_godine" WHEN c2600."godisnjak_godine" IS NOT NULL THEN c2600."godisnjak_godine" ELSE auth."godisnjak_godine" END, "godisnjak_prvi" = CASE WHEN auth."godisnjak_prvi" IS NOT NULL THEN auth."godisnjak_prvi" WHEN c2600."godisnjak_prvi" IS NOT NULL THEN c2600."godisnjak_prvi" ELSE auth."godisnjak_prvi" END, "godisnjak_zadnji" = CASE WHEN auth."godisnjak_zadnji" IS NOT NULL THEN auth."godisnjak_zadnji" WHEN c2600."godisnjak_zadnji" IS NOT NULL THEN c2600."godisnjak_zadnji" ELSE auth."godisnjak_zadnji" END, "dob_age" = CASE WHEN auth."dob_age" IS NOT NULL THEN auth."dob_age" WHEN c2600."dob_age" IS NOT NULL THEN c2600."dob_age" ELSE auth."dob_age" END, "savez_kod" = CASE WHEN auth."savez_kod" IS NOT NULL THEN auth."savez_kod" WHEN c2600."savez_kod" IS NOT NULL THEN c2600."savez_kod" ELSE auth."savez_kod" END, "grand_metadata" = CASE WHEN auth."grand_metadata" IS NOT NULL THEN auth."grand_metadata" WHEN c2600."grand_metadata" IS NOT NULL THEN c2600."grand_metadata" ELSE auth."grand_metadata" END, "external_id" = CASE WHEN auth."external_id" IS NOT NULL THEN auth."external_id" WHEN c2600."external_id" IS NOT NULL THEN c2600."external_id" ELSE auth."external_id" END, "savez_izvor" = CASE WHEN auth."savez_izvor" IS NOT NULL THEN auth."savez_izvor" WHEN c2600."savez_izvor" IS NOT NULL THEN c2600."savez_izvor" ELSE auth."savez_izvor" END, "profile_url" = CASE WHEN auth."profile_url" IS NOT NULL THEN auth."profile_url" WHEN c2600."profile_url" IS NOT NULL THEN c2600."profile_url" ELSE auth."profile_url" END, "uloga_detalj" = CASE WHEN auth."uloga_detalj" IS NOT NULL THEN auth."uloga_detalj" WHEN c2600."uloga_detalj" IS NOT NULL THEN c2600."uloga_detalj" ELSE auth."uloga_detalj" END, "metadata" = CASE WHEN auth."metadata" IS NOT NULL THEN auth."metadata" WHEN c2600."metadata" IS NOT NULL THEN c2600."metadata" ELSE auth."metadata" END, updated_at = now() FROM pgz_sport.clanovi c2600 WHERE auth.id = 481 AND c2600.id = 2600; +UPDATE pgz_sport.clan_nagrada SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.clan_nagrada WHERE clan_id=481; +UPDATE pgz_sport.clan_sezona SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.clan_sezona WHERE clan_id=481; +UPDATE pgz_sport.clan_utakmica SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.clan_utakmica WHERE clan_id=481; +UPDATE pgz_sport.clanarine SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.clanarine WHERE clan_id=481; +UPDATE pgz_sport.lijecnicki_pregledi SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.lijecnicki_pregledi WHERE clan_id=481; +UPDATE pgz_sport.sportas_specifika SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.sportas_specifika WHERE clan_id=481; +UPDATE pgz_sport.user_klub_links SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.user_klub_links WHERE clan_id=481; +UPDATE pgz_sport.expense_reports SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.expense_reports WHERE clan_id=481; +UPDATE pgz_sport.form_submissions SET clan_id=481 WHERE clan_id=2600; +SELECT count(*) FROM pgz_sport.form_submissions WHERE clan_id=481; +SELECT count(*) FROM pgz_sport.utakmice_log d WHERE d.clan_id=2600 AND EXISTS (SELECT 1 FROM pgz_sport.utakmice_log a WHERE a.clan_id=481 AND a.source=d.source AND a.source_match_id IS NOT DISTINCT FROM d.source_match_id); +UPDATE pgz_sport.utakmice_log d SET clan_id=481 WHERE d.clan_id=2600 AND NOT EXISTS (SELECT 1 FROM pgz_sport.utakmice_log a WHERE a.clan_id=481 AND a.source=d.source AND a.source_match_id IS NOT DISTINCT FROM d.source_match_id); +SELECT count(*) FROM pgz_sport.utakmice_log WHERE clan_id=481; +INSERT INTO pgz_sport.clanovi_purged SELECT *, now(), 'hns_dup_merge', 481 FROM pgz_sport.clanovi WHERE id=2600; +INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload, user_email) VALUES ('CLANOVI_PURGE', 'clanovi', 2600, 'merged into 481', '{"auth_id": 481, "dup_id": 2600, "hns_key": "436387", "fk_moves": {"clan_nagrada": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_sezona": {"now_on_auth": 0, "skipped_conflicts": 0}, "clan_utakmica": {"now_on_auth": 0, "skipped_conflicts": 0}, "clanarine": {"now_on_auth": 0, "skipped_conflicts": 0}, "lijecnicki_pregledi": {"now_on_auth": 0, "skipped_conflicts": 0}, "sportas_specifika": {"now_on_auth": 0, "skipped_conflicts": 0}, "user_klub_links": {"now_on_auth": 0, "skipped_conflicts": 0}, "expense_reports": {"now_on_auth": 0, "skipped_conflicts": 0}, "form_submissions": {"now_on_auth": 0, "skipped_conflicts": 0}, "utakmice_log": {"now_on_auth": 1, "skipped_conflicts": 0}}, "dup_row_json": {"id": 2600, "klub_id": 2205, "ime": "ManuelBoras", "prezime": "Mandi\u0107", "oib": null, "datum_rodenja": null, "spol": null, "adresa": null, "grad": null, "postanski_broj": null, "email": null, "telefon": null, "kategorija": null, "podkategorija": null, "pozicija": "", "licenca_broj": null, "licenca_vrijedi_do": null, "reprezentativac": false, "kategoriziran": false, "kategorija_hoo": null, "stipendiran": false, "stipendija_iznos": null, "radno_pravni_status": null, "aktivan": true, "datum_pristupa": null, "datum_napustanja": null, "napomena": null, "created_at": "2026-04-30T21:42:11.546487+02:00", "updated_at": "2026-04-30T21:42:11.546487+02:00", "fts": "''mandi\u0107'':2A ''manuelboras'':1A", "slug": null, "slika_url": null, "source": "hns_semafor", "source_id": null, "source_url": "https://semafor.hns.family/klubovi/1574/hnk-lovran/", "source_synced_at": null, "dominantna_noga": null, "visina_cm": null, "tezina_kg": null, "broj_dresa": null, "reprezentacija_kategorija": null, "biografija": null, "mjesto_rodenja": "", "kategorije": [], "promocija_kategorije": [], "sport": "nogomet", "auto_kategorija_calc_at": null, "hoo_kategorija": null, "hoo_kategorija_od": null, "hoo_kategorija_do": null, "hoo_kat_vrijedi_od": null, "hoo_kat_vrijedi_do": null, "hoo_vrijedi_od": null, "hoo_vrijedi_do": null, "klub_naziv_godisnjak": null, "datum_rodjenja": null, "mjesto_rodjenja": null, "broj_dres": null, "vanjski_id": {}, "aktivni_status": "aktivan", "scrape_source": null, "scrape_url": null, "last_scraped_at": null, "godina_rodenja": null, "uloga": "clan", "hns_igrac_id": "436387", "verified": true, "last_updated": "2026-05-02T15:47:51.754652+02:00", "godisnjak_godine": null, "godisnjak_prvi": null, "godisnjak_zadnji": null, "dob_age": null, "savez_kod": null, "grand_metadata": null, "external_id": null, "savez_izvor": null, "profile_url": null, "uloga_detalj": null, "metadata": null}}'::jsonb, 'subagent-A@pgz-sport'); +DELETE FROM pgz_sport.clanovi WHERE id=2600; +COMMIT; +SELECT count(*) FROM pgz_sport.clanovi; +SELECT count(*) FROM pgz_sport.clanovi_purged; diff --git a/_audit/data_integrity_20260505_0836/B_NAME_FIXES.md b/_audit/data_integrity_20260505_0836/B_NAME_FIXES.md new file mode 100644 index 0000000..856a035 --- /dev/null +++ b/_audit/data_integrity_20260505_0836/B_NAME_FIXES.md @@ -0,0 +1,33 @@ +# B — Name Normalization Engine — REPORT +**Run at:** 2026-05-05T08:55:53 +**Table:** `pgz_sport.clanovi` (3240 rows live, backup `clanovi_backup_20260505_0836` 3243 rows untouched) + +## Detection candidates +- Total rows scanned: 3240 +- Candidates flagged: 4 +- Skipped (clean / abbreviation guard): 1 + +## Problem-class counts (per-field hits) +- camelcase: 0 +- allcaps: 4 +- lowercase: 0 +- trim: 1 +- multispace: 0 +- mixedcase: 0 + +## Applied (confidence >= 0.9): **1** +## Review-only (0.5 <= confidence < 0.9): **4** + +## Sample applied (up to 5) +- id=634 field=ime `Zoran ` → `Zoran` conf=1.0 class=trim src=https://hrvatski-bocarski-savez.hr/klubovi/bribir/ + +## Review-only entries +- id=4863 field=ime `PETAR` → `Petar` conf=0.5 class=allcaps evidence=None +- id=4863 field=prezime `MARŠIĆ` → `Maršić` conf=0.5 class=allcaps evidence=None +- id=4904 field=ime `ANDRIJA` → `Andrija` conf=0.5 class=allcaps evidence=None +- id=4904 field=prezime `ZRINSKI` → `Zrinski` conf=0.5 class=allcaps evidence=None + +## Outputs +- Applied JSON: `/opt/pgz-sport/_audit/data_integrity_20260505_0836/B_NAME_FIXES_applied.json` +- Review JSON: `/opt/pgz-sport/_audit/data_integrity_20260505_0836/B_NAME_FIXES_review.json` +- SQL transcript: `/opt/pgz-sport/_audit/data_integrity_20260505_0836/B_sql_transcript.sql` diff --git a/_audit/data_integrity_20260505_0836/B_NAME_FIXES_applied.json b/_audit/data_integrity_20260505_0836/B_NAME_FIXES_applied.json new file mode 100644 index 0000000..2231517 --- /dev/null +++ b/_audit/data_integrity_20260505_0836/B_NAME_FIXES_applied.json @@ -0,0 +1,13 @@ +[ + { + "id": 634, + "field": "ime", + "before": "Zoran ", + "after": "Zoran", + "confidence": 1.0, + "problem_class": "trim", + "source_url": "https://hrvatski-bocarski-savez.hr/klubovi/bribir/", + "hns_igrac_id": null, + "source": "hbs_savez" + } +] \ No newline at end of file diff --git a/_audit/data_integrity_20260505_0836/B_NAME_FIXES_review.json b/_audit/data_integrity_20260505_0836/B_NAME_FIXES_review.json new file mode 100644 index 0000000..c4f066a --- /dev/null +++ b/_audit/data_integrity_20260505_0836/B_NAME_FIXES_review.json @@ -0,0 +1,50 @@ +[ + { + "id": 4863, + "field": "ime", + "before": "PETAR", + "after": "Petar", + "confidence": 0.5, + "problem_class": "allcaps", + "source_url": null, + "hns_igrac_id": null, + "source": "manual", + "evidence_url": null + }, + { + "id": 4863, + "field": "prezime", + "before": "MARŠIĆ", + "after": "Maršić", + "confidence": 0.5, + "problem_class": "allcaps", + "source_url": null, + "hns_igrac_id": null, + "source": "manual", + "evidence_url": null + }, + { + "id": 4904, + "field": "ime", + "before": "ANDRIJA", + "after": "Andrija", + "confidence": 0.5, + "problem_class": "allcaps", + "source_url": null, + "hns_igrac_id": null, + "source": "manual", + "evidence_url": null + }, + { + "id": 4904, + "field": "prezime", + "before": "ZRINSKI", + "after": "Zrinski", + "confidence": 0.5, + "problem_class": "allcaps", + "source_url": null, + "hns_igrac_id": null, + "source": "manual", + "evidence_url": null + } +] \ No newline at end of file diff --git a/_audit/data_integrity_20260505_0836/B_sql_transcript.sql b/_audit/data_integrity_20260505_0836/B_sql_transcript.sql new file mode 100644 index 0000000..b0a8364 --- /dev/null +++ b/_audit/data_integrity_20260505_0836/B_sql_transcript.sql @@ -0,0 +1,4 @@ +BEGIN; +UPDATE pgz_sport.clanovi SET ime='Zoran', updated_at=now() WHERE id=634; +INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload, user_email) VALUES ('CLANOVI_NAME_NORMALIZE', 'clanovi', 634, 'normalized name fields (ime)', '{"id": 634, "changes": {"ime": {"before": "Zoran ", "after": "Zoran", "confidence": 1.0, "problem_class": "trim"}}}'::jsonb, 'subagent-B@pgz-sport'); +COMMIT; diff --git a/_backups/big_fix_20260505_0857/auth_v2.py b/_backups/big_fix_20260505_0857/auth_v2.py new file mode 100644 index 0000000..4af5761 --- /dev/null +++ b/_backups/big_fix_20260505_0857/auth_v2.py @@ -0,0 +1,929 @@ +#!/usr/bin/env python3 +# auth_v2.py — JWT auth backend with tenant_id, role, tier claims +# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04 +# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout, +# /api/auth/me, /api/auth/password/change, /api/auth/password/reset +""" +JWT claims: + sub int user id + email str + name str + tenant_id int|null pgz_sport.tenants.id (or null for super_admin) + tenant_type str pgz | savez | klub | global + tenant_scope dict {"klub_id": ..., "savez_id": ...} + role str user_type code (super_admin | pgz_admin | savez_admin | klub_admin | klub_clan | viewer ...) + tier int 0 = PGŽ, 1 = savez, 2 = klub + jti str token id (revocable via user_sessions) + iat / exp / nbf +""" + +import os, hashlib, secrets, json, time +from datetime import datetime, timedelta, timezone +from typing import Optional, Dict, List, Any + +import jwt as _jwt +import psycopg2, psycopg2.extras +from fastapi import APIRouter, HTTPException, Header, Depends, Request, Body +from pydantic import BaseModel, EmailStr + +try: + from passlib.hash import bcrypt as _bcrypt + HAS_BCRYPT = True +except Exception: + HAS_BCRYPT = False + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', + user='rinet', password='R1net2026!SecureDB#v7') + +# Persistent JWT secret — read from env, else stable file, else generated. +def _load_secret() -> str: + env_secret = os.environ.get("PGZ_JWT_SECRET") + if env_secret and len(env_secret) >= 32: + return env_secret + secret_file = "/opt/pgz-sport/auth/.jwt_secret" + try: + if os.path.exists(secret_file): + with open(secret_file) as f: + s = f.read().strip() + if len(s) >= 32: + return s + s = "rinet-pgz-" + secrets.token_urlsafe(48) + with open(secret_file, "w") as f: + f.write(s) + os.chmod(secret_file, 0o600) + return s + except Exception: + return "rinet-pgz-jwt-2026-fallback-" + hashlib.sha256(b"pgz-sport").hexdigest() + +JWT_SECRET = _load_secret() +JWT_ALG = "HS256" +ACCESS_TTL = timedelta(minutes=int(os.environ.get("PGZ_JWT_ACCESS_MIN", "30"))) +REFRESH_TTL = timedelta(days=int(os.environ.get("PGZ_JWT_REFRESH_DAYS", "7"))) + +router = APIRouter(prefix="/api/auth", tags=["auth_v2"]) + +# ─────────────────────────── DB helpers ─────────────────────────── +def _conn(): + return psycopg2.connect(**DB) + +def db_query(sql: str, params=()): + with _conn() as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, params) + if cur.description: return cur.fetchall() + return [] + +def db_one(sql: str, params=()): + rows = db_query(sql, params) + return rows[0] if rows else None + +def db_exec(sql: str, params=()): + with _conn() as c: + cur = c.cursor() + cur.execute(sql, params) + if cur.description: + r = cur.fetchone() + return r[0] if r else None + c.commit() + +# ─────────────────────────── Password helpers ─────────────────────────── +def _sha256(pw: str) -> str: + return hashlib.sha256(pw.encode()).hexdigest() + +def hash_password(pw: str) -> str: + if HAS_BCRYPT: + return _bcrypt.using(rounds=12).hash(pw) + return _sha256(pw) + +def verify_password(pw: str, hashed: Optional[str]) -> bool: + if not hashed: return False + h = hashed.strip() + if h.startswith("$2") and HAS_BCRYPT: + try: + return _bcrypt.verify(pw, h) + except Exception: + return False + return h == _sha256(pw) + +def needs_rehash(hashed: Optional[str]) -> bool: + if not hashed: return True + return HAS_BCRYPT and not hashed.startswith("$2") + +# ─────────────────────────── Tenant resolution ─────────────────────────── +PGZ_USER_TYPES = {"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz"} +SAVEZ_USER_TYPES = {"savez_admin", "savez_user"} +KLUB_USER_TYPES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"} + +def _tier_for(user_type: str) -> int: + ut = (user_type or "").lower() + if ut in PGZ_USER_TYPES: return 0 + if ut in SAVEZ_USER_TYPES: return 1 + if ut in KLUB_USER_TYPES: return 2 + return 9 # unknown / viewer / guest + +def _resolve_tenant(u: Dict) -> Dict: + """Resolve tenant_id + tenant_type from a user row.""" + ut = (u.get("user_type") or "").lower() + klub_id = u.get("klub_id") + savez_id = u.get("savez_id") + if ut in PGZ_USER_TYPES: + row = db_one("SELECT id, slug, display_name FROM pgz_sport.tenants WHERE slug='pgz' LIMIT 1") + return { + "tenant_id": row["id"] if row else None, + "tenant_type": "pgz", + "tenant_name": row["display_name"] if row else "PGŽ", + "tenant_scope": {"klub_id": None, "savez_id": None}, + } + if ut in SAVEZ_USER_TYPES and savez_id: + return { + "tenant_id": savez_id, + "tenant_type": "savez", + "tenant_name": (db_one("SELECT naziv FROM pgz_sport.savezi WHERE id=%s",(savez_id,)) or {}).get("naziv"), + "tenant_scope": {"klub_id": None, "savez_id": savez_id}, + } + if ut in KLUB_USER_TYPES and klub_id: + return { + "tenant_id": klub_id, + "tenant_type": "klub", + "tenant_name": (db_one("SELECT naziv FROM pgz_sport.klubovi WHERE id=%s",(klub_id,)) or {}).get("naziv"), + "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}, + } + # super_admin without context + if ut == "super_admin": + return {"tenant_id": None, "tenant_type": "global", + "tenant_name": "Global", "tenant_scope": {"klub_id": None, "savez_id": None}} + return {"tenant_id": None, "tenant_type": "viewer", + "tenant_name": None, "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}} + +# ─────────────────────────── JWT issue / verify ─────────────────────────── +def _now() -> datetime: return datetime.now(timezone.utc) + +def _new_jti() -> str: return secrets.token_urlsafe(16) + +def make_access_token(u: Dict, jti: str) -> str: + tenant = _resolve_tenant(u) + tier = _tier_for(u.get("user_type") or "") + now = _now() + payload = { + "sub": str(u["id"]), + "uid": u["id"], + "email": u["email"], + "name": u.get("full_name") or ((u.get("ime") or "") + " " + (u.get("prezime") or "")).strip() or u["email"], + "tenant_id": tenant["tenant_id"], + "tenant_type": tenant["tenant_type"], + "tenant_name": tenant["tenant_name"], + "tenant_scope": tenant["tenant_scope"], + "role": u.get("user_type") or "viewer", + "tier": tier, + "jti": jti, + "typ": "access", + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "exp": int((now + ACCESS_TTL).timestamp()), + } + return _jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) + +def make_refresh_token(uid: int, jti: str) -> str: + now = _now() + return _jwt.encode({ + "sub": str(uid), "uid": uid, "jti": jti, "typ": "refresh", + "iat": int(now.timestamp()), + "exp": int((now + REFRESH_TTL).timestamp()), + }, JWT_SECRET, algorithm=JWT_ALG) + +def decode_token(token: str) -> Dict: + try: + return _jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) + except _jwt.ExpiredSignatureError: + raise HTTPException(401, "Token expired") + except Exception as e: + raise HTTPException(401, f"Invalid token: {e}") + +def _record_session(uid: int, jti: str, expires: datetime, ip: str = None, ua: str = None): + th = hashlib.sha256(jti.encode()).hexdigest() + db_exec("""INSERT INTO pgz_sport.user_sessions + (user_id, token_hash, device_info, ip_address, expires_at, revoked) + VALUES (%s,%s,%s,%s::inet,%s,false) + ON CONFLICT (token_hash) DO NOTHING""", + (uid, th, ua, ip, expires)) + +def _is_revoked(jti: str) -> bool: + th = hashlib.sha256(jti.encode()).hexdigest() + r = db_one("SELECT revoked FROM pgz_sport.user_sessions WHERE token_hash=%s", (th,)) + if not r: return False + return bool(r.get("revoked")) + +def _revoke_jti(jti: str): + th = hashlib.sha256(jti.encode()).hexdigest() + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE token_hash=%s", (th,)) + +# ─────────────────────────── current_user dep ─────────────────────────── +def _extract_token(authorization: Optional[str]) -> Optional[str]: + if not authorization: return None + return authorization.replace("Bearer ", "").strip() or None + +def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]: + token = _extract_token(authorization) + if not token: return None + try: + payload = decode_token(token) + except HTTPException: + return None + if payload.get("typ") not in (None, "access"): + return None + if _is_revoked(payload.get("jti","")): + return None + uid = payload.get("uid") or int(payload.get("sub", 0) or 0) + u = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, status, aktivan, must_change_pwd + FROM pgz_sport.users WHERE id=%s""", (uid,)) + if not u or u.get("status") != "active" or not u.get("aktivan", True): + return None + u["_jwt"] = payload + u["_token"] = token + return u + +def require_user(user = Depends(get_current_user)) -> Dict: + if not user: + raise HTTPException(401, "Authentication required") + return user + +def require_role(roles: List[str]): + def dep(user = Depends(require_user)): + if user.get("user_type") not in roles: + raise HTTPException(403, f"Forbidden — required: {','.join(roles)}") + return user + return dep + +# ─────────────────────────── Audit ─────────────────────────── +def audit(user_id: Optional[int], action: str, resource_type: str = None, + resource_id: int = None, meta: Dict = None, ip: str = None, ua: str = None): + try: + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id, meta, ip_address, user_agent) + VALUES (%s,%s,%s,%s,%s::jsonb,%s::inet,%s)""", + (user_id, action, resource_type, resource_id, + json.dumps(meta or {}), ip, ua)) + except Exception as e: + print(f"[AUDIT WARN] {e}") + +def _client(req: Request): + ip = (req.headers.get("x-forwarded-for") or req.client.host or "").split(",")[0].strip() or None + ua = req.headers.get("user-agent") + return ip, ua + +# ─────────────────────────── Schemas ─────────────────────────── +class LoginReq(BaseModel): + email: str + password: str + totp: Optional[str] = None # 6-digit TOTP if 2FA enabled (or recovery code) + +class RefreshReq(BaseModel): + refresh_token: str + +class ChangePwdReq(BaseModel): + old_password: Optional[str] = None + new_password: str + +class ResetPwdReq(BaseModel): + email: str + +# ─────────────────────────── Rate limiting (R6 #5) ─────────────────────────── +LOCK_THRESHOLD = int(os.environ.get("PGZ_LOGIN_LOCK_THRESHOLD", "5")) +LOCK_MINUTES = int(os.environ.get("PGZ_LOGIN_LOCK_MINUTES", "5")) +IP_THRESHOLD = int(os.environ.get("PGZ_LOGIN_IP_THRESHOLD", "10")) +IP_WINDOW_SEC = int(os.environ.get("PGZ_LOGIN_IP_WINDOW_SEC", "300")) # 5 min + +# In-memory IP throttle: ip → list[float fail timestamps within window] +_ip_fail_log: Dict[str, List[float]] = {} + +def _ip_record_fail(ip: Optional[str]): + if not ip: return + now = time.time() + arr = [t for t in _ip_fail_log.get(ip, []) if now - t < IP_WINDOW_SEC] + arr.append(now) + _ip_fail_log[ip] = arr + +def _ip_blocked(ip: Optional[str]) -> Optional[int]: + """Return seconds-until-unblock, or None if not blocked.""" + if not ip: return None + now = time.time() + arr = [t for t in _ip_fail_log.get(ip, []) if now - t < IP_WINDOW_SEC] + _ip_fail_log[ip] = arr + if len(arr) < IP_THRESHOLD: return None + oldest = min(arr) + return max(1, int(IP_WINDOW_SEC - (now - oldest))) + +def _ip_clear(ip: Optional[str]): + if ip and ip in _ip_fail_log: + _ip_fail_log.pop(ip, None) + +# ─────────────────────────── Endpoints ─────────────────────────── +@router.post("/login") +def login(req: LoginReq, request: Request): + ip, ua = _client(request) + email = (req.email or "").lower().strip() + if not email or not req.password: + raise HTTPException(400, "Email i lozinka obavezni") + + # R6 #5: per-IP throttle (stops brute-force across many emails) + blocked_for = _ip_blocked(ip) + if blocked_for: + audit(None, "login.ratelimit.ip", + meta={"email": email, "ip": ip, "block_seconds": blocked_for}, + ip=ip, ua=ua) + raise HTTPException(429, f"Previše pokušaja s ove IP adrese — pokušajte za {blocked_for}s") + + u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status, + user_type, klub_id, savez_id, aktivan, must_change_pwd, + failed_login_count, locked_until + FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,)) + if not u: + _ip_record_fail(ip) + audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua) + raise HTTPException(401, "Neispravni podaci") + if u.get("locked_until"): + lu = u["locked_until"] + if lu.tzinfo is None: lu = lu.replace(tzinfo=timezone.utc) + if lu > _now(): + audit(u["id"], "login.locked", ip=ip, ua=ua) + raise HTTPException(423, "Račun privremeno zaključan") + if u.get("status") != "active" or not u.get("aktivan", True): + audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua) + raise HTTPException(403, "Račun nije aktivan") + if not verify_password(req.password, u.get("password_hash")): + # R6 #5: 5 fails → 5-minute lockout + new_fails = (u.get("failed_login_count") or 0) + 1 + will_lock = new_fails >= LOCK_THRESHOLD + db_exec("""UPDATE pgz_sport.users + SET failed_login_count = %s, + locked_until = CASE WHEN %s + THEN now() + (interval '1 minute' * %s) + ELSE locked_until END + WHERE id=%s""", + (new_fails, will_lock, LOCK_MINUTES, u["id"])) + _ip_record_fail(ip) + audit(u["id"], "login.fail", + meta={"reason":"bad_password", "fails": new_fails, + "locked": bool(will_lock), + "lock_minutes": LOCK_MINUTES if will_lock else 0}, + ip=ip, ua=ua) + raise HTTPException(401, + f"Neispravni podaci ({new_fails}/{LOCK_THRESHOLD})" + + (f" — račun je zaključan na {LOCK_MINUTES} minuta" if will_lock else "")) + + # opportunistic rehash to bcrypt + if needs_rehash(u.get("password_hash")): + try: + db_exec("UPDATE pgz_sport.users SET password_hash=%s WHERE id=%s", + (hash_password(req.password), u["id"])) + except Exception: pass + + # 2FA gate — if user has enabled 2FA, demand a valid TOTP / recovery code + twofa_row = None + try: + twofa_row = db_one("SELECT secret, enabled, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s", + (u["id"],)) + except Exception: pass + if twofa_row and twofa_row.get("enabled"): + code = (req.totp or "").strip().replace(" ", "") + if not code: + audit(u["id"], "login.2fa_required", ip=ip, ua=ua) + raise HTTPException(401, "2FA_REQUIRED") + ok = False + if code.isdigit() and len(code) in (6, 8) and HAS_PYOTP: + ok = _pyotp.TOTP(twofa_row["secret"]).verify(code, valid_window=1) + if not ok and twofa_row.get("recovery_codes"): + up = code.upper() + if up in (twofa_row["recovery_codes"] or []): + ok = True + # consume the recovery code so it can't be reused + remaining = [c for c in twofa_row["recovery_codes"] if c != up] + db_exec("UPDATE pgz_sport.user_2fa SET recovery_codes=%s, updated_at=now() WHERE user_id=%s", + (remaining, u["id"])) + if not ok: + audit(u["id"], "login.2fa_fail", ip=ip, ua=ua) + raise HTTPException(401, "Neispravan 2FA kod") + + db_exec("""UPDATE pgz_sport.users + SET failed_login_count=0, locked_until=NULL, last_login=now() + WHERE id=%s""", (u["id"],)) + _ip_clear(ip) # successful login clears IP throttle + + jti = _new_jti() + rjti = _new_jti() + access = make_access_token(u, jti) + refresh = make_refresh_token(u["id"], rjti) + _record_session(u["id"], jti, _now() + ACCESS_TTL, ip=ip, ua=ua) + _record_session(u["id"], rjti, _now() + REFRESH_TTL, ip=ip, ua=(ua or "") + " [refresh]") + audit(u["id"], "login.ok", ip=ip, ua=ua) + + tenant = _resolve_tenant(u) + return { + "access_token": access, + "refresh_token": refresh, + "token_type": "Bearer", + "expires_in": int(ACCESS_TTL.total_seconds()), + "user": { + "id": u["id"], "email": u["email"], + "full_name": u.get("full_name") or (u.get("ime","") + " " + u.get("prezime","")).strip(), + "role": u.get("user_type"), "tier": _tier_for(u.get("user_type") or ""), + "must_change_pwd": bool(u.get("must_change_pwd")), + **tenant, + }, + } + +@router.post("/refresh") +def refresh(req: RefreshReq, request: Request): + payload = decode_token(req.refresh_token) + if payload.get("typ") != "refresh": + raise HTTPException(401, "Invalid refresh token") + if _is_revoked(payload.get("jti","")): + raise HTTPException(401, "Refresh token revoked") + uid = payload.get("uid") or int(payload.get("sub", 0) or 0) + u = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, status, aktivan, must_change_pwd + FROM pgz_sport.users WHERE id=%s""", (uid,)) + if not u or u.get("status") != "active" or not u.get("aktivan", True): + raise HTTPException(401, "User inactive") + ip, ua = _client(request) + new_jti = _new_jti() + access = make_access_token(u, new_jti) + _record_session(u["id"], new_jti, _now() + ACCESS_TTL, ip=ip, ua=ua) + audit(u["id"], "auth.refresh", ip=ip, ua=ua) + return {"access_token": access, "token_type": "Bearer", + "expires_in": int(ACCESS_TTL.total_seconds())} + +@router.post("/logout") +def logout(request: Request, user = Depends(require_user)): + jti = (user.get("_jwt") or {}).get("jti") + if jti: _revoke_jti(jti) + # Also revoke refresh tokens for this user (best-effort) + db_exec("""UPDATE pgz_sport.user_sessions SET revoked=true + WHERE user_id=%s AND device_info LIKE %s""", + (user["id"], "%[refresh]%")) + ip, ua = _client(request) + audit(user["id"], "logout", ip=ip, ua=ua) + return {"status": "ok"} + +@router.get("/me") +def me(user = Depends(require_user)): + enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, must_change_pwd, aktivan, status, + last_login, oib, telefon, phone, preferred_language, created_at, + avatar_url, gdpr_consent_at, google_picture + FROM pgz_sport.users WHERE id=%s""", (user["id"],)) + if not enriched: + raise HTTPException(404, "User not found") + tenant = _resolve_tenant(enriched) + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (user["id"],)) + try: + twofa = db_one("SELECT secret IS NOT NULL AS enabled FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) or {"enabled": False} + except Exception: + twofa = {"enabled": False} + return {**enriched, + "tier": _tier_for(enriched.get("user_type") or ""), + "must_change_pwd": bool(enriched.get("must_change_pwd")), + "two_factor_enabled": bool(twofa.get("enabled")), + **tenant, "roles": roles} + +class UpdateMeReq(BaseModel): + ime: Optional[str] = None + prezime: Optional[str] = None + full_name: Optional[str] = None + telefon: Optional[str] = None + phone: Optional[str] = None + preferred_language: Optional[str] = None + oib: Optional[str] = None + +@router.put("/me") +def update_me(req: UpdateMeReq, request: Request, user = Depends(require_user)): + fields = [] + vals: List[Any] = [] + for k in ("ime","prezime","full_name","telefon","phone","preferred_language","oib"): + v = getattr(req, k) + if v is not None: + fields.append(f"{k}=%s") + vals.append(v.strip() if isinstance(v, str) else v) + if not fields: + raise HTTPException(400, "Nema polja za ažuriranje") + vals.append(user["id"]) + db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)}, updated_at=now() WHERE id=%s", tuple(vals)) + ip, ua = _client(request) + audit(user["id"], "profile.update", meta={"fields": [f.split("=")[0] for f in fields]}, ip=ip, ua=ua) + return me(user) + +# ─────────────────────────── AVATAR UPLOAD ─────────────────────────── +import shutil, pathlib +from fastapi import UploadFile, File + +UPLOAD_ROOT = pathlib.Path("/opt/pgz-sport/uploads") +AVATAR_DIR = UPLOAD_ROOT / "avatars" +AVATAR_DIR.mkdir(parents=True, exist_ok=True) +ALLOWED_AVATAR_MIME = {"image/jpeg","image/jpg","image/png","image/webp"} +ALLOWED_AVATAR_EXT = {".jpg",".jpeg",".png",".webp"} +MAX_AVATAR_BYTES = 5 * 1024 * 1024 # 5 MB + +@router.post("/me/avatar") +async def upload_my_avatar(request: Request, file: UploadFile = File(...), user = Depends(require_user)): + ct = (file.content_type or "").lower() + if ct not in ALLOWED_AVATAR_MIME: + raise HTTPException(400, f"Nedozvoljen tip slike: {ct} — jpeg/png/webp") + ext = pathlib.Path(file.filename or "").suffix.lower() + if ext not in ALLOWED_AVATAR_EXT: + ext = {"image/jpeg":".jpg","image/jpg":".jpg","image/png":".png","image/webp":".webp"}.get(ct, ".jpg") + data = await file.read() + if len(data) > MAX_AVATAR_BYTES: + raise HTTPException(413, f"Slika prevelika ({len(data)} B > {MAX_AVATAR_BYTES})") + if len(data) < 32: + raise HTTPException(400, "Slika prazna ili neispravna") + safe_name = f"{int(user['id'])}_{int(time.time())}{ext}" + target = AVATAR_DIR / safe_name + with open(target, "wb") as f: + f.write(data) + try: os.chmod(target, 0o644) + except Exception: pass + avatar_url = f"/uploads/avatars/{safe_name}" + db_exec("UPDATE pgz_sport.users SET avatar_url=%s, updated_at=now() WHERE id=%s", + (avatar_url, user["id"])) + ip, ua = _client(request) + audit(user["id"], "profile.avatar_upload", + meta={"file": safe_name, "size": len(data), "mime": ct}, ip=ip, ua=ua) + return {"status":"ok", "avatar_url": avatar_url, "size": len(data), "mime": ct} + +@router.delete("/me/avatar") +def delete_my_avatar(request: Request, user = Depends(require_user)): + cur = db_one("SELECT avatar_url FROM pgz_sport.users WHERE id=%s", (user["id"],)) + if cur and cur.get("avatar_url"): + p = AVATAR_DIR / pathlib.Path(cur["avatar_url"]).name + try: + if p.exists() and p.is_relative_to(AVATAR_DIR): p.unlink() + except Exception: pass + db_exec("UPDATE pgz_sport.users SET avatar_url=NULL, updated_at=now() WHERE id=%s", (user["id"],)) + ip, ua = _client(request) + audit(user["id"], "profile.avatar_delete", ip=ip, ua=ua) + return {"status": "ok"} + +@router.post("/password/change") +def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)): + if len(req.new_password) < 8: + raise HTTPException(400, "Lozinka mora imati barem 8 znakova") + cur = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s", + (user["id"],)) + if not cur: raise HTTPException(404, "User not found") + if not cur.get("must_change_pwd"): + if not req.old_password: + raise HTTPException(400, "old_password obavezan") + if not verify_password(req.old_password, cur.get("password_hash")): + raise HTTPException(401, "Stara lozinka netočna") + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, updated_at=now() + WHERE id=%s""", (hash_password(req.new_password), user["id"])) + ip, ua = _client(request) + audit(user["id"], "password.change", ip=ip, ua=ua) + return {"status": "ok"} + +@router.post("/password/reset") +def password_reset(req: ResetPwdReq, request: Request): + """Issue a temporary password (admin-equivalent self-reset; logged).""" + email = (req.email or "").lower().strip() + u = db_one("SELECT id, email, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", + (email,)) + ip, ua = _client(request) + audit(u["id"] if u else None, "password.reset.request", + meta={"email": email, "found": bool(u)}, ip=ip, ua=ua) + # Generic response — do not leak which emails exist + return {"status": "ok", + "message": "Ako račun postoji, administrator će vam poslati instrukcije."} + +# ─────────────────────────── R5 #2+#3: invite & reset tokens ─────────────────────────── +def _ensure_token_table(): + db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_action_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES pgz_sport.users(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- 'invite' | 'reset' + created_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_by INTEGER REFERENCES pgz_sport.users(id), + ip TEXT, + meta JSONB + )""") + db_exec("""CREATE INDEX IF NOT EXISTS idx_action_tokens_user + ON pgz_sport.user_action_tokens (user_id, kind, used_at)""") +_ensure_token_table() + +INVITE_TTL = timedelta(days=int(os.environ.get("PGZ_INVITE_TTL_DAYS", "7"))) +RESET_TTL = timedelta(hours=int(os.environ.get("PGZ_RESET_TTL_HOURS", "2"))) + +def _make_action_token() -> str: + return secrets.token_urlsafe(32) + +def _hash_action_token(t: str) -> str: + return hashlib.sha256(t.encode()).hexdigest() + +def issue_action_token(user_id: int, kind: str, ttl: timedelta, + created_by: Optional[int] = None, + ip: Optional[str] = None, + meta: Optional[Dict] = None) -> str: + """Create a one-time URL-safe token; only its sha256 is persisted.""" + if kind not in ("invite", "reset"): + raise ValueError("kind must be invite|reset") + # Invalidate any prior unused tokens of same kind for this user + db_exec("""UPDATE pgz_sport.user_action_tokens SET used_at=now() + WHERE user_id=%s AND kind=%s AND used_at IS NULL""", + (user_id, kind)) + raw = _make_action_token() + th = _hash_action_token(raw) + db_exec("""INSERT INTO pgz_sport.user_action_tokens + (token_hash, user_id, kind, expires_at, created_by, ip, meta) + VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)""", + (th, user_id, kind, _now() + ttl, created_by, ip, json.dumps(meta or {}))) + return raw + +def consume_action_token(raw: str, kind: str) -> Optional[Dict]: + """Validate (kind/expiry/unused) and atomically mark used_at. Returns row dict if OK.""" + th = _hash_action_token(raw) + row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, t.kind, t.meta, + u.email, u.aktivan, u.status + FROM pgz_sport.user_action_tokens t + JOIN pgz_sport.users u ON u.id = t.user_id + WHERE t.token_hash=%s AND t.kind=%s""", (th, kind)) + if not row: return None + if row["used_at"] is not None: return None + exp = row["expires_at"] + if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) + if exp <= _now(): return None + db_exec("UPDATE pgz_sport.user_action_tokens SET used_at=now() WHERE token_hash=%s", (th,)) + return row + +def _build_link(path: str, token: str) -> str: + base = os.environ.get("PGZ_PUBLIC_BASE", "https://api.rinet.one/sport") + sep = '&' if '?' in path else '?' + return f"{base}{path}{sep}token={token}" + +# ─────────────────────────── /auth/forgot-password ─────────────────────────── +class ForgotPwdReq(BaseModel): + email: str + +@router.post("/forgot-password") +def forgot_password(req: ForgotPwdReq, request: Request): + """Always returns a generic message — never leaks which emails exist. + Issues a reset token only if the user exists and is active, then + sends a (mock) e-mail with the reset link.""" + email = (req.email or "").lower().strip() + ip, ua = _client(request) + u = db_one("SELECT id, email, aktivan, status FROM pgz_sport.users WHERE LOWER(email)=%s", + (email,)) + token = None + mail_result = None + if u and u.get("aktivan") and u.get("status") == "active": + token = issue_action_token(u["id"], "reset", RESET_TTL, ip=ip, + meta={"email": email}) + reset_link = _build_link("/static/login.html?reset=1", token) + try: + from .mailer import send_password_reset + mail_result = send_password_reset(email, reset_link, + int(RESET_TTL.total_seconds())) + except Exception as e: + print(f"[forgot_password mail WARN] {e}") + audit(u["id"], "password.forgot.issue", + meta={"email": email, + "ttl_hours": RESET_TTL.total_seconds()/3600, + "mail_sent": bool(mail_result and mail_result.get("sent")), + "mail_mock": bool(mail_result and mail_result.get("mock"))}, + ip=ip, ua=ua) + else: + audit(u["id"] if u else None, "password.forgot.miss", + meta={"email": email}, ip=ip, ua=ua) + # Generic response — do not leak account existence + resp = {"status": "ok", + "message": "Ako račun postoji, poslan je e-mail s linkom za promjenu lozinke."} + # Reveal link only on localhost or with explicit env flag (debugging). + # Real users get it via e-mail. + if token and (os.environ.get("PGZ_REVEAL_RESET_TOKEN") == "1" or + (request.client.host in ("127.0.0.1", "::1"))): + resp["reset_link"] = _build_link("/static/login.html?reset=1", token) + resp["expires_in_seconds"] = int(RESET_TTL.total_seconds()) + resp["mail_mock"] = bool(mail_result and mail_result.get("mock")) + return resp + +class ResetTokenReq(BaseModel): + token: str + new_password: str + +@router.post("/reset-password") +def reset_password_with_token(req: ResetTokenReq, request: Request): + """Consume a reset token and set a new password.""" + if len(req.new_password or "") < 8: + raise HTTPException(400, "Lozinka mora imati barem 8 znakova") + row = consume_action_token(req.token, "reset") + ip, ua = _client(request) + if not row: + audit(None, "password.reset.fail", + meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua) + raise HTTPException(400, "Token je nevažeći ili istekao") + if not row.get("aktivan") or row.get("status") != "active": + audit(row["user_id"], "password.reset.fail", + meta={"reason": "user_inactive"}, ip=ip, ua=ua) + raise HTTPException(403, "Račun nije aktivan") + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, + failed_login_count=0, locked_until=NULL, updated_at=now() + WHERE id=%s""", (hash_password(req.new_password), row["user_id"])) + # Revoke all active sessions for safety + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", + (row["user_id"],)) + audit(row["user_id"], "password.reset.ok", ip=ip, ua=ua) + return {"status": "ok", "email": row["email"]} + +@router.get("/reset-password") +def reset_password_check(token: str, request: Request): + """Pre-flight: validate that the token exists and isn't expired/used. + Does NOT consume the token.""" + th = _hash_action_token(token) + row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email + FROM pgz_sport.user_action_tokens t + JOIN pgz_sport.users u ON u.id = t.user_id + WHERE t.token_hash=%s AND t.kind='reset'""", (th,)) + if not row: + raise HTTPException(404, "Token nije pronađen") + if row["used_at"] is not None: + raise HTTPException(410, "Token je već iskorišten") + exp = row["expires_at"] + if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) + if exp <= _now(): + raise HTTPException(410, "Token je istekao") + return {"status": "ok", "email": row["email"], "expires_at": row["expires_at"].isoformat()} + +# ─────────────────────────── /auth/setup-password (invite) ─────────────────────────── +class SetupPwdReq(BaseModel): + token: str + new_password: str + +@router.get("/setup-password") +def setup_password_check(token: str, request: Request): + """Pre-flight: validate an invite token without consuming it.""" + th = _hash_action_token(token) + row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email, u.full_name, u.user_type + FROM pgz_sport.user_action_tokens t + JOIN pgz_sport.users u ON u.id = t.user_id + WHERE t.token_hash=%s AND t.kind='invite'""", (th,)) + if not row: + raise HTTPException(404, "Pozivnica nije pronađena") + if row["used_at"] is not None: + raise HTTPException(410, "Pozivnica je već iskorištena") + exp = row["expires_at"] + if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) + if exp <= _now(): + raise HTTPException(410, "Pozivnica je istekla") + return {"status": "ok", + "email": row["email"], + "full_name": row["full_name"], + "user_type": row["user_type"], + "expires_at": row["expires_at"].isoformat()} + +@router.post("/setup-password") +def setup_password_consume(req: SetupPwdReq, request: Request): + """Consume an invite token and set the user's first password.""" + if len(req.new_password or "") < 8: + raise HTTPException(400, "Lozinka mora imati barem 8 znakova") + row = consume_action_token(req.token, "invite") + ip, ua = _client(request) + if not row: + audit(None, "invite.consume.fail", + meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua) + raise HTTPException(400, "Pozivnica je nevažeća ili istekla") + if not row.get("aktivan") or row.get("status") != "active": + audit(row["user_id"], "invite.consume.fail", + meta={"reason": "user_inactive"}, ip=ip, ua=ua) + raise HTTPException(403, "Račun nije aktivan") + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, + email_verified=true, + failed_login_count=0, locked_until=NULL, updated_at=now() + WHERE id=%s""", (hash_password(req.new_password), row["user_id"])) + audit(row["user_id"], "invite.consume.ok", + meta={"email": row["email"]}, ip=ip, ua=ua) + return {"status": "ok", "email": row["email"]} + +# ─────────────────────────── 2FA — real TOTP (RFC 6238) ─────────────────────────── +try: + import pyotp as _pyotp + HAS_PYOTP = True +except Exception: + HAS_PYOTP = False + +def _ensure_2fa_table(): + db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_2fa ( + user_id INTEGER PRIMARY KEY REFERENCES pgz_sport.users(id) ON DELETE CASCADE, + secret TEXT NOT NULL, + enabled BOOLEAN DEFAULT false, + verified_at TIMESTAMPTZ, + recovery_codes TEXT[], + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + )""") +_ensure_2fa_table() + +def _build_qr_png(otpauth_url: str) -> str: + """Return a data: URL containing a base64 PNG of the QR code.""" + try: + import qrcode, io, base64 + img = qrcode.make(otpauth_url) + buf = io.BytesIO() + img.save(buf, format="PNG") + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + except Exception as e: + return "" + +def _gen_recovery_codes(n: int = 8) -> List[str]: + return [secrets.token_hex(4).upper() for _ in range(n)] + +@router.post("/2fa/setup") +def twofa_setup(user = Depends(require_user)): + """Generate a TOTP secret, store unverified, and return otpauth URL + QR + recovery codes. + The 2FA stays disabled until /2fa/verify confirms a valid TOTP code.""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + secret = _pyotp.random_base32() # 32-char base32, RFC 4648 — what authenticator apps expect + recovery = _gen_recovery_codes() + db_exec("""INSERT INTO pgz_sport.user_2fa (user_id, secret, enabled, recovery_codes, updated_at) + VALUES (%s,%s,false,%s,now()) + ON CONFLICT (user_id) DO UPDATE SET + secret=EXCLUDED.secret, enabled=false, + recovery_codes=EXCLUDED.recovery_codes, updated_at=now()""", + (user["id"], secret, recovery)) + issuer = "PGŽ Sport" + otpauth = _pyotp.TOTP(secret).provisioning_uri(name=user["email"], issuer_name=issuer) + return { + "secret": secret, + "otpauth_url": otpauth, + "qr_png": _build_qr_png(otpauth), + "issuer": issuer, + "account": user["email"], + "recovery_codes": recovery, + "enabled": False, + "instructions": "Skenirajte QR u Google Authenticator / Authy / 1Password, zatim potvrdite kod kroz POST /api/auth/2fa/verify", + } + +class TwoFAVerifyReq(BaseModel): + code: str + +@router.post("/2fa/verify") +def twofa_verify(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)): + """Verify TOTP code; on success, mark 2FA enabled.""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + row = db_one("SELECT secret, enabled FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + if not row: + raise HTTPException(400, "2FA nije postavljen — pozovite /2fa/setup prvo") + code = (req.code or "").strip().replace(" ", "") + if not code or not code.isdigit() or len(code) not in (6, 8): + raise HTTPException(400, "Neispravan format koda (6-8 znamenki)") + totp = _pyotp.TOTP(row["secret"]) + # valid_window=1 → tolerate ±30s drift + if not totp.verify(code, valid_window=1): + ip, ua = _client(request) + audit(user["id"], "2fa.verify.fail", ip=ip, ua=ua) + raise HTTPException(401, "Neispravan TOTP kod") + db_exec("""UPDATE pgz_sport.user_2fa + SET enabled=true, verified_at=now(), updated_at=now() + WHERE user_id=%s""", (user["id"],)) + ip, ua = _client(request) + audit(user["id"], "2fa.verify.ok", ip=ip, ua=ua) + return {"status": "ok", "enabled": True} + +@router.post("/2fa/disable") +def twofa_disable(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)): + """Disable 2FA — must verify a current TOTP code (or recovery code).""" + if not HAS_PYOTP: + raise HTTPException(503, "pyotp not installed on server") + row = db_one("SELECT secret, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + if not row: + raise HTTPException(404, "2FA nije postavljen") + code = (req.code or "").strip().replace(" ", "").upper() + valid = False + if code.isdigit() and len(code) in (6, 8): + valid = _pyotp.TOTP(row["secret"]).verify(code, valid_window=1) + elif row.get("recovery_codes") and code in (row["recovery_codes"] or []): + valid = True + if not valid: + raise HTTPException(401, "Neispravan kod") + db_exec("DELETE FROM pgz_sport.user_2fa WHERE user_id=%s", (user["id"],)) + ip, ua = _client(request) + audit(user["id"], "2fa.disable", ip=ip, ua=ua) + return {"status": "ok", "enabled": False} + +@router.get("/2fa/status") +def twofa_status(user = Depends(require_user)): + row = db_one("SELECT enabled, verified_at, created_at FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) + return {"enabled": bool(row and row.get("enabled")), + "configured": bool(row), + "verified_at": row.get("verified_at") if row else None} diff --git a/_backups/r3_cc4/pgz_sport_v2_router.py.pre_dokfix.1777963744 b/_backups/r3_cc4/pgz_sport_v2_router.py.pre_dokfix.1777963744 new file mode 100644 index 0000000..e764f13 --- /dev/null +++ b/_backups/r3_cc4/pgz_sport_v2_router.py.pre_dokfix.1777963744 @@ -0,0 +1,5424 @@ +#!/usr/bin/env python3 +""" +pgz_sport_extended_api.py - Multi-tenant + ERP/CRM extension for /api/v1/* +Author: Damir Radulić (damir@rinet.one) +Date: 28.04.2026 +Port: 8095 (mounted under /api/v2/) +Endpoints: auth, users, roles, klub-access, invoices, forms, alerts, expense reports, RAG sport agent +""" +from fastapi import APIRouter, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from datetime import date, datetime, timedelta +import psycopg2, psycopg2.extras +import hashlib, secrets, json, requests, os, re, time + +DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') +QDRANT = "http://10.10.0.2:6333" +EMBED = "http://localhost:9879/api/embeddings" +COLL = "pgz_sport_v1" + +router = APIRouter(prefix="/api/v2", tags=["pgz_sport_v2"]) + +# ---------------- DB helpers ---------------- +def db_query(sql: str, params=()): + with psycopg2.connect(**DB) as c: + cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute(sql, params) + if cur.description: return cur.fetchall() + return [] + +def db_one(sql: str, params=()): + rows = db_query(sql, params) + return rows[0] if rows else None + +def db_exec(sql: str, params=()): + with psycopg2.connect(**DB) as c: + cur = c.cursor() + cur.execute(sql, params) + if cur.description: + r = cur.fetchone() + return r[0] if r else None + c.commit() + +# ---------------- Auth helpers ---------------- +def hash_pw(pw: str) -> str: + return hashlib.sha256(pw.encode()).hexdigest() + +def make_token() -> str: + return secrets.token_urlsafe(32) + +def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]: + if not authorization: return None + token = authorization.replace('Bearer ','').strip() + th = hashlib.sha256(token.encode()).hexdigest() + s = db_one("""SELECT s.user_id, u.email, u.full_name, u.status, + u.user_type, u.klub_id, u.savez_id, u.aktivan, u.ime, u.prezime, + u.must_change_pwd + FROM pgz_sport.user_sessions s + JOIN pgz_sport.users u ON u.id=s.user_id + WHERE s.token_hash=%s AND s.revoked=false AND s.expires_at>now()""", (th,)) + if not s or not s.get('aktivan', True): return None + s['_token'] = token + return s + +# ═══ RBAC HELPERS ═══ +def is_super(user) -> bool: + return user and user.get('user_type') == 'super_admin' + +def is_pgz_admin(user) -> bool: + return user and user.get('user_type') in ('super_admin', 'pgz_admin') + +def can_manage_users(user) -> bool: + return is_pgz_admin(user) + +def require_role(user, allowed: list): + if not user or user.get('user_type') not in allowed: + raise HTTPException(403, f"Forbidden — required role: {','.join(allowed)}") + +def tenant_filter_users(user) -> tuple: + """Returns (sql_where, params) tuple to scope users list to the user's tenant.""" + ut = user.get('user_type') + if ut == 'super_admin' or ut == 'pgz_admin' or ut == 'pgz_user' or ut == 'pgz_finance' or ut == 'pgz_zzjz': + return ("", []) + if ut == 'savez_admin' or ut == 'savez_user': + sid = user.get('savez_id') + if not sid: return ("AND 1=0", []) + return ("AND (savez_id=%s OR id IN (SELECT user_id FROM pgz_sport.user_klub_links WHERE savez_id=%s))", [sid, sid]) + if ut == 'klub_admin' or ut == 'klub_user': + kid = user.get('klub_id') + if not kid: return ("AND 1=0", []) + return ("AND (klub_id=%s OR id IN (SELECT user_id FROM pgz_sport.user_klub_links WHERE klub_id=%s))", [kid, kid]) + # klub_clan, guest, viewer → only self + return ("AND id=%s", [user['user_id']]) + +def require_user(user = Depends(get_current_user)): + if not user: raise HTTPException(401, "Authentication required") + return user + +def user_has_role(user_id: int, role_code: str, scope_type: str = None, scope_id: int = None) -> bool: + sql = """SELECT 1 FROM pgz_sport.user_roles ur + JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND r.code=%s AND ur.active=true + AND (ur.expires_at IS NULL OR ur.expires_at>now())""" + args = [user_id, role_code] + if scope_type: + sql += " AND (ur.scope_type=%s OR ur.scope_type='global')" + args.append(scope_type) + if scope_id: + sql += " AND (ur.scope_id=%s OR ur.scope_id IS NULL)" + args.append(scope_id) + return bool(db_one(sql, tuple(args))) + +# ============== AUTH ENDPOINTS ============== +class LoginReq(BaseModel): + email: str + password: str + +@router.post("/auth/login") +def login(req: LoginReq): + u = db_one("""SELECT id, email, full_name, password_hash, status, + ime, prezime, user_type, klub_id, savez_id, must_change_pwd, aktivan, + locked_until, failed_login_count + FROM pgz_sport.users WHERE email=%s""", (req.email.lower().strip(),)) + if not u or u['status'] != 'active' or not u.get('aktivan', True): + raise HTTPException(401, "Invalid credentials") + if u.get('locked_until') and u['locked_until'].tzinfo is not None: + from datetime import timezone + if u['locked_until'] > datetime.now(timezone.utc): + raise HTTPException(423, "Korisnik je privremeno zaključan") + if not u['password_hash'] or u['password_hash'] != hash_pw(req.password): + # bump failed counter, lock after 5 + db_exec("""UPDATE pgz_sport.users SET failed_login_count=COALESCE(failed_login_count,0)+1, + locked_until=CASE WHEN COALESCE(failed_login_count,0)+1>=5 THEN now()+interval '15 minutes' ELSE locked_until END + WHERE id=%s""", (u['id'],)) + raise HTTPException(401, "Invalid credentials") + db_exec("UPDATE pgz_sport.users SET failed_login_count=0, locked_until=NULL WHERE id=%s", (u['id'],)) + + token = make_token() + th = hashlib.sha256(token.encode()).hexdigest() + expires = datetime.now() + timedelta(days=30) + db_exec("""INSERT INTO pgz_sport.user_sessions (user_id, token_hash, expires_at) + VALUES (%s,%s,%s)""", (u['id'], th, expires)) + db_exec("UPDATE pgz_sport.users SET last_login=now() WHERE id=%s", (u['id'],)) + db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,'login')""", (u['id'],)) + + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (u['id'],)) + + return { + "token": token, + "expires_at": expires.isoformat(), + "user": { + "id": u['id'], "email": u['email'], "full_name": u['full_name'], + "ime": u.get('ime'), "prezime": u.get('prezime'), + "user_type": u.get('user_type'), "klub_id": u.get('klub_id'), "savez_id": u.get('savez_id'), + "must_change_pwd": bool(u.get('must_change_pwd')), + "roles": roles + } + } + +@router.post("/auth/logout") +def logout(user = Depends(require_user)): + th = hashlib.sha256(user.get('_token','').encode()).hexdigest() + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (user['user_id'],)) + return {"status":"ok"} + +@router.get("/auth/me") +def me(user = Depends(require_user)): + enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, must_change_pwd, aktivan, status, + last_login, oib, telefon, phone, preferred_language + FROM pgz_sport.users WHERE id=%s""", (user['user_id'],)) + if not enriched: + raise HTTPException(404, "User not found") + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (user['user_id'],)) + klubovi = db_query("""SELECT k.id, k.naziv, ukl.link_type, ukl.primary_klub, ukl.role + FROM pgz_sport.user_klub_links ukl JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id + WHERE ukl.user_id=%s AND (ukl.do_datuma IS NULL OR ukl.do_datuma>now()::date)""", + (user['user_id'],)) + savezi = db_query("""SELECT s.id, s.naziv, ukl.role + FROM pgz_sport.user_klub_links ukl JOIN pgz_sport.savezi s ON s.id=ukl.savez_id + WHERE ukl.user_id=%s AND ukl.savez_id IS NOT NULL""", + (user['user_id'],)) + return {**user, **enriched, "must_change_pwd": bool(enriched.get('must_change_pwd')), + "roles": roles, "klubovi": klubovi, "savezi": savezi} + +class ChangePwdReq(BaseModel): + new_password: str + old_password: Optional[str] = None + +@router.post("/auth/change-password") +def change_password(req: ChangePwdReq, user = Depends(require_user)): + if len(req.new_password) < 8: + raise HTTPException(400, "Password mora imati barem 8 znakova") + u = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s", (user['user_id'],)) + if not u: raise HTTPException(404, "User not found") + # If not in must_change_pwd flow, require old password + if not u.get('must_change_pwd'): + if not req.old_password: + raise HTTPException(400, "old_password required") + if u['password_hash'] != hash_pw(req.old_password): + raise HTTPException(401, "Stara lozinka netočna") + new_hash = hash_pw(req.new_password) + db_exec("""UPDATE pgz_sport.users + SET password_hash=%s, must_change_pwd=false, updated_at=now() + WHERE id=%s""", (new_hash, user['user_id'])) + db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,'password.change')""", + (user['user_id'],)) + return {"status":"ok"} + + + +# ═══ ADMIN USER MGMT — extended ═══ + +@router.get("/users/list") +def list_users_v2( + q: Optional[str] = None, + user_type: Optional[str] = None, + klub_id: Optional[int] = None, + savez_id: Optional[int] = None, + aktivan: Optional[bool] = None, + limit: int = 100, + offset: int = 0, + user = Depends(require_user) +): + """List users with tenant filter applied automatically based on caller's role.""" + where = ["1=1"] + args = [] + tf, tp = tenant_filter_users(user) + if tf: + where.append(tf.replace('AND ', '')) + args.extend(tp) + if q: + where.append("(LOWER(email) LIKE %s OR LOWER(ime) LIKE %s OR LOWER(prezime) LIKE %s OR LOWER(full_name) LIKE %s)") + args.extend([f"%{q.lower()}%"]*4) + if user_type: where.append("user_type=%s"); args.append(user_type) + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if savez_id: where.append("savez_id=%s"); args.append(savez_id) + if aktivan is not None: where.append("aktivan=%s"); args.append(aktivan) + + sql = f"""SELECT id, email, ime, prezime, full_name, user_type, klub_id, savez_id, + aktivan, must_change_pwd, last_login, locked_until, failed_login_count, + telefon, oib, status, created_at + FROM pgz_sport.users WHERE {" AND ".join(where)} + ORDER BY id LIMIT %s OFFSET %s""" + args.extend([limit, offset]) + rows = db_query(sql, tuple(args)) + + cnt_sql = f"SELECT COUNT(*) AS c FROM pgz_sport.users WHERE {" AND ".join(where)}" + total = db_one(cnt_sql, tuple(args[:-2]))['c'] + + return {"count": len(rows), "total": total, "results": rows} + +class CreateUserV2Req(BaseModel): + email: str + ime: Optional[str] = None + prezime: Optional[str] = None + user_type: str = 'klub_user' + klub_id: Optional[int] = None + savez_id: Optional[int] = None + telefon: Optional[str] = None + oib: Optional[str] = None + password: Optional[str] = None # if not provided, default 'PgzSport2026!' + must_change_pwd + +@router.post("/users/create") +def create_user_v2(req: CreateUserV2Req, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + pwd = req.password or 'PgzSport2026!' + must_change = not bool(req.password) + full_name = (req.ime or '') + ' ' + (req.prezime or '') + full_name = full_name.strip() or req.email + try: + new_id = db_one("""INSERT INTO pgz_sport.users + (email, password_hash, ime, prezime, full_name, user_type, klub_id, savez_id, + telefon, oib, must_change_pwd, aktivan, status, auth_provider, created_by) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,'active','local',%s) + RETURNING id""", + (req.email.lower().strip(), hash_pw(pwd), req.ime, req.prezime, full_name, + req.user_type, req.klub_id, req.savez_id, req.telefon, req.oib, must_change, + user['user_id']))['id'] + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'user.create:{new_id}')) + return {"id": new_id, "email": req.email, "must_change_pwd": must_change, + "temporary_password": pwd if must_change else None} + except Exception as e: + if 'duplicate' in str(e).lower() or 'unique' in str(e).lower(): + raise HTTPException(409, f"Email već postoji: {req.email}") + raise HTTPException(400, str(e)) + +class EditUserReq(BaseModel): + ime: Optional[str] = None + prezime: Optional[str] = None + user_type: Optional[str] = None + klub_id: Optional[int] = None + savez_id: Optional[int] = None + telefon: Optional[str] = None + oib: Optional[str] = None + aktivan: Optional[bool] = None + +@router.put("/users/{uid}") +def edit_user(uid: int, req: EditUserReq, user = Depends(require_user)): + # Self-edit allowed for limited fields, admin-edit for all + self_edit = uid == user['user_id'] + if not self_edit and not is_pgz_admin(user): + raise HTTPException(403, "Forbidden") + fields = [] + args = [] + allowed_self = {'ime','prezime','telefon'} + for f in ['ime','prezime','user_type','klub_id','savez_id','telefon','oib','aktivan']: + v = getattr(req, f) + if v is not None: + if self_edit and f not in allowed_self and not is_pgz_admin(user): + continue + fields.append(f"{f}=%s") + args.append(v) + if not fields: return {"status":"nothing to update"} + if 'ime' in [f.split('=')[0] for f in fields] or 'prezime' in [f.split('=')[0] for f in fields]: + # rebuild full_name + cur = db_one("SELECT ime, prezime FROM pgz_sport.users WHERE id=%s", (uid,)) + new_ime = req.ime if req.ime is not None else (cur['ime'] if cur else '') + new_prez = req.prezime if req.prezime is not None else (cur['prezime'] if cur else '') + fn = ((new_ime or '') + ' ' + (new_prez or '')).strip() + fields.append("full_name=%s"); args.append(fn) + fields.append("updated_at=now()") + args.append(uid) + db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)} WHERE id=%s", tuple(args)) + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'user.edit:{uid}')) + return {"status":"ok", "id": uid} + +@router.post("/users/{uid}/reset-password") +def admin_reset_password(uid: int, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + new_temp = 'PgzSport' + secrets.token_hex(3) # e.g. PgzSporta3f2c1 + db_exec("""UPDATE pgz_sport.users SET password_hash=%s, must_change_pwd=true, + failed_login_count=0, locked_until=NULL, updated_at=now() WHERE id=%s""", + (hash_pw(new_temp), uid)) + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'user.reset-pwd:{uid}')) + # Revoke all active sessions + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + return {"status":"ok", "temporary_password": new_temp} + +@router.post("/users/{uid}/toggle-active") +def admin_toggle_active(uid: int, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + r = db_one("UPDATE pgz_sport.users SET aktivan=NOT aktivan WHERE id=%s RETURNING aktivan", (uid,)) + if not r: raise HTTPException(404, "User not found") + if not r['aktivan']: + db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,)) + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'user.toggle:{uid}:{r["aktivan"]}')) + return {"id": uid, "aktivan": r['aktivan']} + +@router.post("/users/{uid}/unlock") +def admin_unlock(uid: int, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + db_exec("UPDATE pgz_sport.users SET failed_login_count=0, locked_until=NULL WHERE id=%s", (uid,)) + return {"status":"ok"} + +@router.get("/users/{uid}/audit") +def user_audit(uid: int, limit: int = 50, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + rows = db_query("""SELECT id, action, user_id, ts AS created_at, meta AS payload, + resource_type, resource_id, ip_address + FROM pgz_sport.audit_events + WHERE user_id=%s OR meta::text LIKE %s + ORDER BY id DESC LIMIT %s""", (uid, f'%"user_id":{uid}%', limit)) + return {"count": len(rows), "results": rows} + +@router.get("/admin/audit") +def global_audit(action: Optional[str]=None, user_id: Optional[int]=None, + limit: int=100, offset: int=0, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + where = ["1=1"]; args = [] + if action: where.append("action LIKE %s"); args.append(f'%{action}%') + if user_id: where.append("user_id=%s"); args.append(user_id) + args.extend([limit, offset]) + rows = db_query(f"""SELECT a.id, a.action, a.user_id, + a.ts AS created_at, a.meta AS payload, + a.resource_type, a.resource_id, a.ip_address, + u.email, u.ime, u.prezime + FROM pgz_sport.audit_events a + LEFT JOIN pgz_sport.users u ON u.id=a.user_id + WHERE {" AND ".join(where)} + ORDER BY a.id DESC LIMIT %s OFFSET %s""", tuple(args)) + return {"count": len(rows), "results": rows} + +@router.get("/admin/permissions-matrix") +def perms_matrix(user = Depends(require_user)): + """Returns roles with their JSONB permissions, for the editor UI.""" + require_role(user, ['super_admin','pgz_admin']) + roles = db_query("SELECT id, code, naziv, opis, permissions FROM pgz_sport.roles ORDER BY id") + user_overrides = db_query("""SELECT user_id, permission_code, granted, granted_at + FROM pgz_sport.user_permissions ORDER BY user_id, permission_code""") + return {"roles": roles, "user_overrides": user_overrides} + +class PermissionGrantReq(BaseModel): + user_id: int + permission_code: str + granted: bool = True + note: Optional[str] = None + +@router.post("/admin/permissions/grant") +def grant_permission(req: PermissionGrantReq, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin']) + db_exec("""INSERT INTO pgz_sport.user_permissions (user_id, permission_code, granted, granted_by, note) + VALUES (%s,%s,%s,%s,%s) + ON CONFLICT (user_id, permission_code) DO UPDATE SET + granted=EXCLUDED.granted, granted_by=EXCLUDED.granted_by, + granted_at=now(), note=EXCLUDED.note""", + (req.user_id, req.permission_code, req.granted, user['user_id'], req.note)) + return {"status":"ok"} + +# ═══ KLUB-LINK CRUD ═══ +class KlubLinkReq(BaseModel): + user_id: int + klub_id: Optional[int] = None + savez_id: Optional[int] = None + role: str = 'membership' + primary_klub: bool = False + +@router.post("/users/klub-link") +def klub_link_create(req: KlubLinkReq, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin','savez_admin','klub_admin']) + if not req.klub_id and not req.savez_id: + raise HTTPException(400, "klub_id or savez_id required") + new_id = db_one("""INSERT INTO pgz_sport.user_klub_links + (user_id, klub_id, savez_id, role, link_type, primary_klub, granted_by, granted_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,now()) + ON CONFLICT (user_id, klub_id, link_type, od_datuma) DO UPDATE SET + role=EXCLUDED.role, primary_klub=EXCLUDED.primary_klub, granted_at=now() + RETURNING id""", + (req.user_id, req.klub_id, req.savez_id, req.role, req.role, + req.primary_klub, user['user_id']))['id'] + return {"id": new_id} + +@router.delete("/users/klub-link/{lid}") +def klub_link_delete(lid: int, user = Depends(require_user)): + require_role(user, ['super_admin','pgz_admin','savez_admin','klub_admin']) + db_exec("DELETE FROM pgz_sport.user_klub_links WHERE id=%s", (lid,)) + return {"status":"ok"} + +# ═══ IMPERSONATE (super_admin only) ═══ +class ImpersonateReq(BaseModel): + target_user_id: int + +@router.post("/admin/impersonate") +def impersonate(req: ImpersonateReq, user = Depends(require_user)): + require_role(user, ['super_admin']) + target = db_one("SELECT id, email, full_name FROM pgz_sport.users WHERE id=%s AND aktivan=true", + (req.target_user_id,)) + if not target: raise HTTPException(404, "Target user not found or inactive") + # Issue a session token for target user, with audit tag + token = make_token() + th = hashlib.sha256(token.encode()).hexdigest() + expires = datetime.now() + timedelta(hours=2) # short-lived impersonation + db_exec("""INSERT INTO pgz_sport.user_sessions (user_id, token_hash, expires_at) + VALUES (%s,%s,%s)""", (req.target_user_id, th, expires)) + db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) + VALUES (%s,%s)""", + (user['user_id'], f'admin.impersonate:{req.target_user_id}')) + return {"token": token, "expires_at": expires.isoformat(), "as_user": target, + "impersonated_by": user['email']} + +# ============== USER MANAGEMENT (super_admin / pgz_admin) ============== +class CreateUserReq(BaseModel): + email: str + full_name: str + password: Optional[str] = None + oib: Optional[str] = None + phone: Optional[str] = None + role_code: str = 'klub_user' + scope_type: Optional[str] = None + scope_id: Optional[int] = None + +@router.post("/users") +def create_user(req: CreateUserReq, user = Depends(require_user)): + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'pgz_admin') or + user_has_role(user['user_id'],'klub_admin', 'klub', req.scope_id)): + raise HTTPException(403, "Forbidden") + + pw_hash = hash_pw(req.password) if req.password else None + uid = db_exec("""INSERT INTO pgz_sport.users + (email, full_name, oib, phone, password_hash, status, email_verified) + VALUES (%s,%s,%s,%s,%s,'active',false) RETURNING id""", + (req.email.lower().strip(), req.full_name, req.oib, req.phone, pw_hash)) + + role_id = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role_code,)) + if role_id: + db_exec("""INSERT INTO pgz_sport.user_roles (user_id, role_id, scope_type, scope_id, granted_by) + VALUES (%s,%s,%s,%s,%s)""", (uid, role_id['id'], req.scope_type, req.scope_id, user['user_id'])) + + db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action, resource_type, resource_id) + VALUES (%s,'create_user','user',%s)""", (user['user_id'], uid)) + return {"id": uid, "email": req.email} + +@router.get("/users") +def list_users(klub_id: Optional[int]=None, role: Optional[str]=None, limit:int=100, user = Depends(require_user)): + where, args = ["1=1"], [] + if klub_id: + where.append("EXISTS (SELECT 1 FROM pgz_sport.user_klub_links ukl WHERE ukl.user_id=u.id AND ukl.klub_id=%s)") + args.append(klub_id) + if role: + where.append("""EXISTS (SELECT 1 FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=u.id AND r.code=%s AND ur.active=true)""") + args.append(role) + args.append(limit) + return db_query(f"""SELECT u.id, u.email, u.full_name, u.status, u.last_login, + (SELECT array_agg(r.code) FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=u.id AND ur.active=true) AS roles + FROM pgz_sport.users u WHERE {" AND ".join(where)} ORDER BY u.id LIMIT %s""", args) + +class GrantRoleReq(BaseModel): + user_id: int + role_code: str + scope_type: Optional[str] = None + scope_id: Optional[int] = None + expires_at: Optional[datetime] = None + +@router.post("/users/grant-role") +def grant_role(req: GrantRoleReq, user = Depends(require_user)): + if not user_has_role(user['user_id'],'super_admin'): + if not user_has_role(user['user_id'],'pgz_admin'): + raise HTTPException(403, "Only super_admin or pgz_admin can grant roles") + role = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role_code,)) + if not role: raise HTTPException(404, "Unknown role") + db_exec("""INSERT INTO pgz_sport.user_roles + (user_id, role_id, scope_type, scope_id, granted_by, expires_at) + VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""", + (req.user_id, role['id'], req.scope_type, req.scope_id, user['user_id'], req.expires_at)) + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id, meta) + VALUES (%s,'grant_role','user',%s,%s::jsonb)""", + (user['user_id'], req.user_id, json.dumps({'role':req.role_code,'scope':req.scope_type}))) + return {"status":"ok"} + +@router.get("/roles") +def list_roles(): + return db_query("SELECT id, code, naziv, opis, permissions FROM pgz_sport.roles ORDER BY id") + +# ============== KLUB MEMBERSHIP LINKS ============== +class LinkUserKlubReq(BaseModel): + user_id: int + klub_id: int + clan_id: Optional[int] = None + link_type: str # 'sportas','trener','tajnik','predsjednik','clan_uprave','volonter' + od_datuma: Optional[date] = None + primary_klub: bool = True + napomena: Optional[str] = None + +@router.post("/klub-links") +def link_user_klub(req: LinkUserKlubReq, user = Depends(require_user)): + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'pgz_admin') or + user_has_role(user['user_id'],'klub_admin','klub', req.klub_id)): + raise HTTPException(403, "Forbidden") + db_exec("""INSERT INTO pgz_sport.user_klub_links + (user_id, klub_id, clan_id, link_type, od_datuma, primary_klub, napomena) + VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""", + (req.user_id, req.klub_id, req.clan_id, req.link_type, + req.od_datuma or date.today(), req.primary_klub, req.napomena)) + return {"status":"ok"} + +# ============== INVOICES (ERP) ============== +class CreateInvoiceReq(BaseModel): + klub_id: int + invoice_kind: str # 'ulazni'|'izlazni' + invoice_no: str + vendor_name: Optional[str] = None + vendor_oib: Optional[str] = None + customer_name: Optional[str] = None + customer_oib: Optional[str] = None + invoice_date: date + due_date: Optional[date] = None + amount_net: Optional[float] = None + amount_vat: Optional[float] = None + amount_gross: float + vat_rate: Optional[float] = 25 + description: Optional[str] = None + category: Optional[str] = None + account_code: Optional[str] = None + +@router.post("/invoices") +def create_invoice(req: CreateInvoiceReq, user = Depends(require_user)): + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'klub_admin','klub',req.klub_id)): + raise HTTPException(403, "Forbidden") + iid = db_exec("""INSERT INTO pgz_sport.invoices + (klub_id, invoice_kind, invoice_no, vendor_name, vendor_oib, customer_name, customer_oib, + invoice_date, due_date, amount_net, amount_vat, amount_gross, vat_rate, + description, category, account_code, created_by) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id""", (req.klub_id, req.invoice_kind, req.invoice_no, + req.vendor_name, req.vendor_oib, req.customer_name, req.customer_oib, + req.invoice_date, req.due_date, req.amount_net, req.amount_vat, req.amount_gross, + req.vat_rate, req.description, req.category, req.account_code, user['user_id'])) + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id) + VALUES (%s,'create_invoice','invoice',%s)""", (user['user_id'], iid)) + return {"id": iid} + +@router.get("/invoices") +def list_invoices(klub_id: Optional[int]=None, kind: Optional[str]=None, + status: Optional[str]=None, year: Optional[int]=None, + limit:int=200, user=Depends(require_user)): + where, args = ["1=1"], [] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if kind: where.append("invoice_kind=%s"); args.append(kind) + if status: where.append("payment_status=%s"); args.append(status) + if year: where.append("EXTRACT(year FROM invoice_date)=%s"); args.append(year) + args.append(limit) + return db_query(f"""SELECT id, invoice_no, invoice_kind, vendor_name, customer_name, + invoice_date, due_date, amount_gross, currency, payment_status, category, klub_id + FROM pgz_sport.invoices WHERE {" AND ".join(where)} + ORDER BY invoice_date DESC LIMIT %s""", args) + +@router.get("/invoices/{invoice_id}") +def get_invoice(invoice_id: int, user=Depends(require_user)): + inv = db_one("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,)) + if not inv: raise HTTPException(404) + inv['lines'] = db_query("""SELECT * FROM pgz_sport.invoice_lines + WHERE invoice_id=%s ORDER BY line_no""", (invoice_id,)) + inv['payments'] = db_query("""SELECT * FROM pgz_sport.payments + WHERE invoice_id=%s ORDER BY payment_date""", (invoice_id,)) + return inv + +# ============== INVOICE OCR UPLOAD ============== +class OcrUploadReq(BaseModel): + klub_id: int + file_name: str + file_path: str + file_size: Optional[int] = None + mime: Optional[str] = None + sha256: Optional[str] = None + +@router.post("/invoice-uploads") +def create_upload(req: OcrUploadReq, user = Depends(require_user)): + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'klub_admin','klub',req.klub_id) or + user_has_role(user['user_id'],'klub_user','klub',req.klub_id)): + raise HTTPException(403, "Forbidden") + uid = db_exec("""INSERT INTO pgz_sport.invoice_uploads + (klub_id, uploaded_by, file_name, file_path, file_size, mime, sha256) + VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""", + (req.klub_id, user['user_id'], req.file_name, req.file_path, + req.file_size, req.mime, req.sha256)) + return {"id":uid, "status":"queued_for_ocr"} + +@router.get("/invoice-uploads") +def list_uploads(klub_id: Optional[int]=None, status: Optional[str]=None, limit:int=100): + where, args = ["1=1"], [] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if status: where.append("ocr_status=%s"); args.append(status) + args.append(limit) + return db_query(f"""SELECT id, klub_id, file_name, ocr_status, ai_invoice_no, + ai_amount_gross, ai_vendor_name, ai_invoice_date, uploaded_at, processed_at + FROM pgz_sport.invoice_uploads WHERE {" AND ".join(where)} + ORDER BY uploaded_at DESC LIMIT %s""", args) + +# ============== EXPENSE REPORTS ============== +class ExpenseReportReq(BaseModel): + klub_id: int + user_id: Optional[int] = None + clan_id: Optional[int] = None + report_type: str # 'putni_nalog','putni_trosak','dnevnice','vlastiti_auto' + destination: Optional[str] = None + purpose: Optional[str] = None + date_from: date + date_to: date + vehicle_type: Optional[str] = None + km_driven: Optional[float] = None + cost_transport: float = 0 + cost_lodging: float = 0 + cost_meals: float = 0 + cost_other: float = 0 + dnevnice_count: int = 0 + notes: Optional[str] = None + +@router.post("/expense-reports") +def create_expense(req: ExpenseReportReq, user = Depends(require_user)): + cost_total = (req.cost_transport + req.cost_lodging + req.cost_meals + req.cost_other + + (req.km_driven or 0)*0.42 + req.dnevnice_count*30) + eid = db_exec("""INSERT INTO pgz_sport.expense_reports + (klub_id, user_id, clan_id, report_type, destination, purpose, date_from, date_to, + vehicle_type, km_driven, cost_transport, cost_lodging, cost_meals, cost_other, + cost_total, dnevnice_count, notes) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id""", (req.klub_id, req.user_id or user['user_id'], req.clan_id, + req.report_type, req.destination, req.purpose, req.date_from, req.date_to, + req.vehicle_type, req.km_driven, req.cost_transport, req.cost_lodging, + req.cost_meals, req.cost_other, cost_total, req.dnevnice_count, req.notes)) + return {"id": eid, "cost_total": float(cost_total)} + +@router.get("/expense-reports") +def list_expenses(klub_id: Optional[int]=None, status: Optional[str]=None, limit:int=100): + where, args = ["1=1"], [] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if status: where.append("status=%s"); args.append(status) + args.append(limit) + return db_query(f"""SELECT * FROM pgz_sport.expense_reports + WHERE {" AND ".join(where)} ORDER BY created_at DESC LIMIT %s""", args) + +# ============== FORMS ============== +@router.get("/forms/templates") +def list_form_templates(kategorija: Optional[str]=None): + where, args = ["active=true"], [] + if kategorija: where.append("kategorija=%s"); args.append(kategorija) + return db_query(f"""SELECT id, code, naziv, kategorija, opis, schema_json, required_role + FROM pgz_sport.form_templates WHERE {" AND ".join(where)} ORDER BY kategorija, naziv""", args) + +@router.get("/forms/templates/{code}") +def get_form_template(code: str): + t = db_one("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=true", (code,)) + if not t: raise HTTPException(404) + return t + +class SubmitFormReq(BaseModel): + template_code: str + klub_id: int + clan_id: Optional[int] = None + data: Dict[str, Any] + submit: bool = False # True = submit, False = save draft + +@router.post("/forms/submit") +def submit_form(req: SubmitFormReq, user = Depends(require_user)): + t = db_one("SELECT id FROM pgz_sport.form_templates WHERE code=%s", (req.template_code,)) + if not t: raise HTTPException(404, "Unknown template") + sid = db_exec("""INSERT INTO pgz_sport.form_submissions + (template_id, template_code, klub_id, user_id, clan_id, data, status, submitted_at) + VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s) RETURNING id""", + (t['id'], req.template_code, req.klub_id, user['user_id'], req.clan_id, + json.dumps(req.data), 'submitted' if req.submit else 'draft', + datetime.now() if req.submit else None)) + db_exec("""INSERT INTO pgz_sport.audit_events + (user_id, action, resource_type, resource_id, meta) + VALUES (%s,'submit_form','form',%s,%s::jsonb)""", + (user['user_id'], sid, json.dumps({'template':req.template_code}))) + return {"id": sid} + +@router.get("/forms/submissions") +def list_submissions(klub_id: Optional[int]=None, template_code: Optional[str]=None, + status: Optional[str]=None, limit:int=100): + where, args = ["1=1"], [] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if template_code: where.append("template_code=%s"); args.append(template_code) + if status: where.append("status=%s"); args.append(status) + args.append(limit) + return db_query(f"""SELECT id, template_code, klub_id, user_id, clan_id, status, + submitted_at, approved_at, reference_no + FROM pgz_sport.form_submissions WHERE {" AND ".join(where)} + ORDER BY created_at DESC LIMIT %s""", args) + +# ============== ALERTS ============== +@router.get("/alerts") +def list_alerts(klub_id: Optional[int]=None, severity: Optional[str]=None, + resolved: bool=False, limit:int=100): + where, args = ["rijeseno=%s"], [resolved] + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + if severity: where.append("razina=%s"); args.append(severity.upper()) + args.append(limit) + return db_query(f"""SELECT id, tip, razina, poruka, klub_id, clan_id, due_date, + iznos, datum, rijeseno, created_at + FROM pgz_sport.alertovi WHERE {" AND ".join(where)} + ORDER BY + CASE razina WHEN 'CRITICAL' THEN 1 WHEN 'WARNING' THEN 2 ELSE 3 END, + due_date NULLS LAST LIMIT %s""", args) + +@router.post("/alerts/{alert_id}/resolve") +def resolve_alert(alert_id: int, user = Depends(require_user)): + db_exec("""UPDATE pgz_sport.alertovi SET rijeseno=true, rijeseno_at=now(), rijeseno_od=%s + WHERE id=%s""", (user['user_id'], alert_id)) + return {"status":"ok"} + +@router.post("/alerts/scan") +def trigger_scan(user = Depends(require_user)): + """Run alert rules now.""" + if not (user_has_role(user['user_id'],'super_admin') or user_has_role(user['user_id'],'pgz_admin')): + raise HTTPException(403) + return {"status":"scan_queued","note":"Scan triggers will be honoured on next cron tick"} + +# ============== CLUB DASHBOARD ============== +@router.get("/klub/{klub_id}/dashboard") +def klub_dashboard(klub_id: int): + klub = db_one("SELECT * FROM pgz_sport.v_klub_full WHERE id=%s", (klub_id,)) + if not klub: raise HTTPException(404) + return { + "klub": klub, + "clanovi_count": db_one("SELECT COUNT(*) AS n FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan=true", (klub_id,)), + "lijecnicki_isteka_30d": db_one("""SELECT COUNT(*) AS n FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + WHERE c.klub_id=%s AND lp.vrijedi_do BETWEEN now()::date AND now()::date+interval '30 days'""", (klub_id,)), + "alerts_open": db_query("""SELECT id, tip, razina, poruka, due_date FROM pgz_sport.alertovi + WHERE klub_id=%s AND rijeseno=false ORDER BY + CASE razina WHEN 'CRITICAL' THEN 1 WHEN 'WARNING' THEN 2 ELSE 3 END LIMIT 10""", (klub_id,)), + "invoices_unpaid": db_one("""SELECT COUNT(*) AS n, COALESCE(SUM(amount_gross),0) AS sum_eur + FROM pgz_sport.invoices WHERE klub_id=%s AND payment_status='unpaid'""", (klub_id,)), + "clanarine_status": db_one("""SELECT + SUM(CASE WHEN status='podmireno' THEN 1 ELSE 0 END) AS placena, + SUM(CASE WHEN status='nepodmireno' THEN 1 ELSE 0 END) AS neplacena, + SUM(CASE WHEN status='djelomicno' THEN 1 ELSE 0 END) AS djelomicno, + COALESCE(SUM(iznos_propisan-COALESCE(iznos_placen,0)),0) AS dug_total + FROM pgz_sport.clanarine WHERE klub_id=%s AND godina=EXTRACT(year FROM now())::int""", (klub_id,)), + "form_drafts": db_query("""SELECT id, template_code, status, updated_at + FROM pgz_sport.form_submissions WHERE klub_id=%s AND status='draft' + ORDER BY updated_at DESC LIMIT 5""", (klub_id,)), + } + +# ============== AI RAG SPORT AGENT ============== +class AskReq(BaseModel): + query: str + limit: int = 5 + +@router.post("/sport/ask") +def sport_ask(req: AskReq): + """RAG over pgz_sport_v1 — return relevant context for an LLM. + POST-PROCESS: resolve deleted clan_id/klub_id to canonical via DB lookup.""" + try: + r = requests.post(EMBED, json={"input":[req.query, req.query]}, timeout=30) + j = r.json() + emb = j.get('embeddings', [j.get('embedding')])[0] + except Exception as e: + raise HTTPException(503, f"Embedder unavailable: {e}") + + # Fetch MORE results (limit*3) so we can dedupe and resolve + r = requests.post(f"{QDRANT}/collections/{COLL}/points/search", + json={"vector": emb, "limit": req.limit * 3, "with_payload": True}, timeout=30) + if r.status_code >= 400: raise HTTPException(503, f"Qdrant: {r.text[:200]}") + hits = r.json()['result'] + + # Resolve deleted IDs to canonical via DB + valid_clan_ids = set(r['id'] for r in db_query("SELECT id FROM pgz_sport.clanovi")) + valid_klub_ids = set(r['id'] for r in db_query("SELECT id FROM pgz_sport.klubovi")) + + seen_canon = set() + out_results = [] + for h in hits: + pl = h['payload'] + tip = pl.get('tip', pl.get('type')) + cid = pl.get('clan_id') + kid = pl.get('klub_id') + naziv = pl.get('naziv') or pl.get('title') or '?' + + # If clan_id deleted, try to resolve by name + if tip == 'clan' and cid and cid not in valid_clan_ids: + parts = naziv.split() + if len(parts) >= 2: + ime = parts[0] + prezime = ' '.join(parts[1:]) + resolved = db_one("""SELECT c.id, c.klub_id, k.naziv AS klub + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE LOWER(c.ime)=LOWER(%s) AND LOWER(c.prezime)=LOWER(%s) + ORDER BY (c.slika_url IS NOT NULL) DESC, c.id ASC LIMIT 1""", (ime, prezime)) + if resolved: + cid = resolved['id'] + kid = resolved['klub_id'] + pl = {**pl, 'clan_id': cid, 'klub_id': kid, 'klub': resolved['klub']} + else: + continue # skip - not findable + + # Skip klub points if klub deleted + if tip == 'klub' and kid and kid not in valid_klub_ids: + continue + + # Dedup by canonical clan_id (or klub_id) + canon_key = ('clan', cid) if tip == 'clan' else ('klub', kid) if tip == 'klub' else (tip, naziv) + if canon_key in seen_canon: + continue + seen_canon.add(canon_key) + + out_results.append({ + "score": h['score'], + "type": tip, + "title": naziv, + "snippet": (pl.get('tekst') or '')[:500], + "payload": {k:v for k,v in pl.items() if k != 'tekst'} + }) + if len(out_results) >= req.limit: + break + + return {"query": req.query, "results": out_results} + +# ============== CALENDAR / SCHEDULE ============== + +@router.post("/sport/lawyer") +def sport_lawyer(payload: dict): + """ + AI Pravnik — odgovara na sve pravne, regulatorne i proceduralne nedoumice + iz pravilnika HOO, MINT, županije i klubova. Koristi RAG + DeepSeek/Groq LLM. + """ + q = (payload.get("query") or payload.get("question") or "").strip() + if not q: + raise HTTPException(400, "query je prazan") + + # OS-FIRST: delegate to orchestrator if env flag set + if USE_ORCHESTRATOR: + result = delegate_to_orchestrator(q, persona="sport") + if result.get("delegated"): + return { + "query": q, + "answer": result["answer"], + "sources": result.get("sources", []), + "llm": result["llm"], + "hits_count": len(result.get("sources", [])), + "via": "orchestrator", + } + # Fallback to local waterfall if orchestrator failed + + # 1) RAG v2 — Fetch 25 candidates → dedup → top 6 unique docs + import requests as _requests + try: + emb_r = _requests.post( + "http://localhost:9879/api/embeddings", + json={"input": [q]}, timeout=20 + ) + if not emb_r.ok: + raise HTTPException(500, f"embed failed: {emb_r.status_code}") + _r = emb_r.json(); emb = _r.get("embedding") or _r.get("embeddings",[None])[0] + except Exception as e: + raise HTTPException(500, f"embedding error: {e}") + + # Fetch 25 candidates (will dedup) + try: + qr = _requests.post( + "http://10.10.0.2:6333/collections/pgz_sport_v1/points/search", + json={"vector": emb, "limit": 25, "with_payload": True, "score_threshold": 0.35}, + timeout=20 + ) + if not qr.ok: + raise HTTPException(500, f"qdrant http {qr.status_code}: {qr.text[:200]}") + all_hits = qr.json().get("result", []) + except HTTPException: raise + except Exception as e: + raise HTTPException(500, f"qdrant error: {e}") + + if not all_hits: + return {"query": q, "answer": "Nema relevantnih pravilnika u bazi za ovaj upit. Probaj preformulirati pitanje konkretnije, npr. uključi specifičan sport, godinu, ili tip dokumenta (pravilnik, zakon, kriterij).", "sources": []} + + # 2) Dedup by document — keep top chunk per unique doc_id/title (not all chunks of same doc) + seen_docs = {} # key = doc_id or normalized title + for h in all_hits: + p = h.get("payload") or {} + # Normalize doc identity + doc_key = p.get("doc_id") or p.get("source_url") or p.get("title", "?") + if doc_key not in seen_docs or h.get("score", 0) > seen_docs[doc_key].get("score", 0): + seen_docs[doc_key] = h + + # Sort by score, take top 6 unique docs + unique_hits = sorted(seen_docs.values(), key=lambda x: x.get("score", 0), reverse=True)[:6] + hits = unique_hits + + # 3) PGŽ-relevance boost: prefer PGŽ-specific docs over general national + PGZ_KW = ['pgž','pgz','primorsk','rijeka','kvarner','crikvenic','opatij','krk','cres','lošinj','rab'] + def pgz_boost(h): + p = h.get("payload") or {} + all_t = ((p.get("title","") or "") + " " + (p.get("text","")[:300] or "") + " " + (p.get("source_url","") or "")).lower() + if any(k in all_t for k in PGZ_KW): + return h.get("score", 0) * 1.15 # 15% boost + return h.get("score", 0) + hits = sorted(hits, key=pgz_boost, reverse=True) + + # 4) Build context with metadata + ctx_chunks = [] + sources = [] + for i, h in enumerate(hits): + p = h.get("payload") or {} + text = (p.get("text") or "")[:1200] # more context per chunk + title = p.get("title", "(bez naslova)") + url = p.get("source_url") or p.get("url", "") + doc_type = p.get("doc_type", "") + publish_date = p.get("publish_date", "") + source = p.get("source", "") + date_str = f", {publish_date[:10]}" if publish_date else "" + if text and len(text) > 50: + ctx_chunks.append(f"[{i+1}] {title}{date_str} ({doc_type or source}):\n{text}") + sources.append({ + "id": i+1, "title": title, "url": url, + "doc_type": doc_type, "publish_date": publish_date, + "source": source, "score": round(float(h.get("score", 0)), 3) + }) + + context = "\n\n".join(ctx_chunks) + + # 3) LLM call — DeepSeek primary + SYSTEM = """Ti si AI PRAVNIK Zajednice sportova Primorsko-goranske županije (ZSPGŽ). +Specijaliziran si za hrvatsko sportsko pravo i propise koje primjenjuje ZSPGŽ. + +ZNANJE TI DOLAZI ISKLJUČIVO IZ PRILOŽENIH IZVORA. Nikada ne izmišljaj ni datume ni iznose ni članke. + +PRAVILA ODGOVORA: +1. KRATAK DIREKTAN ODGOVOR PRVI (1-3 rečenice). Što tražitelj treba znati odmah. +2. DETALJI: konkretni iznosi, rokovi, članci pravilnika, postupci. Citiraj BROJEVE [1], [2]... za svaki podatak. +3. AKO INFORMACIJA NIJE U IZVORIMA: jasno kaži "Ovo pitanje nije pokriveno priloženim pravilnicima — preporučujem provjeru izravno na sport-pgz.hr ili kod nadležne osobe." +4. AKO POSTOJE SLIČNI ALI NE IDENTIČNI PRAVILNICI: navedi razliku jasno. +5. PRIORITET: ZSPGŽ pravilnici > PGŽ županijski > nacionalni HOO/MINT > zakonski tekst. +6. STIL: stručan, ali razumljiv. Hrvatski jezik. Bez fraza poput "kako je navedeno" — direktno citiraj. +7. NE PONAVLJAJ pitanje. Ne počinji s "Prema priloženim pravilnicima..." — direktno na odgovor. + +FORMAT: +**Odgovor:** [1-3 rečenice s ključnim podacima i [1] referencama] + +**Detalji:** +- [bullet] [konkretan podatak] [referenca] +- [bullet] [postupak] [referenca] + +**Reference:** [auto generirano ispod, ne pisati u odgovoru] + +Ako tražitelj pita o konkretnom iznosu/rokovima a nemaš to u izvorima, **nemoj nagađati** — kaži da nisi siguran i preporuči direktan kontakt.""" + + user_msg = f"PITANJE: {q}\n\nPRILOŽENI PRAVILNICI/IZVORI:\n{context}\n\nODGOVOR (sa referencama [1], [2]...):" + + answer = "" + llm_used = "none" + + # Try DeepSeek + try: + ds_key = os.environ.get("DEEPSEEK_API_KEY") + if ds_key: + r = _requests.post( + "https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {ds_key}"}, + json={ + "model": "deepseek-chat", + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.15, + "max_tokens": 2000, + }, + timeout=60, + ) + if r.ok: + answer = r.json()["choices"][0]["message"]["content"] + llm_used = "deepseek" + except Exception as e: + pass + + # Fallback Groq + if not answer: + try: + gk = os.environ.get("GROQ_API_KEY") + if gk: + r = _requests.post( + "https://api.groq.com/openai/v1/chat/completions", + headers={"Authorization": f"Bearer {gk}"}, + json={ + "model": "llama-3.3-70b-versatile", + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user_msg}, + ], + "temperature": 0.15, + "max_tokens": 2000, + }, + timeout=60, + ) + if r.ok: + answer = r.json()["choices"][0]["message"]["content"] + llm_used = "groq" + except Exception as e: + pass + + # Local Ollama fallback + if not answer: + try: + r = _requests.post( + "http://localhost:11434/api/chat", + json={ + "model": "qwen2.5:7b", + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user_msg}, + ], + "stream": False, + "options": {"temperature": 0.15, "num_predict": 1500}, + }, + timeout=120, + ) + if r.ok: + answer = r.json().get("message", {}).get("content", "") + llm_used = "ollama_qwen2.5" + except Exception: + pass + + if not answer: + # No LLM available — return pure RAG with notice + answer = "**[LLM nije dostupan, evo top relevantnih izvora:]**\n\n" + for i, src_item in enumerate(sources[:3], 1): + answer += f"**[{i}] {src_item['title']}**\n" + answer += f"_{src_item.get('doc_type','')}, score={src_item['score']:.2f}_\n\n" + llm_used = "rag_only" + + # Audit + try: + from psycopg2 import connect + conn = connect(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7') + cu = conn.cursor() + cu.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_text, payload) + VALUES (%s,%s,%s,%s::jsonb)""", + ('lawyer.query', 'sport_lawyer', q[:500], + json.dumps({"llm": llm_used, "hits": len(hits), "sources": len(sources)}))) + conn.commit(); conn.close() + except Exception: + pass + + return { + "query": q, + "answer": answer, + "sources": sources, + "llm": llm_used, + "hits_count": len(hits), + } + + +@router.get("/calendar/upcoming") +def calendar_upcoming(klub_id: Optional[int]=None, days_ahead: int=30): + end = datetime.now() + timedelta(days=days_ahead) + out = [] + + # Liječnički pregledi koji ističu + where = ""; args = [] + if klub_id: + where = "AND c.klub_id=%s"; args = [klub_id] + args = [end] + args + rows = db_query(f"""SELECT 'lijecnicki_istek' AS type, + 'Liječnički istječe — '||c.ime||' '||c.prezime AS title, + lp.vrijedi_do AS date, c.klub_id, lp.clan_id, k.naziv AS klub_naziv + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.clanovi c ON c.id=lp.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lp.vrijedi_do BETWEEN now()::date AND %s::date {where} + ORDER BY lp.vrijedi_do""", args) + out.extend(rows) + + # Računi koji dospijevaju + args2 = [end] + if klub_id: args2.append(klub_id) + where2 = " AND klub_id=%s" if klub_id else "" + rows = db_query(f"""SELECT 'invoice_due' AS type, + 'Račun '||invoice_no||' — '||COALESCE(vendor_name,'?')||' — '||amount_gross::text||' EUR' AS title, + due_date AS date, klub_id, NULL::int AS clan_id, NULL::text AS klub_naziv + FROM pgz_sport.invoices WHERE due_date BETWEEN now()::date AND %s::date + AND payment_status='unpaid'{where2} + ORDER BY due_date""", args2) + out.extend(rows) + + # Alerts otvorene + args3 = [end] + if klub_id: args3.append(klub_id) + where3 = " AND klub_id=%s" if klub_id else "" + rows = db_query(f"""SELECT 'alert' AS type, poruka AS title, due_date AS date, + klub_id, clan_id, NULL::text FROM pgz_sport.alertovi + WHERE rijeseno=false AND due_date IS NOT NULL AND due_date<=%s::date{where3} + ORDER BY due_date""", args3) + out.extend(rows) + + return sorted(out, key=lambda x: x.get('date') or date.max) + +# ============== EKOSUSTAV STATS (extended) ============== +@router.get("/ekosustav/v2") +def ekosustav_v2(): + return { + "savezi": db_one("""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE razina='nacional') AS nacional, + COUNT(*) FILTER (WHERE razina='zupanijski') AS zupanijski, + COUNT(*) FILTER (WHERE razina='gradski') AS gradski + FROM pgz_sport.savezi"""), + "klubovi": db_one("""SELECT + COUNT(*) AS total, + COUNT(oib) AS s_oib, + COUNT(*) FILTER (WHERE entity_id IS NOT NULL) AS linked + FROM pgz_sport.klubovi"""), + "documents": db_one("""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE LENGTH(COALESCE(text_extracted,''))>=200) AS extracted + FROM sport.documents"""), + "qdrant_points": (lambda: requests.get(f"{QDRANT}/collections/{COLL}").json()['result']['points_count'])(), + "users": db_one("""SELECT COUNT(*) FROM pgz_sport.users WHERE status='active'"""), + "alerts_open": db_one("""SELECT + COUNT(*) FILTER (WHERE razina='CRITICAL') AS critical, + COUNT(*) FILTER (WHERE razina='WARNING') AS warning, + COUNT(*) FILTER (WHERE razina='INFO') AS info + FROM pgz_sport.alertovi WHERE rijeseno=false"""), + "forms_templates": db_one("SELECT COUNT(*) FROM pgz_sport.form_templates WHERE active=true"), + "invoices": db_one("""SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE payment_status='unpaid') AS unpaid, + COALESCE(SUM(amount_gross),0)::numeric AS total_eur + FROM pgz_sport.invoices"""), + } + +# =========== MULTIPART UPLOAD + USER CREATE (added 28apr) =========== +UPLOAD_DIR = "/var/lib/pgz-sport/invoices" +os.makedirs(UPLOAD_DIR, exist_ok=True) + +@router.post("/invoice-uploads/file") +async def upload_invoice_file( + file: UploadFile = File(...), + klub_id: int = Form(...), + invoice_kind: str = Form("ulazni"), + user = Depends(require_user) +): + """Multipart upload — saves to disk + queues for OCR.""" + # Permissions: super_admin (global) OR klub_admin/klub_user/pgz_admin/pgz_user + is_authorized = ( + user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'pgz_admin') or + user_has_role(user['user_id'],'pgz_user') or + user_has_role(user['user_id'],'klub_admin','klub',klub_id) or + user_has_role(user['user_id'],'klub_user','klub',klub_id) or + # Fallback - if user_type field on users table indicates super_admin + (db_one("SELECT user_type FROM pgz_sport.users WHERE id=%s", (user['user_id'],)) or {}).get('user_type') in ('super_admin','pgz_admin') + ) + if not is_authorized: + raise HTTPException(403, f"Forbidden - need klub_admin role for klub_id={klub_id}") + raw = await file.read() + sha = hashlib.sha256(raw).hexdigest() + safe = re.sub(r'[^a-zA-Z0-9._-]','_', file.filename or 'invoice') + path = f"{UPLOAD_DIR}/{klub_id}_{int(time.time())}_{sha[:8]}_{safe}" + with open(path, 'wb') as f: + f.write(raw) + uid = db_exec("""INSERT INTO pgz_sport.invoice_uploads + (klub_id, uploaded_by, file_name, file_path, file_size, mime, sha256, ocr_status) + VALUES (%s,%s,%s,%s,%s,%s,%s,'pending') RETURNING id""", + (klub_id, user['user_id'], file.filename, path, len(raw), file.content_type, sha)) + db_exec("INSERT INTO pgz_sport.audit_events(user_id,action,resource_type,resource_id,meta) VALUES (%s,'upload_invoice','invoice_upload',%s,%s)", + (user['user_id'], uid, json.dumps({'klub_id':klub_id,'kind':invoice_kind,'sha':sha[:16]}))) + return {"upload_id": uid, "ocr_status": "pending", "klub_id": klub_id, "size": len(raw), "sha": sha[:16]} + +# =========== USER CREATE (simple, no token required from super_admin via API) =========== +class CreateUserReq(BaseModel): + email: str + full_name: Optional[str] = None + password: str + role: str = "viewer" + klub_id: Optional[int] = None + oib: Optional[str] = None + phone: Optional[str] = None + +@router.post("/users") +def api_create_user(req: CreateUserReq, user = Depends(require_user)): + """Create new user. Requires super_admin or pgz_admin or klub_admin.""" + if not (user_has_role(user['user_id'],'super_admin') or + user_has_role(user['user_id'],'pgz_admin') or + (req.klub_id and user_has_role(user['user_id'],'klub_admin','klub',req.klub_id))): + raise HTTPException(403, "Need super_admin/pgz_admin/klub_admin") + # Existing user? + existing = db_one("SELECT id FROM pgz_sport.users WHERE email=%s", (req.email,)) + if existing: + raise HTTPException(409, f"User {req.email} already exists (id={existing['id']})") + # Create + uid = db_exec("""INSERT INTO pgz_sport.users (email, full_name, oib, phone, password_hash, status) + VALUES (%s,%s,%s,%s,%s,'active') RETURNING id""", + (req.email, req.full_name, req.oib, req.phone, hash_pw(req.password))) + # Assign role + role_row = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role,)) + if role_row: + scope = ('klub', req.klub_id) if req.klub_id else ('global', None) + db_exec("""INSERT INTO pgz_sport.user_roles (user_id, role_id, scope_type, scope_id, granted_by, active) + VALUES (%s,%s,%s,%s,%s,true)""", + (uid, role_row['id'], scope[0], scope[1], user['user_id'])) + db_exec("INSERT INTO pgz_sport.audit_events(user_id,action,resource_type,resource_id,meta) VALUES (%s,'create_user','user',%s,%s)", + (user['user_id'], uid, json.dumps({'email':req.email,'role':req.role,'klub_id':req.klub_id}))) + return {"user_id": uid, "email": req.email, "role": req.role, "klub_id": req.klub_id} + + +# ═══════════════════════════════════════════════════════ +# SPORTAŠ (Player) ENDPOINTS — semafor.hns.family style +# ═══════════════════════════════════════════════════════ + +@router.get("/sportas/{cid}/profile") +def sportas_profile(cid: int): + """Full player profile + per-season stats + match log. Public read.""" + sportas = db_one("""SELECT c.id, c.kategorije, c.promocija_kategorije, c.sport, c.ime, c.prezime, c.datum_rodenja, c.mjesto_rodenja, + c.slika_url, c.source, c.source_id, c.source_url, c.source_synced_at, + c.pozicija, c.dominantna_noga, c.visina_cm, c.tezina_kg, c.broj_dresa, + c.reprezentativac, c.reprezentacija_kategorija, c.biografija, + c.klub_id, k.naziv AS klub_naziv, k.sport, k.razina, k.region, k.logo_url, + k.hns_klub_id, k.hns_slug + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.id=%s""", (cid,)) + if not sportas: + raise HTTPException(404, "Sportaš nije pronađen") + + # Per-season aggregate from utakmice_log + seasons = db_query(""" + SELECT + CASE WHEN EXTRACT(MONTH FROM datum)>=7 + THEN EXTRACT(YEAR FROM datum)::TEXT||'/'||(EXTRACT(YEAR FROM datum)+1)::TEXT + ELSE (EXTRACT(YEAR FROM datum)-1)::TEXT||'/'||EXTRACT(YEAR FROM datum)::TEXT + END AS sezona, + natjecanje, count(*) AS nastupi, + COALESCE(SUM(pogodaka),0) AS pogoci, + COALESCE(SUM(zuti_kartoni),0) AS zuti, + COALESCE(SUM(crveni_kartoni),0) AS crveni, + COALESCE(SUM(minute),0) AS minute_total + FROM pgz_sport.utakmice_log + WHERE clan_id=%s + GROUP BY 1, 2 + ORDER BY 1 DESC, 2""", (cid,)) + + # Match log (latest 50) + matches = db_query("""SELECT id, datum, vrijeme, natjecanje, + klub_dom, klub_dom_logo, klub_gost, klub_gost_logo, + rezultat, pogodaka, zuti_kartoni, crveni_kartoni, minute, + zapocet_kao_starter, source_url + FROM pgz_sport.utakmice_log + WHERE clan_id=%s ORDER BY datum DESC NULLS LAST LIMIT 50""", (cid,)) + + # Career — clubs over time (from utakmice_log distinct za_klub_id) + career = db_query("""SELECT k.id, k.naziv, k.logo_url, + min(ul.datum) AS od_dat, max(ul.datum) AS do_dat, + count(*) AS nastupa + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.klubovi k ON k.id=ul.za_klub_id + WHERE ul.clan_id=%s GROUP BY k.id, k.naziv, k.logo_url + ORDER BY min(ul.datum)""", (cid,)) + + return { + "sportas": sportas, + "seasons": seasons, + "career": career, + "matches": matches, + "totals": { + "nastupa": sum(s['nastupi'] for s in seasons), + "pogodaka": sum(s['pogoci'] for s in seasons), + "zutih": sum(s['zuti'] for s in seasons), + "crvenih": sum(s['crveni'] for s in seasons), + } + } + +@router.get("/klub/{kid}/sportasi") +def klub_sportasi(kid: int, limit: int = 100, offset: int = 0): + """Roster: players in a club.""" + klub = db_one("SELECT id, naziv, sport, razina, hns_klub_id, logo_url FROM pgz_sport.klubovi WHERE id=%s", (kid,)) + if not klub: + raise HTTPException(404, "Klub nije pronađen") + sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.mjesto_rodenja, + c.slika_url, c.pozicija, c.broj_dresa, c.reprezentativac, c.source, c.source_url, + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE clan_id=c.id) AS nastupa, + (SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log WHERE clan_id=c.id) AS pogoci + FROM pgz_sport.clanovi c + WHERE c.klub_id=%s + ORDER BY c.broj_dresa NULLS LAST, c.prezime, c.ime + LIMIT %s OFFSET %s""", (kid, limit, offset)) + total = db_one("SELECT count(*) AS c FROM pgz_sport.clanovi WHERE klub_id=%s", (kid,))['c'] + + # A4_KLUB_TROFEJI_PATCH: trofeji, povijesne nagrade, top medalisti + trofeji = db_query(""" + SELECT sezona, natjecanje, plasiranje, trofej, bodovi, napomena + FROM pgz_sport.klub_sezona + WHERE klub_id=%s + ORDER BY + CASE WHEN sezona ~ '^[0-9]{4}' THEN substring(sezona FROM '^[0-9]{4}')::INT ELSE 0 END DESC, + plasiranje ASC NULLS LAST + LIMIT 50""", (kid,)) + + priznanja = db_query(""" + SELECT godina, kategorija, ime_prezime, sport, napomena, clan_id + FROM pgz_sport.najbolji_sportasi + WHERE klub_id=%s + ORDER BY godina DESC LIMIT 50""", (kid,)) + + top_medalisti = db_query(""" + SELECT ime_prezime, clan_id, count(*) AS nagrade, + count(*) FILTER (WHERE medalja='ZLATO') AS z, + count(*) FILTER (WHERE medalja='SREBRO') AS s, + count(*) FILTER (WHERE medalja='BRONCA') AS b, + count(*) FILTER (WHERE razina_natjecanja IN ('SP','EP','OI')) AS svj + FROM pgz_sport.clan_nagrada + WHERE klub_id=%s AND medalja IS NOT NULL + GROUP BY ime_prezime, clan_id + ORDER BY count(*) FILTER (WHERE medalja='ZLATO') DESC, count(*) DESC + LIMIT 15""", (kid,)) + + # HOO kategorizirani u klubu + hoo_sportasi = db_query(""" + SELECT id, ime, prezime, kategorija_hoo, sport + FROM pgz_sport.clanovi + WHERE klub_id=%s AND kategorija_hoo IS NOT NULL + ORDER BY kategorija_hoo, prezime, ime""", (kid,)) + + return { + "klub": klub, "count": len(sportasi), "total": total, "sportasi": sportasi, + "trofeji": trofeji, + "priznanja": priznanja, + "top_medalisti": top_medalisti, + "hoo_sportasi": hoo_sportasi + } + + +# === NATJECANJA_TABLICA_PATCH === +@router.get("/natjecanja") +def natjecanja_list(sport: Optional[str] = None, sezona: Optional[str] = None, pgz_only: bool = False, limit: int = 100): + """List natjecanja (lige) with filtering.""" + where = []; args = [] + if sport: where.append("LOWER(sport) = LOWER(%s)"); args.append(sport) + if sezona: where.append("sezona = %s"); args.append(sezona) + if pgz_only: where.append("pgz_relevant = true") + where_sql = " AND ".join(where) if where else "1=1" + args.append(limit) + rows = db_query(f""" + SELECT n.id, n.sport, n.naziv, n.razina, n.tip, n.sezona, n.source, n.source_url, + n.pgz_relevant, + (SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE natjecanje_id=n.id) AS broj_klubova, + s.naziv AS savez_naziv + FROM pgz_sport.natjecanja n + LEFT JOIN pgz_sport.savezi s ON s.id = n.savez_id + WHERE {where_sql} + ORDER BY n.pgz_relevant DESC, n.sport, n.razina, n.naziv + LIMIT %s""", tuple(args)) + return {"count": len(rows), "natjecanja": rows} + + +@router.get("/natjecanja/{nid}/tablica") +def natjecanja_tablica(nid: int): + """Get current standings for a natjecanje - from DB or external URL.""" + natj = db_one("""SELECT n.*, s.naziv AS savez_naziv FROM pgz_sport.natjecanja n + LEFT JOIN pgz_sport.savezi s ON s.id=n.savez_id + WHERE n.id=%s""", (nid,)) + if not natj: + raise HTTPException(404, f"Natjecanje {nid} nije pronađeno") + + # Get from scraped table + klubovi = db_query(""" + SELECT nt.rang, nt.klub_naziv, nt.utakmica, nt.pobjede, nt.nerijeseno, nt.porazi, + nt.golovi_za, nt.golovi_protiv, nt.bodovi, nt.source, nt.scraped_at, + k.id as klub_id + FROM pgz_sport.natjecanje_tablica nt + LEFT JOIN pgz_sport.klubovi k ON LOWER(k.naziv) LIKE LOWER('%%'||LEFT(nt.klub_naziv,15)||'%%') AND k.aktivan=true + WHERE nt.natjecanje_id=%s + ORDER BY nt.rang + """, (nid,)) + + return {"natjecanje": natj, "klubovi": klubovi, "count": len(klubovi)} + + +@router.get("/audit/freshness") +def audit_freshness(): + """Show data freshness across all scrapers.""" + rows = db_query(""" + SELECT 'utakmice_log (HNS)' AS tabela, count(*) AS broj, max(scraped_at) AS zadnji_update, + min(scraped_at) AS prvi_update, + (now() - max(scraped_at))::text AS od_zadnjeg + FROM pgz_sport.utakmice_log + UNION ALL + SELECT 'clan_sezona', count(*), max(last_scraped_at), min(last_scraped_at), + (now() - max(last_scraped_at))::text + FROM pgz_sport.clan_sezona + UNION ALL + SELECT 'klubovi (HNS)', count(*), max(source_synced_at), min(source_synced_at), + (now() - max(source_synced_at))::text + FROM pgz_sport.klubovi WHERE source = 'hns_semafor' + UNION ALL + SELECT 'klubovi (HBS)', count(*), max(source_synced_at), min(source_synced_at), + (now() - max(source_synced_at))::text + FROM pgz_sport.klubovi WHERE source = 'hbs_savez' + UNION ALL + SELECT 'natjecanja_tablice', count(*), max(updated_at), min(updated_at), + (now() - max(updated_at))::text + FROM pgz_sport.natjecanja_tablice + UNION ALL + SELECT 'natjecanja', count(*), max(updated_at), min(updated_at), + (now() - max(updated_at))::text + FROM pgz_sport.natjecanja + UNION ALL + SELECT 'dokumenti', count(*), max(scraped_at), min(scraped_at), + (now() - max(scraped_at))::text + FROM pgz_sport.dokumenti + UNION ALL + SELECT 'clan_nagrada', count(*), max(last_updated), min(last_updated), + (now() - max(last_updated))::text + FROM pgz_sport.clan_nagrada + ORDER BY tabela + """) + return {"freshness": rows} + +@router.get("/audit/sources") +def audit_sources(): + """Distribucija izvora po tablicama.""" + klubovi = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.klubovi GROUP BY source ORDER BY count(*) DESC") + clanovi = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.clanovi GROUP BY source ORDER BY count(*) DESC") + dokumenti = db_query("SELECT COALESCE(vrsta,'unknown') AS source, count(*) AS broj FROM pgz_sport.dokumenti GROUP BY vrsta ORDER BY count(*) DESC") + natjecanja = db_query("SELECT COALESCE(source,'unknown') AS source, count(*) AS broj FROM pgz_sport.natjecanja GROUP BY source ORDER BY count(*) DESC") + return { + "klubovi_by_source": klubovi, + "clanovi_by_source": clanovi, + "dokumenti_by_vrsta": dokumenti, + "natjecanja_by_source": natjecanja + } + +@router.get("/sportas/{clan_id}/godisnjak_history") +def godisnjak_history(clan_id: int): + """Vraća sve spomene sportaša u godišnjacima sa snippet kontekstima.""" + with psycopg2.connect(**DB) as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu: + cu.execute("SELECT ime, prezime, sport FROM pgz_sport.clanovi WHERE id=%s", (clan_id,)) + sp = cu.fetchone() + if not sp: + raise HTTPException(404, "Sportaš nije pronađen") + cu.execute("""SELECT cg.godina, cg.snippet, cg.klub_naziv, cg.keywords, + cg.has_medal, cg.has_kategorija, d.izvor_url, d.title + FROM pgz_sport.clan_godisnjak cg + JOIN pgz_sport.dokumenti d ON d.id = cg.dokument_id + WHERE cg.clan_id = %s ORDER BY cg.godina""", (clan_id,)) + history = cu.fetchall() + return {"sportas": dict(sp), "count": len(history), "history": [dict(h) for h in history]} + + +@router.get("/godisnjak/{godina}/sportasi") +def godisnjak_sportasi(godina: int, has_medal: bool = False, limit: int = 100): + """Sportaši spomenuti u određenom godišnjaku.""" + with psycopg2.connect(**DB) as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu: + sql = """SELECT cg.clan_id, c.ime, c.prezime, c.sport, k.naziv AS klub, + cg.has_medal, cg.has_kategorija, cg.keywords, cg.snippet + FROM pgz_sport.clan_godisnjak cg + JOIN pgz_sport.clanovi c ON c.id = cg.clan_id + LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE cg.godina = %s""" + params = [godina] + if has_medal: + sql += " AND cg.has_medal = true" + sql += " ORDER BY cg.has_medal DESC, c.prezime LIMIT %s" + params.append(limit) + cu.execute(sql, params) + rows = cu.fetchall() + return {"godina": godina, "count": len(rows), "sportasi": [dict(r) for r in rows]} + + +@router.get("/audit/coverage_matrix") +def audit_coverage_matrix(limit: int = 80): + """Pokrivenost po klubu: heat-map data za GUI.""" + with psycopg2.connect(**DB) as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cu: + cu.execute(""" + SELECT k.id, k.naziv, k.sport, k.hns_klub_id, + (SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) AS sportasa, + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) AS utakmica, + (SELECT count(*) FROM pgz_sport.clan_sezona WHERE klub_naziv ILIKE '%%' || k.naziv || '%%' OR klub_naziv ILIKE k.naziv) AS sezona, + (SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) AS trofeja, + (SELECT count(*) FROM pgz_sport.clan_nagrada WHERE klub_id=k.id) AS nagrada, + (SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE klub_id=k.id) AS u_ligama, + CASE WHEN k.logo_url IS NOT NULL THEN 1 ELSE 0 END AS ima_logo, + array_length(k.godisnjak_godine, 1) AS godina_god, + k.godisnjak_prvi, k.godisnjak_zadnji + FROM pgz_sport.klubovi k + WHERE k.aktivan=true + ORDER BY + ((SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) + + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id)/10 + + COALESCE(array_length(k.godisnjak_godine, 1), 0)*5) DESC + LIMIT %s + """, (limit,)) + rows = cu.fetchall() + return {"count": len(rows), "klubovi": [dict(r) for r in rows]} + + +@router.get("/audit/coverage") +def audit_coverage(): + """Pokrivenost po klubu — koliko podataka imamo.""" + rows = db_query(""" + SELECT k.id, k.naziv, k.sport, k.razina, k.hns_klub_id, + k.source, k.source_synced_at, + (SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) AS sportasa, + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) AS utakmica, + (SELECT count(*) FROM pgz_sport.clan_sezona WHERE clan_id IN (SELECT id FROM pgz_sport.clanovi WHERE klub_id=k.id)) AS sezona, + (SELECT count(*) FROM pgz_sport.clan_nagrada WHERE klub_id=k.id) AS nagrada, + (SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) AS trofeja, + (SELECT count(*) FROM pgz_sport.natjecanja_tablice WHERE klub_id=k.id) AS u_ligama + FROM pgz_sport.klubovi k + WHERE k.aktivan = true + AND ((SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) > 0 + OR (SELECT count(*) FROM pgz_sport.klub_sezona WHERE klub_id=k.id) > 0) + ORDER BY + (SELECT count(*) FROM pgz_sport.utakmice_log WHERE za_klub_id=k.id) DESC, + (SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=k.id) DESC + LIMIT 200 + """) + return {"count": len(rows), "klubovi": rows} + +@router.get("/audit/feed") +def audit_feed_recent(limit: int = 50): + """Recent audit events.""" + rows = db_query("""SELECT table_name, action, source, source_url, scraped_at, + changed_fields, details + FROM pgz_sport.audit_feed + ORDER BY scraped_at DESC LIMIT %s""", (limit,)) + return {"count": len(rows), "events": rows} + +# === END AUDIT_PATCH === + +# === GODISNJAK_SEARCH_PATCH === +@router.get("/dokumenti") +def dokumenti_list(vrsta: Optional[str] = None, godina: Optional[int] = None, limit: int = 100): + """List dokumenti.""" + where = []; args = [] + if vrsta: where.append("vrsta = %s"); args.append(vrsta) + if godina: where.append("godina = %s"); args.append(godina) + where_sql = " AND ".join(where) if where else "1=1" + args.append(limit) + rows = db_query(f"""SELECT id, title, vrsta, godina, izdano_datum, organizacija, + length(sadrzaj) AS chars, izvor_url, scraped_at + FROM pgz_sport.dokumenti + WHERE aktivan = true AND {where_sql} + ORDER BY godina DESC NULLS LAST, izdano_datum DESC NULLS LAST + LIMIT %s""", tuple(args)) + return {"count": len(rows), "dokumenti": rows} + +@router.get("/dokumenti/{did:int}") +def dokument_detail(did: int): + """Get dokument detail with full text or excerpt.""" + d = db_one("""SELECT id, title, vrsta, godina, izdano_datum, organizacija, kratak_opis, + izvor_url, sadrzaj, scraped_at + FROM pgz_sport.dokumenti WHERE id = %s""", (did,)) + if not d: raise HTTPException(404, "Dokument nije pronađen") + return d + +@router.get("/dokumenti/search/q") +def dokumenti_search(q: str, vrsta: Optional[str] = None, limit: int = 30): + """Full-text search kroz godišnjake i sve dokumente.""" + if not q or len(q) < 2: raise HTTPException(400, "Query premalen") + where_extra = "AND vrsta = %s" if vrsta else "" + args = [f"%{q}%", f"%{q}%"] + if vrsta: args.append(vrsta) + args.append(limit) + + rows = db_query(f""" + SELECT id, title, vrsta, godina, izdano_datum, izvor_url, + (CASE + WHEN POSITION(LOWER(%s) IN LOWER(sadrzaj)) > 100 + THEN '…' || SUBSTR(sadrzaj, GREATEST(1, POSITION(LOWER(%s) IN LOWER(sadrzaj)) - 100), 400) || '…' + ELSE SUBSTR(sadrzaj, 1, 400) || '…' + END) AS excerpt + FROM pgz_sport.dokumenti + WHERE LOWER(sadrzaj) LIKE LOWER(%s) {where_extra} + ORDER BY godina DESC NULLS LAST + LIMIT %s""", + tuple([q, q, f"%{q}%"] + ([vrsta] if vrsta else []) + [limit])) + return {"query": q, "count": len(rows), "rezultati": rows} +# === END GODISNJAK_SEARCH_PATCH === + + + + +@router.get("/dokumenti/{did:int}/pdf") +def dokumenti_pdf(did: int): + """Stream the original PDF file if available locally.""" + from fastapi.responses import FileResponse, JSONResponse + import os + rows = db_query("SELECT fname, vrsta, godina, title FROM pgz_sport.dokumenti WHERE id=%s", (did,)) + if not rows: + return JSONResponse({"error": "not found"}, status_code=404) + rec = rows[0] + # Try local paths + candidates = [] + if rec.get("fname"): + candidates.extend([ + f"/opt/pgz-sport/_data/godisnjaci/{rec['fname']}", + f"/opt/pgz-sport/_data/dokumenti/{rec['fname']}", + f"/opt/pgz-sport/_data/{rec['fname']}", + ]) + # Construct from godina/vrsta + if rec.get("godina") and rec.get("vrsta") == "godisnjak": + candidates.append(f"/opt/pgz-sport/_data/godisnjaci/godisnjak_{rec['godina']}.pdf") + for path in candidates: + if os.path.isfile(path): + return FileResponse(path, media_type="application/pdf", + filename=os.path.basename(path), + headers={"Cache-Control": "max-age=3600"}) + return JSONResponse({"error": "PDF file not found locally", "candidates": candidates}, status_code=404) + + +@router.get("/dokumenti/{did:int}/text") +def dokumenti_text(did: int): + """Return the full parsed text content of a document.""" + from fastapi.responses import PlainTextResponse, JSONResponse + rows = db_query("SELECT title, sadrzaj, vrsta, godina FROM pgz_sport.dokumenti WHERE id=%s", (did,)) + if not rows: + return JSONResponse({"error": "not found"}, status_code=404) + rec = rows[0] + if not rec.get("sadrzaj"): + return JSONResponse({"error": "no parsed text"}, status_code=404) + import re as _re; title = _re.sub(r"[^A-Za-z0-9_.-]", "_", rec.get("title", f"dokument_{did}")) + return PlainTextResponse(rec["sadrzaj"], headers={ + "Content-Disposition": f'inline; filename="{title}.txt"', + "Cache-Control": "max-age=3600" + }) + +@router.get("/klub/{kid}/natjecanja") +def klub_natjecanja(kid: int): + """Get all natjecanja a klub participates in (current sezona).""" + rows = db_query(""" + SELECT n.id, n.sport, n.naziv, n.razina, n.sezona, n.pgz_relevant, + t.pozicija, t.odigrano, t.pobjede, t.nerijeseno, t.porazi, t.bodovi + FROM pgz_sport.natjecanja_tablice t + JOIN pgz_sport.natjecanja n ON n.id = t.natjecanje_id + WHERE t.klub_id = %s + ORDER BY n.sport, n.razina""", (kid,)) + return {"count": len(rows), "natjecanja": rows} + +@router.get("/sportas/search") +def sportas_search(q: str = "", klub_id: Optional[int] = None, limit: int = 30): + """Search players by name.""" + where = ["1=1"]; args = [] + if q: + where.append("(LOWER(ime||' '||prezime) LIKE %s OR LOWER(prezime||' '||ime) LIKE %s)") + args.extend([f"%{q.lower()}%"]*2) + if klub_id: where.append("klub_id=%s"); args.append(klub_id) + args.append(limit) + rows = db_query(f"""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.slika_url, c.source, + k.naziv AS klub + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {" AND ".join(where)} + ORDER BY c.prezime, c.ime LIMIT %s""", tuple(args)) + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# DASHBOARD STATS +# ═══════════════════════════════════════════════════════ + +@router.get("/dashboard/sport-stats") +def dashboard_sport_stats(): + """High-level KPIs + top players for landing dashboard.""" + summary = db_one(""" + SELECT + (SELECT count(*) FROM pgz_sport.savezi WHERE aktivan=true) AS savezi, + (SELECT count(*) FROM pgz_sport.klubovi WHERE aktivan=true) AS klubovi, + (SELECT count(*) FROM pgz_sport.klubovi WHERE hns_klub_id IS NOT NULL) AS klubova_hns, + (SELECT count(*) FROM pgz_sport.clanovi) AS clanova, + (SELECT count(*) FROM pgz_sport.clanovi WHERE source='hns_semafor') AS sportasa_hns, + (SELECT count(distinct clan_id) FROM pgz_sport.utakmice_log) AS aktivnih_igraca, + (SELECT count(distinct source_match_id) FROM pgz_sport.utakmice_log) AS scraped_utakmica, + (SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log) AS ukupno_golova, + (SELECT COALESCE(sum(zuti_kartoni),0) FROM pgz_sport.utakmice_log) AS ukupno_zutih, + (SELECT COALESCE(sum(crveni_kartoni),0) FROM pgz_sport.utakmice_log) AS ukupno_crvenih + """) + + top_scorers = db_query(""" + SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime, + c.broj_dresa, c.pozicija, c.slika_url, k.naziv AS klub, + SUM(ul.pogodaka) AS pogodaka, + COUNT(*) AS nastupa + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.clanovi c ON c.id=ul.clan_id + JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE ul.pogodaka > 0 + GROUP BY c.id, c.ime, c.prezime, c.broj_dresa, c.pozicija, c.slika_url, k.naziv + ORDER BY pogodaka DESC, nastupa ASC + LIMIT 10""") + + top_appearances = db_query(""" + SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime, + c.broj_dresa, c.pozicija, c.slika_url, k.naziv AS klub, + COUNT(*) AS nastupa, + SUM(ul.minute) AS ukupno_minuta, + SUM(ul.pogodaka) AS pogoci + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.clanovi c ON c.id=ul.clan_id + JOIN pgz_sport.klubovi k ON k.id=c.klub_id + GROUP BY c.id, c.ime, c.prezime, c.broj_dresa, c.pozicija, c.slika_url, k.naziv + ORDER BY nastupa DESC, ukupno_minuta DESC NULLS LAST + LIMIT 10""") + + most_carded = db_query(""" + SELECT c.id, c.ime||' '||COALESCE(c.prezime,'') AS ime, + k.naziv AS klub, c.slika_url, + SUM(ul.zuti_kartoni) AS zutih, + SUM(ul.crveni_kartoni) AS crvenih, + COUNT(*) AS nastupa + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.clanovi c ON c.id=ul.clan_id + JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE ul.zuti_kartoni > 0 OR ul.crveni_kartoni > 0 + GROUP BY c.id, c.ime, c.prezime, c.slika_url, k.naziv + ORDER BY (SUM(ul.zuti_kartoni) + SUM(ul.crveni_kartoni)*2) DESC + LIMIT 10""") + + klub_breakdown = db_query(""" + SELECT k.id, k.naziv, k.sport, + COUNT(DISTINCT c.id) AS sportasa, + COUNT(DISTINCT ul.source_match_id) AS utakmica, + SUM(ul.pogodaka) AS pogoci + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id=k.id AND c.source='hns_semafor' + LEFT JOIN pgz_sport.utakmice_log ul ON ul.clan_id=c.id + WHERE k.hns_klub_id IS NOT NULL + GROUP BY k.id, k.naziv, k.sport + ORDER BY sportasa DESC NULLS LAST + LIMIT 20""") + + recent_matches = db_query(""" + SELECT DISTINCT ON (ul.source_match_id) + ul.source_match_id, ul.datum, ul.vrijeme, ul.natjecanje, + ul.klub_dom, ul.klub_dom_logo, ul.klub_gost, ul.klub_gost_logo, + ul.rezultat, ul.source_url + FROM pgz_sport.utakmice_log ul + ORDER BY ul.source_match_id, ul.datum DESC + LIMIT 20""") + + return { + "summary": summary, + "top_scorers": top_scorers, + "top_appearances": top_appearances, + "most_carded": most_carded, + "klub_breakdown": klub_breakdown, + "recent_matches": recent_matches, + "proracun_tek_god": float(db_exec("SELECT COALESCE(sum(iznos_eur),0) FROM pgz_sport.sufinanciranje_sport WHERE godina=EXTRACT(YEAR FROM NOW())::int") or 0), + "proracun_godina": int(db_exec("SELECT EXTRACT(YEAR FROM NOW())::int") or 2026), + "top_hoo": db_query("SELECT ime, prezime, hoo_kategorija, sport FROM pgz_sport.clanovi WHERE hoo_kategorija IN ('I','II','III') ORDER BY hoo_kategorija, sport LIMIT 20"), + } + + +# ═══════════════════════════════════════════════════════ +# RUČNI UNOS SPORTAŠA (klub admin / pgz_admin) +# ═══════════════════════════════════════════════════════ + +class CreateSportasReq(BaseModel): + ime: str + prezime: str + klub_id: int + datum_rodenja: Optional[str] = None + mjesto_rodenja: Optional[str] = None + broj_dresa: Optional[int] = None + pozicija: Optional[str] = None + dominantna_noga: Optional[str] = None + visina_cm: Optional[int] = None + tezina_kg: Optional[int] = None + slika_url: Optional[str] = None + oib: Optional[str] = None + biografija: Optional[str] = None + reprezentativac: Optional[bool] = False + reprezentacija_kategorija: Optional[str] = None + +@router.post("/sportas/create") +def create_sportas(req: CreateSportasReq, user = Depends(require_user)): + """Klub admin or pgz_admin can manually add a player to their club.""" + ut = user.get('user_type') + if ut not in ('super_admin','pgz_admin','pgz_user','savez_admin','savez_user','klub_admin','klub_user'): + raise HTTPException(403, "Forbidden — only admins can add sportaše") + + # Klub admin — must add to their own klub + if ut in ('klub_admin','klub_user'): + if user.get('klub_id') != req.klub_id: + # check via user_klub_links + link = db_one("SELECT 1 FROM pgz_sport.user_klub_links WHERE user_id=%s AND klub_id=%s", + (user['user_id'], req.klub_id)) + if not link: + raise HTTPException(403, "Možeš dodavati sportaše samo u svoj klub") + if ut in ('savez_admin','savez_user'): + if user.get('savez_id'): + klub = db_one("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (req.klub_id,)) + if klub and klub.get('savez_id') != user['savez_id']: + raise HTTPException(403, "Klub nije u tvom savezu") + + # Slug + name = (req.ime + ' ' + req.prezime).strip() + slug = re.sub(r'[^\w]+','-', name.lower()).strip('-') + + new_id = db_one("""INSERT INTO pgz_sport.clanovi + (ime, prezime, klub_id, datum_rodenja, mjesto_rodenja, broj_dresa, + pozicija, dominantna_noga, visina_cm, tezina_kg, slika_url, oib, + biografija, reprezentativac, reprezentacija_kategorija, + source, source_synced_at, slug) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'manual',now(),%s) + RETURNING id""", + (req.ime, req.prezime, req.klub_id, req.datum_rodenja, req.mjesto_rodenja, + req.broj_dresa, req.pozicija, req.dominantna_noga, req.visina_cm, req.tezina_kg, + req.slika_url, req.oib, req.biografija, req.reprezentativac, + req.reprezentacija_kategorija, slug))['id'] + + db_exec("INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,%s)", + (user['user_id'], f'sportas.create:{new_id}')) + return {"id": new_id, "ime": req.ime, "prezime": req.prezime, "klub_id": req.klub_id} + + +# ═══════════════════════════════════════════════════════ +# RUČNI UNOS UTAKMICA (klub admin → po igraču) +# ═══════════════════════════════════════════════════════ + +class CreateUtakmicaLogReq(BaseModel): + clan_id: int + za_klub_id: int + datum: str + natjecanje: Optional[str] = None + klub_dom: Optional[str] = None + klub_gost: Optional[str] = None + rezultat: Optional[str] = None + pogodaka: Optional[int] = 0 + zuti_kartoni: Optional[int] = 0 + crveni_kartoni: Optional[int] = 0 + minute: Optional[int] = None + zapocet_kao_starter: Optional[bool] = True + +@router.post("/utakmice/log") +def create_utakmica_log(req: CreateUtakmicaLogReq, user = Depends(require_user)): + """Klub admin can log a match for a sportaš in their klub.""" + ut = user.get('user_type') + if ut not in ('super_admin','pgz_admin','klub_admin','klub_user','savez_admin'): + raise HTTPException(403, "Forbidden") + + # Verify sportaš belongs to klub + c = db_one("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (req.clan_id,)) + if not c: raise HTTPException(404, "Sportaš ne postoji") + if ut in ('klub_admin','klub_user'): + if user.get('klub_id') and user['klub_id'] != c['klub_id']: + raise HTTPException(403, "Sportaš nije u tvom klubu") + + new_id = db_one("""INSERT INTO pgz_sport.utakmice_log + (clan_id, za_klub_id, datum, natjecanje, klub_dom, klub_gost, rezultat, + pogodaka, zuti_kartoni, crveni_kartoni, minute, zapocet_kao_starter, source) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'manual') + RETURNING id""", + (req.clan_id, req.za_klub_id, req.datum, req.natjecanje, + req.klub_dom, req.klub_gost, req.rezultat, req.pogodaka, + req.zuti_kartoni, req.crveni_kartoni, req.minute, req.zapocet_kao_starter))['id'] + + return {"id": new_id, "clan_id": req.clan_id} + + +# ═══════════════════════════════════════════════════════ +# OSOBE / FUNKCIONARI — sportski rukovoditelji PGŽ +# ═══════════════════════════════════════════════════════ + +@router.get("/osobe-funkcije/list") +def list_osobe_funkcije(sport: Optional[str] = None, savez_id: Optional[int] = None, + q: Optional[str] = None, limit: int = 100): + """List funkcionare (rukovoditelji saveza/klubova).""" + where = [] + params = [] + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + if savez_id: + where.append("savez_id = %s"); params.append(savez_id) + if q: + where.append("(ime ILIKE %s OR prezime ILIKE %s OR funkcija ILIKE %s OR organizacija ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"]) + where.append("o.aktivan = true") + sql = f""" + SELECT o.id, o.ime, o.prezime, o.funkcija, o.sport, o.organizacija, + o.izvor, o.izvor_url, o.mandate_od, o.mandate_do, + o.kontakt_email, o.kontakt_tel, o.savez_id, o.klub_id, + s.naziv AS savez_naziv, k.naziv AS klub_naziv + FROM pgz_sport.osobe_funkcije o + LEFT JOIN pgz_sport.savezi s ON s.id=o.savez_id + LEFT JOIN pgz_sport.klubovi k ON k.id=o.klub_id + WHERE {" AND ".join(where)} + ORDER BY o.organizacija NULLS LAST, o.sport NULLS LAST, o.prezime, o.ime + LIMIT %s""" + params.append(limit) + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/osobe-funkcije/by-sport") +def osobe_by_sport(): + """Group funkcionare by sport.""" + rows = db_query(""" + SELECT sport, count(*) AS osoba_count, + json_agg(json_build_object( + 'id', id, 'ime', ime, 'prezime', prezime, + 'funkcija', funkcija, 'organizacija', organizacija + ) ORDER BY ime) AS osobe + FROM pgz_sport.osobe_funkcije + WHERE sport IS NOT NULL AND aktivan=true + GROUP BY sport + ORDER BY sport""") + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# DOBNE KATEGORIJE (auto-assign po sportu i godini rođenja) +# ═══════════════════════════════════════════════════════ + +class AutoAssignReq(BaseModel): + datum_rodenja: str # YYYY-MM-DD + sport: str + referentna_godina: Optional[int] = None # default: tekuća + spol: Optional[str] = 'MIX' + +@router.get("/dobne-kategorije/list") +def list_dobne_kategorije(sport: Optional[str] = None): + """List sve dobne kategorije, optionally filtrirano po sportu.""" + where = ["aktivan = true"] + params = [] + if sport: + where.append("LOWER(sport) = LOWER(%s)") + params.append(sport) + sql = f"""SELECT id, sport, naziv, oznaka, min_godina, max_godina, + spol, organizacija, redoslijed, napomena, promocija_dozvoljena + FROM pgz_sport.dobne_kategorije + WHERE {' AND '.join(where)} + ORDER BY sport, redoslijed""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.post("/dobne-kategorije/auto-assign") +def auto_assign_categories(req: AutoAssignReq): + """Iz datuma rođenja + sporta vraća primjenjive kategorije. + Sportaš može biti u više kategorija (npr. mladi koji su pozvani u stariju selekciju). + + Returns: + primary: kategorija najbolje odgovara dobi + additional: ostale primjenjive kategorije (mlađe koje uključuju) + promocije: kategorije u koje se može promovirati (sljedeća stariju) + neeligible: kategorije za koje je presta(la)o pravo + """ + from datetime import date as _date, datetime as _dt + try: + dob = _dt.strptime(req.datum_rodenja, '%Y-%m-%d').date() + except: + raise HTTPException(400, "datum_rodenja mora biti YYYY-MM-DD format") + + ref_god = req.referentna_godina or _date.today().year + starost = ref_god - dob.year + + rows = db_query("""SELECT id, sport, naziv, oznaka, min_godina, max_godina, + organizacija, redoslijed, napomena, promocija_dozvoljena + FROM pgz_sport.dobne_kategorije + WHERE LOWER(sport) = LOWER(%s) AND aktivan = true + ORDER BY redoslijed""", (req.sport,)) + + if not rows: + return { + "starost": starost, + "datum_rodenja": req.datum_rodenja, + "sport": req.sport, + "primary": None, + "additional": [], + "promocije": [], + "neeligible": [], + "warning": f"Nema definiranih dobnih kategorija za sport '{req.sport}'" + } + + primary = None + additional = [] + promocije = [] + neeligible = [] + + # Pravilo: Sportaš pripada svim kategorijama gdje je njegova dob unutar [min, max]. + # Primarna = najmlađa (najniži redoslijed) gdje pripada. + # Promocije = sljedeća jedna ili dvije starije (mladi se često promoviraju u stariju selekciju). + # Neeligible = kategorije gdje je dob preuska/iznad max. + + eligible_idx = [] + for i, k in enumerate(rows): + mn = k['min_godina'] if k['min_godina'] is not None else 0 + mx = k['max_godina'] if k['max_godina'] is not None else 200 + in_range = mn <= starost <= mx + if in_range: + eligible_idx.append(i) + + if eligible_idx: + primary = rows[eligible_idx[0]] + for j in eligible_idx[1:]: + additional.append(rows[j]) + # Promocije = sljedeće 1-2 starije kategorije od primary (po redoslijed) + primary_redoslijed = primary['redoslijed'] + promocije_kandidati = [k for k in rows if k['redoslijed'] > primary_redoslijed and k.get('promocija_dozvoljena', True)] + # Filtriraj samo one čija je donja granica blizu starosti (max +3 godine razlike) + promocije = [] + for k in sorted(promocije_kandidati, key=lambda x: x['redoslijed'])[:3]: + mn = k['min_godina'] or 0 + if mn - starost <= 3: # može se promovirati ako je razlika ≤ 3 godine + promocije.append(k) + # Neeligible = kategorije gdje je presta(la)o pravo + neeligible = [k for k in rows if k['redoslijed'] < primary_redoslijed and + (k.get('max_godina') is not None) and starost > k['max_godina']] + else: + # Nije eligible u nijednu kategoriju (presta vrlo mlad ili previše star) + # Pokušaj naći najbližu nižu po starosti + for k in rows: + if k.get('max_godina') and starost > k['max_godina']: + neeligible.append(k) + else: + if not primary: + primary = k + else: + additional.append(k) + + return { + "starost": starost, + "datum_rodenja": req.datum_rodenja, + "referentna_godina": ref_god, + "sport": req.sport, + "primary": primary, + "additional": additional, + "promocije": promocije, + "neeligible": neeligible, + "ukupno_dostupno": len(rows), + } + + +@router.post("/sportas/{cid}/recalc-categories") +def recalc_sportas_categories(cid: int, sport: Optional[str] = None, user = Depends(require_user)): + """Re-izračunaj kategorije za sportaša na osnovi datuma rođenja + sport.""" + s = db_one("""SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.sport, + k.sport AS klub_sport, c.kategorije + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.id=%s""", (cid,)) + if not s: raise HTTPException(404, "Sportaš ne postoji") + if not s.get('datum_rodenja'): + return {"id": cid, "primary": None, "kategorije": [], "promocije": [], + "warning": "Nema datum_rodenja"} + + sport_use = sport or s.get('sport') or s.get('klub_sport') + if not sport_use: + return {"id": cid, "primary": None, "kategorije": [], "promocije": [], + "warning": "Nema sporta (klub_sport ni sport stupac)"} + + # Use auto_assign internally + from datetime import date as _date + dob_str = str(s['datum_rodenja'])[:10] + req = AutoAssignReq(datum_rodenja=dob_str, sport=sport_use) + result = auto_assign_categories(req) + + primary_oznaka = result['primary']['oznaka'] if result.get('primary') else None + primary_naziv = result['primary']['naziv'] if result.get('primary') else None + additional_ozn = [r['oznaka'] for r in result.get('additional', []) if r.get('oznaka')] + promocije_ozn = [r['oznaka'] for r in result.get('promocije', []) if r.get('oznaka')] + + # Save to clanovi.kategorije and promocija_kategorije + kategorije = [] + if primary_oznaka: kategorije.append(primary_oznaka) + kategorije.extend(additional_ozn) + + db_exec("""UPDATE pgz_sport.clanovi + SET kategorije=%s, + promocija_kategorije=%s, + sport=COALESCE(sport, %s), + auto_kategorija_calc_at=now() + WHERE id=%s""", + (kategorije, promocije_ozn, sport_use, cid)) + + return { + "id": cid, + "ime": s['ime'], "prezime": s.get('prezime'), + "starost": result['starost'], + "sport": sport_use, + "primary": primary_naziv, + "primary_oznaka": primary_oznaka, + "kategorije": kategorije, + "promocije": promocije_ozn, + "details": result, + } + + +@router.post("/sportas/recalc-all-categories") +def recalc_all_categories(user = Depends(require_user)): + """Bulk re-izračun kategorija za sve sportaše sa datum_rođenja + sport.""" + if user.get('user_type') not in ('super_admin', 'pgz_admin'): + raise HTTPException(403, "Forbidden — samo admin") + + rows = db_query("""SELECT c.id, c.datum_rodenja, COALESCE(c.sport, k.sport) AS sport + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.datum_rodenja IS NOT NULL""") + + n_updated = 0; n_skipped = 0; n_errors = 0 + from datetime import date as _date + for r in rows: + if not r.get('sport'): + n_skipped += 1; continue + try: + dob_str = str(r['datum_rodenja'])[:10] + req = AutoAssignReq(datum_rodenja=dob_str, sport=r['sport']) + result = auto_assign_categories(req) + primary_ozn = result['primary']['oznaka'] if result.get('primary') else None + additional_ozn = [k['oznaka'] for k in result.get('additional', []) if k.get('oznaka')] + promocije_ozn = [k['oznaka'] for k in result.get('promocije', []) if k.get('oznaka')] + kategorije = [] + if primary_ozn: kategorije.append(primary_ozn) + kategorije.extend(additional_ozn) + db_exec("""UPDATE pgz_sport.clanovi + SET kategorije=%s, promocija_kategorije=%s, sport=%s, + auto_kategorija_calc_at=now() WHERE id=%s""", + (kategorije, promocije_ozn, r['sport'], r['id'])) + n_updated += 1 + except Exception as e: + n_errors += 1 + + return {"updated": n_updated, "skipped_no_sport": n_skipped, "errors": n_errors} + + +@router.get("/dobne-kategorije/by-sport") +def kategorije_grouped(): + """Group kategorije po sportu — for GUI display.""" + rows = db_query("""SELECT sport, + json_agg(json_build_object( + 'id', id, 'naziv', naziv, 'oznaka', oznaka, + 'min_godina', min_godina, 'max_godina', max_godina, + 'organizacija', organizacija, 'redoslijed', redoslijed, + 'napomena', napomena, 'promocija_dozvoljena', promocija_dozvoljena + ) ORDER BY redoslijed) AS kategorije, + count(*) AS broj + FROM pgz_sport.dobne_kategorije WHERE aktivan=true + GROUP BY sport ORDER BY sport""") + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# DOKUMENTI / ZAKONI / PRAVILNICI — RAG search + AI agent +# ═══════════════════════════════════════════════════════ + +import requests as _rq + +QDRANT_URL = "http://10.10.0.2:6333" +DOK_COLL = "pgz_sport_dokumenti_v1" +EMBED_URL = "http://localhost:9879/api/embeddings" + +def _embed_query(text: str): + r = _rq.post(EMBED_URL, json={"model":"bge-m3","prompt":text}, timeout=20) + j = r.json() + return j.get('embedding') or (j.get('data') or [{}])[0].get('embedding') + +@router.get("/dokumenti/list") +def list_dokumenti(razina: Optional[str] = None, vrsta: Optional[str] = None, + organizacija: Optional[str] = None, sport: Optional[str] = None, + q: Optional[str] = None, limit: int = 200): + """Filterable list svih dokumenata.""" + where = ["COALESCE(aktivan,true)=true"] + params = [] + if razina: where.append("razina = %s"); params.append(razina) + if vrsta: where.append("vrsta = %s"); params.append(vrsta) + if organizacija: where.append("organizacija = %s"); params.append(organizacija) + if sport: where.append("LOWER(sport) = LOWER(%s)"); params.append(sport) + if q: + where.append("(title ILIKE %s OR kratak_opis ILIKE %s OR organizacija ILIKE %s)") + params.extend([f'%{q}%', f'%{q}%', f'%{q}%']) + sql = f"""SELECT id, title AS naziv, kratak_opis, vrsta, razina, organizacija, + sport, sluzbeni_glasnik, izvor_url, kljucne_rijeci, izdano_datum, + CASE WHEN sadrzaj IS NOT NULL THEN length(sadrzaj) ELSE 0 END AS bytes + FROM pgz_sport.dokumenti WHERE {' AND '.join(where)} + ORDER BY razina, vrsta, title LIMIT %s""" + params.append(limit) + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/dokumenti/by-razina") +def dokumenti_grouped(): + """Group po razini i vrsti — for dashboard.""" + rows = db_query("""SELECT razina, vrsta, count(*) AS broj + FROM pgz_sport.dokumenti + WHERE COALESCE(aktivan,true)=true + GROUP BY razina, vrsta ORDER BY razina, vrsta""") + return {"count": len(rows), "results": rows} + +@router.get("/dokumenti/{did:int}") +def get_dokument(did: int): + """Full dokument view with content.""" + d = db_one("""SELECT id, title AS naziv, kratak_opis, sadrzaj, vrsta, razina, + organizacija, sport, sluzbeni_glasnik, izvor_url, pdf_url, + kljucne_rijeci, izdano_datum, godina + FROM pgz_sport.dokumenti WHERE id=%s""", (did,)) + if not d: raise HTTPException(404, "Dokument ne postoji") + chunks = db_query("""SELECT id, chunk_index, chunk_text, chunk_tokens + FROM pgz_sport.dokument_chunks WHERE dokument_id=%s + ORDER BY chunk_index""", (did,)) + return {"dokument": d, "chunks": chunks, "chunks_count": len(chunks)} + +class DocSearchReq(BaseModel): + q: str + limit: Optional[int] = 10 + razina: Optional[str] = None + sport: Optional[str] = None + +@router.post("/dokumenti/search") +def search_dokumenti(req: DocSearchReq): + """RAG search — vector similarity search across chunks.""" + try: + vec = _embed_query(req.q) + if not vec: + raise HTTPException(500, "Embedding failed") + except Exception as e: + raise HTTPException(500, f"Embed error: {e}") + + qdrant_filter = None + must = [] + if req.razina: + must.append({"key":"razina","match":{"value": req.razina}}) + if req.sport: + must.append({"key":"sport","match":{"value": req.sport}}) + if must: + qdrant_filter = {"must": must} + + body = { + "vector": vec, + "limit": req.limit or 10, + "with_payload": True, + } + if qdrant_filter: body["filter"] = qdrant_filter + + r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search", + json=body, timeout=15) + if r.status_code != 200: + raise HTTPException(500, f"Qdrant error: {r.text[:200]}") + + hits = r.json().get("result", []) + + # Enrich with full chunk text + results = [] + seen_dok = set() + for h in hits: + p = h.get("payload", {}) + dok_id = p.get("dokument_id") + chunk = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks + WHERE dokument_id=%s AND chunk_index=%s""", + (dok_id, p.get("chunk_index", 0))) + results.append({ + "dokument_id": dok_id, + "naziv": p.get("title"), + "vrsta": p.get("vrsta"), + "razina": p.get("razina"), + "organizacija": p.get("organizacija"), + "sport": p.get("sport"), + "izvor_url": p.get("izvor_url"), + "score": round(h.get("score", 0), 4), + "snippet": (chunk["chunk_text"][:400] if chunk else p.get("preview","")) + "...", + }) + + return {"query": req.q, "count": len(results), "results": results} + +class DocAskReq(BaseModel): + q: str + limit_context: Optional[int] = 5 + +@router.post("/dokumenti/ask") +def ask_legal_expert(req: DocAskReq): + """AI legal expert — RAG + DeepSeek V3 odgovor s citiranjem.""" + # 1. RAG retrieval + try: + vec = _embed_query(req.q) + if not vec: + return {"answer":"Greška u embeddingu pitanja.","sources":[]} + except Exception as e: + return {"answer":f"Embedding greška: {e}","sources":[]} + + r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search", + json={"vector":vec, "limit":req.limit_context or 5, "with_payload":True}, + timeout=15) + hits = r.json().get("result", []) + + # 2. Build context with sources + context_parts = [] + sources = [] + for i, h in enumerate(hits): + p = h.get("payload", {}) + dok_id = p.get("dokument_id") + chunk = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks + WHERE dokument_id=%s AND chunk_index=%s""", + (dok_id, p.get("chunk_index", 0))) + text = chunk["chunk_text"] if chunk else p.get("preview","") + context_parts.append(f"[{i+1}] {p.get('title','?')} ({p.get('razina','?')} · {p.get('organizacija','?')}):\n{text}\n") + sources.append({ + "n": i+1, + "naziv": p.get("title"), + "razina": p.get("razina"), + "organizacija": p.get("organizacija"), + "izvor_url": p.get("izvor_url"), + "score": round(h.get("score",0),4), + }) + + context = "\n\n".join(context_parts) + + # 3. LLM via DeepSeek V3 + import os + +# ═══════════════════════════════════════════════════════════════════════════ +# ORCHESTRATOR DELEGATION (OS-first) +# Set USE_ORCHESTRATOR=1 in env to delegate sport queries to dabi-orchestrator +# instead of running pgz-sport's own LLM waterfall. +# Same brain, sport-domain prompt comes from orchestrator persona. +# ═══════════════════════════════════════════════════════════════════════════ +USE_ORCHESTRATOR = os.environ.get("USE_ORCHESTRATOR", "0") == "1" +ORCHESTRATOR_URL = os.environ.get("ORCHESTRATOR_URL", "http://localhost:8080/api/v3/ask") + +def delegate_to_orchestrator(question: str, persona: str = "sport", timeout: int = 60): + """Call dabi-orchestrator-v3 instead of running our own LLM waterfall. + Returns dict with answer + sources, compatible with sport_lawyer response.""" + import json, urllib.request + try: + body = json.dumps({"question": question, "persona": persona}).encode() + req = urllib.request.Request(ORCHESTRATOR_URL, data=body, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + d = json.loads(r.read()) + return { + "answer": d.get("answer", ""), + "sources": d.get("sources", []) or [], + "intent": d.get("intent"), + "confidence": d.get("confidence"), + "llm": "orchestrator_v3", + "delegated": True, + } + except Exception as e: + return {"answer": "", "delegated": False, "error": str(e)} + + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + try: + with open("/opt/.env.rinet") as f: + for line in f: + if line.startswith("DEEPSEEK_API_KEY="): + api_key = line.strip().split("=",1)[1].strip("'\"") + break + except: pass + + if not api_key: + return { + "answer":"AI agent nije konfiguriran (nedostaje DEEPSEEK_API_KEY). Vraćam sirove rezultate pretrage.", + "sources":sources, "context": context_parts + } + + system = """Ti si vrhunski hrvatski stručnjak za sport, zakone i pravilnike — radiš za Zajednicu sportova Primorsko-goranske županije. +Odgovaraš na hrvatskom, kratko, oštro, profesionalno (kao iskusan pravnik za sport). + +PRAVILA: +1. Koristi ISKLJUČIVO informacije iz priloženih dokumenata. NIKAD ne izmišljaj. +2. Citiraj izvore brojevima [1], [2] itd. nakon svake tvrdnje koja zahtijeva izvor. +3. Ako kontekst djelomično odgovara — daj odgovor na temelju onoga što imaš + jasno označi što fali. +4. Ako kontekst NE sadrži odgovor — kaži "U dostupnim dokumentima nema odgovora na ovo pitanje" + predloži koji bi dokument bio relevantan. +5. Strukturiraj odgovor: GLAVNI ODGOVOR → DETALJI po točkama → CITIRANI IZVORI [1], [2]… +6. Za pitanja o postupcima (registracija, transfer, kategorizacija) — daj numeriranu listu koraka. +7. Za pitanja o financiranju — istakni iznose, rokove, kriterije. +8. Za PGŽ-specifična pitanja — fokusiraj se na PGZ i Grad Rijeka razinu, ali napomeni i RH/HOO razinu kad je relevantno. +9. Spomeni i nadležnu instituciju (HNS, HOO, HASMS, MTS, ZS PGŽ, itd.). +10. Ako se citirani članci, brojevi NN-a ili slični specifikumi pojavljuju u kontekstu — uvijek ih navedi.""" + + user_msg = f"PITANJE: {req.q}\n\nKONTEKST DOKUMENATA:\n{context}\n\nOdgovor:" + + try: + resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={ + "model":"deepseek-chat", + "messages":[ + {"role":"system","content": system}, + {"role":"user","content": user_msg} + ], + "temperature": 0.2, + "max_tokens": 800 + }, timeout=30) + if resp.status_code != 200: + return {"answer":f"LLM error {resp.status_code}: {resp.text[:200]}","sources":sources} + data = resp.json() + answer = data.get("choices",[{}])[0].get("message",{}).get("content","") + return {"answer": answer, "sources": sources, "model":"deepseek-v3"} + except Exception as e: + return {"answer":f"LLM error: {e}","sources":sources, "context":context_parts} + + +# ═══════════════════════════════════════════════════════ +# SPORTSKI OBJEKTI +# ═══════════════════════════════════════════════════════ +@router.get("/objekti/list") +def list_objekti(grad: Optional[str] = None, tip: Optional[str] = None, sport: Optional[str] = None): + where = ["aktivan = true"] + params = [] + if grad: where.append("grad = %s"); params.append(grad) + if tip: where.append("tip = %s"); params.append(tip) + if sport: where.append("%s = ANY(sportovi)"); params.append(sport) + sql = f"""SELECT id, naziv, tip, grad, adresa, upravitelj, kapacitet, sportovi, + izgradeno, natkrita, web, napomena, lat, lng + FROM pgz_sport.sportski_objekti WHERE {' AND '.join(where)} + ORDER BY grad, naziv""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/objekti/by-grad") +def objekti_by_grad(): + rows = db_query("""SELECT grad, count(*) AS broj, + array_agg(DISTINCT tip) AS tipovi + FROM pgz_sport.sportski_objekti WHERE aktivan = true + GROUP BY grad ORDER BY broj DESC""") + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# NATJECANJA (366) +# ═══════════════════════════════════════════════════════ +@router.get("/natjecanja/list") +def list_natjecanja(sport: Optional[str] = None, sezona: Optional[str] = None, + razina: Optional[str] = None, limit: int = 100): + where = [] + params = [] + if sport: where.append("LOWER(sport) = LOWER(%s)"); params.append(sport) + if sezona: where.append("sezona = %s"); params.append(sezona) + if razina: where.append("razina = %s"); params.append(razina) + where_clause = " WHERE " + " AND ".join(where) if where else "" + sql = f"""SELECT id, naziv, sport, razina, tip, sezona, kategorija, spol, + datum_pocetka, datum_zavrsetka, status, source, external_url + FROM pgz_sport.natjecanja {where_clause} + ORDER BY datum_pocetka DESC NULLS LAST, naziv LIMIT %s""" + params.append(limit) + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# MANIFESTACIJE (113) +# ═══════════════════════════════════════════════════════ +@router.get("/manifestacije/list") +def list_manifestacije(savez_id: Optional[int] = None, mjesto: Optional[str] = None, limit: int = 200): + where = ["aktivna = true"] + params = [] + if savez_id: where.append("savez_id = %s"); params.append(savez_id) + if mjesto: where.append("mjesto ILIKE %s"); params.append(f"%{mjesto}%") + sql = f"""SELECT m.id, m.naziv, m.mjesto, m.organizator, m.razina, m.broj_ucesnika, + m.godina_od, m.spol_kategorija, m.napomena, s.naziv AS savez_naziv, m.savez_id + FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id = m.savez_id + WHERE {' AND '.join(where)} + ORDER BY m.naziv LIMIT %s""" + params.append(limit) + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# NAJBOLJI SPORTAŠI (22) +# ═══════════════════════════════════════════════════════ +@router.get("/najbolji/list") +def list_najbolji(godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("godina = %s"); params.append(godina) + sql = f"""SELECT id, godina, kategorija, ime_prezime, klub, sport, napomena + FROM pgz_sport.najbolji_sportasi + {('WHERE ' + ' AND '.join(where)) if where else ''} + ORDER BY godina DESC, kategorija""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# POTPORE NOSITELJIMA KVALITETE (182) +# ═══════════════════════════════════════════════════════ +@router.get("/potpore/list") +def list_potpore(godina: Optional[int] = None): + where = [] + params = [] + if godina: where.append("godina = %s"); params.append(godina) + sql = f"""SELECT p.id, p.naziv_kluba, p.godina, p.iznos, p.napomena, p.klub_id, k.sport + FROM pgz_sport.potpore_nositelji p + LEFT JOIN pgz_sport.klubovi k ON k.id = p.klub_id + {('WHERE ' + ' AND '.join(where)) if where else ''} + ORDER BY p.godina DESC, p.iznos DESC""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows, + "total_iznos": sum(float(r.get('iznos') or 0) for r in rows)} + +@router.get("/potpore/by-godina") +def potpore_by_godina(): + rows = db_query("""SELECT godina, count(*) AS broj, sum(iznos) AS ukupno + FROM pgz_sport.potpore_nositelji + GROUP BY godina ORDER BY godina DESC""") + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# STATISTIKA SAVEZA (166) +# ═══════════════════════════════════════════════════════ +@router.get("/statistika/list") +def list_statistika(godina: Optional[int] = None, savez_id: Optional[int] = None): + where = [] + params = [] + if godina: where.append("ss.godina = %s"); params.append(godina) + if savez_id: where.append("ss.savez_id = %s"); params.append(savez_id) + sql = f"""SELECT ss.id, ss.savez_id, s.naziv AS savez_naziv, ss.godina, + ss.klubova_clanica, ss.kategoriziranih, ss.registriranih, ss.rekreativaca, + ss.trenera, ss.reprezentativaca, ss.stipendiranih, ss.zaposlenika + FROM pgz_sport.statistika_saveza ss + LEFT JOIN pgz_sport.savezi s ON s.id = ss.savez_id + {('WHERE ' + ' AND '.join(where)) if where else ''} + ORDER BY ss.godina DESC, s.naziv""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +# ═══════════════════════════════════════════════════════ +# VIJESTI (286) +# ═══════════════════════════════════════════════════════ +@router.get("/vijesti/list") +def list_vijesti(limit: int = 30): + rows = db_query("""SELECT id, title AS naslov, scraped_at AS datum, kind AS kategorija, + substring(body, 1, 200) AS sazetak, url + FROM pgz_sport.vijesti + WHERE title IS NOT NULL + ORDER BY scraped_at DESC NULLS LAST LIMIT %s""", (limit,)) + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# SUCI / TRENERI / SPONZORI / MEDIJI / AKADEMSKI SPORT +# ═══════════════════════════════════════════════════════ + +@router.get("/suci/list") +def list_suci(sport: Optional[str] = None, grad: Optional[str] = None): + where = ["aktivan=true"]; params = [] + if sport: where.append("LOWER(sport)=LOWER(%s)"); params.append(sport) + if grad: where.append("LOWER(grad)=LOWER(%s)"); params.append(grad) + sql = f"""SELECT id, ime, prezime, sport, licenca, kategorija, organizacija, grad + FROM pgz_sport.suci WHERE {' AND '.join(where)} + ORDER BY sport, prezime, ime""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/treneri/list") +def list_treneri(sport: Optional[str] = None, klub_naziv: Optional[str] = None): + where = ["aktivan=true"]; params = [] + if sport: where.append("LOWER(sport)=LOWER(%s)"); params.append(sport) + if klub_naziv: where.append("klub_naziv ILIKE %s"); params.append(f"%{klub_naziv}%") + sql = f"""SELECT id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad + FROM pgz_sport.treneri WHERE {' AND '.join(where)} + ORDER BY sport, klub_naziv, pozicija, prezime""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/sponzori/list") +def list_sponzori(klub: Optional[str] = None): + where = ["aktivan=true"]; params = [] + if klub: where.append("naziv_kluba ILIKE %s"); params.append(f"%{klub}%") + sql = f"""SELECT id, naziv_kluba, sponzor, tip, razdoblje_od, razdoblje_do, iznos_eur, napomena + FROM pgz_sport.sponzori WHERE {' AND '.join(where)} + ORDER BY naziv_kluba, tip, sponzor""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/mediji/list") +def list_mediji(tip: Optional[str] = None, grad: Optional[str] = None): + where = ["aktivan=true"]; params = [] + if tip: where.append("tip=%s"); params.append(tip) + if grad: where.append("LOWER(grad)=LOWER(%s)"); params.append(grad) + sql = f"""SELECT id, naziv, tip, grad, vlasnik, web, sport_fokus, pokrivenost + FROM pgz_sport.mediji WHERE {' AND '.join(where)} + ORDER BY tip, naziv""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/akademski/list") +def list_akademski(): + rows = db_query("""SELECT id, naziv, fakultet, sveuciliste, sport, sportovi, voditelj, + web, razina, broj_clanova + FROM pgz_sport.akademski_sport WHERE aktivan=true + ORDER BY fakultet, naziv""") + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# HYBRID AI AGENT — SQL + RAG router +# ═══════════════════════════════════════════════════════ + +# Schema descriptor — što AI zna o bazi (kratko, fokusirano) +SCHEMA_HINT = """ +TABLES PGŽ SPORT (čitanje samo, koristi pgz_sport.X notaciju): + +-- KLUBOVI: 1086 PGŽ klubova +pgz_sport.klubovi (id, naziv, sport, oib, savez_id, grad, adresa, telefon, email, web, + godina_osnutka, broj_clanova, predsjednik, tajnik, region, sjediste) + +-- SPORTAŠI: 1129 +pgz_sport.clanovi (id, ime, prezime, klub_id, datum_rodenja, sport, spol, pozicija, + broj_dresa, dominantna_noga, kategorije TEXT[], promocija_kategorije TEXT[], oib, biografija, slika_url) + +-- SAVEZI: 220 +pgz_sport.savezi (id, naziv, razina, oib, sport, sjediste, predsjednik, web) + +-- SPORTSKI OBJEKTI: 60 +pgz_sport.sportski_objekti (id, naziv, tip, grad, adresa, upravitelj, kapacitet, + sportovi TEXT[], "izgrađeno", natkrita, web) + -- tip: 'stadion','dvorana','bazen','klizalište','marina','strelište','boćalište','kompleks','tenis kompleks',... + +-- SUCI PGŽ: 27 +pgz_sport.suci (id, ime, prezime, sport, licenca, kategorija, organizacija, grad, aktivan) + +-- TRENERI PGŽ: 30 +pgz_sport.treneri (id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad) + -- pozicija: 'glavni','pomoćni','kondicijski','konzultant' + +-- SPONZORI: 22 ugovora +pgz_sport.sponzori (id, naziv_kluba, sponzor, tip, razdoblje_od, iznos_eur, napomena) + +-- MEDIJI: 15 +pgz_sport.mediji (id, naziv, tip, grad, vlasnik, web, sport_fokus TEXT[], pokrivenost) + +-- AKADEMSKI SPORT UNIRI: 11 +pgz_sport.akademski_sport (id, naziv, fakultet, sveuciliste, sport, sportovi TEXT[], voditelj, web, razina, broj_clanova) + +-- NATJECANJA KALENDAR: 366 +pgz_sport.natjecanja (id, sport, naziv, razina, tip, sezona, kategorija, spol, + datum_pocetka, datum_zavrsetka, status, savez_id) + +-- MANIFESTACIJE: 113 +pgz_sport.manifestacije (id, naziv, mjesto, organizator, razina, broj_ucesnika, godina_od, savez_id) + +-- NAJBOLJI SPORTAŠI PGŽ: 22 godišnjih nagrada +pgz_sport.najbolji_sportasi (id, godina, kategorija, ime_prezime, klub, sport) + +-- POTPORE: 182 isplate +pgz_sport.potpore_nositelji (id, klub_id, naziv_kluba, godina, iznos) + -- iznosi su EUR + +-- STATISTIKA SAVEZA godišnja: 166 +pgz_sport.statistika_saveza (id, savez_id, godina, klubova_clanica, kategoriziranih, + registriranih, rekreativaca, trenera, reprezentativaca, stipendiranih, zaposlenika) + +-- DOBNE KATEGORIJE: 127 +pgz_sport.dobne_kategorije (id, sport, naziv, oznaka, min_godina, max_godina, organizacija) + +-- FUNKCIONARI saveza/klubova: 155 +pgz_sport.osobe_funkcije (id, ime, prezime, funkcija, sport, savez_id, klub_id, organizacija) + +-- VIJESTI: 286 +pgz_sport.vijesti (id, naslov, datum, kategorija, sazetak, url) + +-- DOKUMENTI / ZAKONI / PRAVILNICI: 176 (kolona naziv = title) +pgz_sport.dokumenti (id, title, kratak_opis, vrsta, razina, organizacija, sport, sluzbeni_glasnik, izvor_url, kljucne_rijeci, sadrzaj) + +-- UTAKMICE log: 5017 +pgz_sport.utakmice_log (id, datum, sport, klub_dom, klub_gost, rezultat, klub_id, sezona) +""" + +class HybridAskReq(BaseModel): + q: str + +@router.post("/ai/ask") +def hybrid_ai_ask(req: HybridAskReq): + """Hybrid agent — odluči SQL vs RAG vs oba. + Workflow: + 1. LLM klasificira pitanje (SQL / RAG / oba) + 2. Ako SQL: generira SELECT, izvršava, formira odgovor + 3. Ako RAG: vector search dokumentima + 4. Ako oba: kombinira + """ + import os + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + try: + with open("/opt/.env.rinet") as f: + for line in f: + if line.startswith("DEEPSEEK_API_KEY="): + api_key = line.strip().split("=",1)[1].strip("'\"") + break + except: pass + + if not api_key: + return {"answer":"AI agent nije konfiguriran","mode":"error"} + + # STEP 1: Classify + generate SQL if applicable + classify_prompt = f"""Ti si SQL ekspert za PGŽ sport bazu. Korisnik je pitao: + +PITANJE: {req.q} + +DOSTUPNE TABLICE: +{SCHEMA_HINT} + +ODLUČI: +1) SQL — ako pitanje traži operativne podatke iz tablica (imena trenera, popis objekata, statistike, brojevi, lokacije, najbolji) +2) RAG — ako pitanje traži pravne/regulativne info (zakoni, pravilnici, postupci, propisi, definicije) +3) BOTH — ako traži oba + +VRATI VALIDAN JSON OBJEKT (bez markdown): +{{"mode":"SQL"|"RAG"|"BOTH", "sql": "SELECT ...", "rag_query": "..."}} + +PRAVILA SQL: +- Koristi LIMIT 30 +- ILIKE za fuzzy match +- Koristi LOWER() za case-insensitive +- NIKAD UPDATE/DELETE/INSERT/DROP +- Samo SELECT iz pgz_sport.* tablica +- Koristi join-ove gdje treba + +PRIMJERI: +"Tko je trener HNK Rijeke?" → SQL: SELECT ime, prezime, pozicija, licenca FROM pgz_sport.treneri WHERE klub_naziv ILIKE '%HNK Rijeka%' +"Sportski objekti Rijeka" → SQL: SELECT naziv, tip, adresa, kapacitet, sportovi FROM pgz_sport.sportski_objekti WHERE grad='Rijeka' ORDER BY tip, naziv LIMIT 30 +"Najbolji sportaši 2025" → SQL: SELECT kategorija, ime_prezime, klub FROM pgz_sport.najbolji_sportasi WHERE godina=2025 +"Koje obveze ima sportski klub po Zakonu o sportu" → RAG: question +"Suci nogometa u PGŽ" → SQL: SELECT ime, prezime, licenca, kategorija FROM pgz_sport.suci WHERE sport='nogomet' +"Sponzori HNK Rijeka" → SQL: SELECT sponzor, tip, razdoblje_od FROM pgz_sport.sponzori WHERE naziv_kluba ILIKE '%HNK Rijeka%' +"Koliko klubova ima Boćarski savez PGŽ" → SQL: SELECT klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE s.naziv ILIKE '%Boćarski savez%' ORDER BY godina DESC LIMIT 1 +""" + + try: + resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type":"application/json"}, + json={ + "model":"deepseek-chat", + "messages":[{"role":"user","content": classify_prompt}], + "temperature": 0.0, "max_tokens": 500, + "response_format": {"type":"json_object"} + }, timeout=20) + plan = resp.json()["choices"][0]["message"]["content"] + import json as _json + plan_obj = _json.loads(plan) + except Exception as e: + return {"answer":f"Klasifikacija greška: {e}","mode":"error"} + + mode = plan_obj.get("mode","SQL").upper() + sql = plan_obj.get("sql","") + rag_q = plan_obj.get("rag_query", req.q) + + sql_results = [] + rag_sources = [] + + # STEP 2a: Execute SQL if needed + if mode in ("SQL","BOTH") and sql: + # Safety: only SELECT, only pgz_sport.* + sql_lower = sql.lower().strip() + if not sql_lower.startswith("select") or any(x in sql_lower for x in ["update ","delete ","insert ","drop ","alter ","create ","truncate ","grant ","revoke ","exec "]): + return {"answer":"SQL nije siguran","mode":"error","sql": sql} + try: + # Direct execution bez parameter binding to avoid % placeholder issues + import psycopg2 as _ps2, psycopg2.extras as _pse2 + with _ps2.connect(**DB) as _c: + _cur = _c.cursor(cursor_factory=_pse2.RealDictCursor) + _cur.execute(sql) # no params, raw SQL + sql_results = _cur.fetchall() if _cur.description else [] + except Exception as e: + return {"answer":f"SQL greška: {e}","mode":"sql_error","sql":sql} + + # STEP 2b: Execute RAG if needed + rag_context = "" + if mode in ("RAG","BOTH"): + try: + vec = _embed_query(rag_q) + r = _rq.post(f"{QDRANT_URL}/collections/{DOK_COLL}/points/search", + json={"vector":vec, "limit":4, "with_payload":True}, timeout=15) + hits = r.json().get("result",[]) + for i, h in enumerate(hits): + p_data = h.get("payload",{}) + ch = db_one("""SELECT chunk_text FROM pgz_sport.dokument_chunks + WHERE dokument_id=%s AND chunk_index=%s""", + (p_data.get("dokument_id"), p_data.get("chunk_index",0))) + txt = ch["chunk_text"] if ch else p_data.get("preview","") + rag_context += f"[{i+1}] {p_data.get('title','?')}: {txt[:600]}\n\n" + rag_sources.append({"n":i+1, "naziv":p_data.get("title"), + "razina":p_data.get("razina"), "organizacija":p_data.get("organizacija"), + "izvor_url":p_data.get("izvor_url"), "score": round(h.get("score",0),3)}) + except Exception as e: + rag_context = "" + + # STEP 3: Final answer + final_prompt = f"""Ti si stručnjak za PGŽ sport. Korisnik pitao: + +PITANJE: {req.q} + +""" + if sql_results: + import json as _json + final_prompt += f"REZULTATI SQL UPITA ({len(sql_results)} redaka):\n{_json.dumps(sql_results, ensure_ascii=False, default=str)[:3000]}\n\n" + if rag_context: + final_prompt += f"PRAVNI/REGULATIVNI KONTEKST:\n{rag_context}\n\n" + + final_prompt += """Daj kratak, precizan, profesionalan odgovor na hrvatskom. +Koristi konkretne podatke iz SQL rezultata. +Citiraj pravne izvore brojevima [1][2] ako koristiš RAG. +Ako rezultati nisu dovoljni, kaži to iskreno. +Ne ponavljaj cijele tablice — sažmi i istakni najvažnije.""" + + try: + resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type":"application/json"}, + json={ + "model":"deepseek-chat", + "messages":[{"role":"user","content": final_prompt}], + "temperature": 0.2, "max_tokens": 800 + }, timeout=30) + answer = resp.json()["choices"][0]["message"]["content"] + except Exception as e: + return {"answer":f"Final greška: {e}","mode":mode,"sql":sql,"sql_results":sql_results[:3]} + + return { + "answer": answer, + "mode": mode, + "sql": sql if sql else None, + "sql_count": len(sql_results), + "sql_sample": sql_results[:5] if sql_results else None, + "sources": rag_sources if rag_sources else None, + } + + +# ═══════════════════════════════════════════════════════ +# TEXT-TO-SQL AGENT — operativna pitanja preko SQL +# ═══════════════════════════════════════════════════════ + +# Whitelist tablica koje SQL agent smije čitati +SQL_AGENT_TABLES = { + 'klubovi': 'Sportski klubovi PGŽ. Stupci: id, naziv, sport, oib, iban, web, email, telefon, adresa, grad, region, godina_osnutka, predsjednik, tajnik, broj_clanova, savez_id', + 'savezi': 'Sportski savezi (županijski/gradski/nacionalni). Stupci: id, naziv, razina, oib, sjediste, web, email', + 'clanovi': 'Sportaši. Stupci: id, ime, prezime, klub_id (može biti NULL!), sport, datum_rodenja, spol, kategorije TEXT[] (dobne kat npr. {U17} ili {OPEN}), pozicija, broj_dresa, oib, hoo_kategorija TEXT (rimski I/II/III/IV/V/VI), hoo_vrijedi_od/do DATE, klub_naziv_godisnjak TEXT (KORISTI OVO za pretragu klub-roster, npr. HNK Rijeka roster preko klub_naziv_godisnjak ILIKE \'%HNK Rijeka%\'). NAPOMENE: za broj sportaša u klubu, koristi klub_naziv_godisnjak ILIKE \'%X%\' (mnogi sportaši nemaju klub_id, ali imaju ime kluba u godišnjaku). NE JOIN-aj s dobne_kategorije.', + 'sportski_objekti': 'Sportske građevine PGŽ. Stupci: id, naziv, tip, grad, adresa, upravitelj, kapacitet, sportovi (array), izgradeno (BEZ Č - godina izgradnje), natkrita (bool), web. NAPOMENA: tip može biti dvorana/stadion/bazen/marina/klizalište/skijaški/strelište/boćalište.', + 'suci': 'Suci po sportovima. Stupci: id, ime, prezime, sport, licenca, kategorija, organizacija, grad', + 'treneri': 'Treneri klubova PGŽ. Stupci: id, ime, prezime, sport, licenca, organizacija, klub_naziv, pozicija, grad', + 'sponzori': 'Sponzorstva klubova. Stupci: id, naziv_kluba, sponzor, tip, razdoblje_od, iznos_eur, napomena', + 'mediji': 'Sportski mediji PGŽ. Stupci: id, naziv, tip, grad, vlasnik, web, sport_fokus (array), pokrivenost', + 'akademski_sport': 'UNIRI sportski klubovi. Stupci: id, naziv, fakultet, sport, sportovi (array), voditelj, web, razina, broj_clanova', + 'natjecanja': 'Sportska natjecanja. Stupci: id, naziv, sport, savez_id, razina, tip, sezona, kategorija, datum_pocetka, datum_zavrsetka, status', + 'manifestacije': 'Sportske manifestacije. Stupci: id, naziv, mjesto, organizator, razina, broj_ucesnika, godina_od, savez_id', + 'najbolji_sportasi': 'Godišnji najbolji/najuspješniji sportaši PGŽ (godišnja izborna lista). Stupci: id, godina, kategorija (npr. "Najuspješniji sportaš senior", "Najuspješnija sportašica seniorka", "Najuspješniji trener", "Sportski djelatnik godine", "Najuspješnija muška seniorska ekipa", "Najuspješniji parasportaš (motor/slijepi)", "Najuspješniji sportaš junior/kadet"), ime_prezime, klub, sport, napomena. NAPOMENE: za pretragu po kategoriji koristi ILIKE "%trener%" ili "%djelatnik%" jer kategorije imaju duga imena.', + 'potpore_nositelji': 'Financijske potpore klubovima. Stupci: id, naziv_kluba, klub_id, godina, iznos, napomena', + 'statistika_saveza': 'Godišnja statistika saveza. Stupci: id, savez_id, godina, klubova_clanica, kategoriziranih, registriranih, rekreativaca, trenera, reprezentativaca, stipendiranih', + 'vijesti': 'Sportske vijesti. Stupci: id, naslov, datum, kategorija, sazetak, url', + 'osobe_funkcije': 'Funkcionari klubova/saveza. Stupci: id, ime, prezime, funkcija, sport, savez_id, klub_id, organizacija', + 'dobne_kategorije': 'Dobne kategorije po sportu. Stupci: id, sport, naziv, oznaka, min_godina, max_godina, organizacija', + 'sportas_specifika': 'Sport-specifični podaci sportaša (1:N s clanovima). Stupci: id, clan_id, sport, pojas_titula (npr. "Velemajstor (GM)", "Olimpijka", "CMAS instructor"), rating, rating_sustav (FIDE/ATP/WA), najbolji_rezultat, najbolja_godina, hns_id, visina_cm, tezina_kg, nogometska_pozicija, napomena.', + 'klub_sezona': 'Klubovi po sezoni i trofejima. Stupci: id, klub_id, klub_naziv, sezona (npr. "2024/2025"), natjecanje (npr. "1. HNL", "PH rukomet", "SP Szekesfehérvár"), plasiranje, bodovi, trofej (npr. "PRVAK HRVATSKE", "OSVAJAČI KUPA", "SVJETSKO ZLATO"), napomena.', + 'utakmice_log': 'Utakmice log. Stupci: id, klub_id, sportas_id, datum, protivnik, rezultat, golovi, asistencije, zuti, crveni, sezona, sport, broj_natjecanja', +} + +def _db_schema_brief(): + parts = [] + for tbl, desc in SQL_AGENT_TABLES.items(): + parts.append(f"pgz_sport.{tbl} — {desc}") + return "\n".join(parts) + +def _sql_safe(sql: str) -> bool: + """Verify SQL is read-only and only touches whitelist tables.""" + sql_lower = sql.lower().strip() + # Block destructive + forbidden = ['insert', 'update', 'delete', 'drop', 'truncate', 'alter', 'create', 'grant', 'revoke', '--', ';--', 'pg_', 'into ', 'copy ', 'replace '] + for f in forbidden: + if f in sql_lower: return False + # Must be SELECT + if not sql_lower.startswith('select'): return False + # Multiple statements not allowed + if sql_lower.rstrip(';').count(';') > 0: return False + return True + +class AskSmartReq(BaseModel): + q: str + limit_context: Optional[int] = 5 + +@router.post("/dokumenti/ask-smart") +def ask_smart(req: AskSmartReq): + """Smart ask: prvo procijeni pitanje (operativno vs regulativno), + onda izvrši SQL (operativno) ili RAG (regulativno). + """ + import os + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + try: + with open("/opt/.env.rinet") as f: + for line in f: + if line.startswith("DEEPSEEK_API_KEY="): + api_key = line.strip().split("=",1)[1].strip("\'\"") + break + except: pass + + if not api_key: + return {"answer":"DEEPSEEK_API_KEY missing","sources":[],"mode":"error"} + + # Step 1: classify + classify_msg = f"""Pitanje korisnika: "{req.q}" + +Kategorija pitanja: +A) OPERATIVNO — pita konkretne entitete iz baze: + - Imena pojedinačnih trenera/sudaca/sportaša ("tko je X", "tko trenira Y") + - Liste s konkretnim redovima (objekti u Rijeci, sponzori HNK, suci nogomet) + - Brojevi entiteta (koliko klubova, sportaša, sudaca - count po kriteriju) + - Top N po metriki (top 5 klubova, najmlađi, najstariji) + - Trofeji klub_sezona (HNK Rijeka, KK Mlaka) + - HOO kategorije (I-VI), najbolji godine +B) REGULATIVNO/OPISNO — RAG iz dokumenata: + - Zakoni, pravilnici, statuti, etika, fair play + - Postupci (kako se registrira, licencira, kategorizira) + - Definicije pojmova + - PRORAČUNI ZS PGŽ (proračun, financiranje, potpore) + - PROGRAMI ZS PGŽ (programi, programske aktivnosti, sufinanciranje) + - SPORTSKI PREGLEDI (zdravstveni pregledi, ambulanta) + - Razvoj sporta, statistički pregledi cijele PGŽ scene + - Najuspješniji sportovi međunarodno + - Općenita pitanja "tko/što su X" ako nije named entity + +Odgovor SAMO jednim slovom: A ili B""" + + try: + cl_resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model":"deepseek-chat", + "messages":[{"role":"user","content": classify_msg}], + "temperature":0.0, "max_tokens":5}, timeout=20) + clss = cl_resp.json().get("choices",[{}])[0].get("message",{}).get("content","B").strip().upper() + mode = "SQL" if clss.startswith("A") else "RAG" + except: + mode = "RAG" + + # Step 2: SQL or RAG + if mode == "SQL": + # Generate SQL + schema = _db_schema_brief() + sql_prompt = f"""Ti si PostgreSQL ekspert. Schema: + +{schema} + +Pravila: +- KORISTI SAMO SELECT, nikad INSERT/UPDATE/DELETE/DROP/ALTER +- Schema je `pgz_sport.` +- Vrati SAMO čisti SQL, bez markdown ```, bez objašnjenja +- LIMIT 50 ako vraćaš listu +- Za pretragu po imenu koristi ILIKE '%text%' +- Datum_rodenja je DATE format +- Za pretragu sportaša po kategoriji: WHERE 'U17' = ANY(c.kategorije) — NIKAD JOIN s dobne_kategorije +- Stupci s kvačicama: koristi izgradeno (NE izgrađeno), velicina (NE veličina) — uvijek ASCII +- Koristi LOWER() za case-insensitive equals: WHERE LOWER(grad) = LOWER('rijeka') + +Primjeri: +P: "Tko su sportaši I. kategorije?" → SELECT ime, prezime, sport, klub_naziv_godisnjak FROM pgz_sport.clanovi WHERE hoo_kategorija='I' ORDER BY sport, prezime LIMIT 50 +P: "Najbolji trener 2025?" → SELECT ime_prezime, klub, sport FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%trener%' +P: "Sportski djelatnik 2025?" → SELECT ime_prezime, klub FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%djelatnik%' +P: "Koliko sportaša HNK Rijeka?" → SELECT count(*) FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%nogometni klub%Rijeka%' OR klub_naziv_godisnjak ILIKE '%HNK Rijeka%' (NAPOMENA: u DB je "Hrvatski nogometni klub \"Rijeka\"") +P: "VK Primorje juniori roster?" → SELECT ime, prezime, sport FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%Primorje EB%' OR klub_naziv_godisnjak ILIKE '%Primorje%vaterpolo%' +P: "VK Primorje juniori?" → SELECT ime, prezime FROM pgz_sport.clanovi WHERE klub_naziv_godisnjak ILIKE '%Primorje%' AND napomena ILIKE '%junior%' +P: "Koliko vrhunskih u karateu?" → SELECT count(*) FROM pgz_sport.clanovi WHERE LOWER(sport)='karate' AND hoo_kategorija IN ('I','II') +P: "Tko trenira X?" → SELECT ime, prezime, pozicija FROM pgz_sport.treneri WHERE klub_naziv ILIKE '%X%' +P: "Koliko sportaša u U17?" → SELECT count(*) FROM pgz_sport.clanovi WHERE 'U17' = ANY(kategorije) +P: "Stadioni s preko 5000 mjesta" → SELECT naziv, kapacitet FROM pgz_sport.sportski_objekti WHERE tip='stadion' AND kapacitet>5000 +P: "Sportaši po kategoriji nogomet" → SELECT kat, count(*) FROM pgz_sport.clanovi c, unnest(c.kategorije) AS kat WHERE c.sport='nogomet' GROUP BY kat ORDER BY count(*) DESC +P: "Sponzori najveći iznos" → SELECT sponzor, naziv_kluba, iznos_eur FROM pgz_sport.sponzori WHERE iznos_eur IS NOT NULL ORDER BY iznos_eur DESC LIMIT 10 +P: "Ukupno potpora 2026" → SELECT sum(iznos) FROM pgz_sport.potpore_nositelji WHERE godina=2026 +P: "Statistika saveza po klubovima" → SELECT s.naziv, ss.klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE ss.godina=2024 ORDER BY ss.klubova_clanica DESC LIMIT 10 +P: "Tko su svjetski prvaci PGŽ 2025?" → SELECT ime_prezime, klub, sport, napomena FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%SVJETSKI%' +P: "Trofeji HNK Rijeka 2024/25?" → SELECT natjecanje, plasiranje, trofej FROM pgz_sport.klub_sezona WHERE klub_naziv ILIKE '%HNK Rijeka%' AND sezona='2024/2025' +P: "Nagrade za životno djelo 2025?" → SELECT ime_prezime, klub, sport, napomena FROM pgz_sport.najbolji_sportasi WHERE godina=2025 AND kategorija ILIKE '%životno djelo%' +P: "Sport-spec za Sara Kolak?" → SELECT s.* FROM pgz_sport.sportas_specifika s JOIN pgz_sport.clanovi c ON c.id=s.clan_id WHERE c.ime='Sara' AND c.prezime='Kolak' +P: "Top saveza po klubovima 2025" → SELECT s.naziv, ss.klubova_clanica FROM pgz_sport.statistika_saveza ss JOIN pgz_sport.savezi s ON s.id=ss.savez_id WHERE ss.godina=2025 AND ss.klubova_clanica IS NOT NULL ORDER BY ss.klubova_clanica DESC NULLS LAST LIMIT 10 +P: "Predsjednik X saveza" → SELECT o.ime, o.prezime, o.funkcija FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE s.naziv ILIKE '%X%' AND o.funkcija ILIKE '%predsjednik%' +P: "Tko je predsjednik Parasportskog saveza" → SELECT o.ime, o.prezime FROM pgz_sport.osobe_funkcije o JOIN pgz_sport.savezi s ON s.id=o.savez_id WHERE s.naziv ILIKE 'Parasportski%' AND o.funkcija ILIKE '%predsjednik%' +P: "Klubovi u parasportskom savezu" → SELECT k.naziv, k.sport, k.grad FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE s.naziv ILIKE 'Parasportski%' ORDER BY k.naziv +P: "Koje sportove pokriva X savez" → SELECT DISTINCT k.sport FROM pgz_sport.klubovi k JOIN pgz_sport.savezi s ON s.id=k.savez_id WHERE s.naziv ILIKE '%X%' +P: "Sport sportaša X" → SELECT DISTINCT sport FROM pgz_sport.najbolji_sportasi WHERE ime_prezime ILIKE '%X%' UNION SELECT DISTINCT sport FROM pgz_sport.clanovi WHERE (ime || ' ' || prezime) ILIKE '%X%' + + +Pitanje: {req.q} + +SQL:""" + try: + sg_resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model":"deepseek-chat", + "messages":[{"role":"user","content": sql_prompt}], + "temperature":0.0, "max_tokens":300}, timeout=20) + raw = sg_resp.json().get("choices",[{}])[0].get("message",{}).get("content","").strip() + # Strip markdown if present + sql = raw.replace("```sql","").replace("```","").strip() + if not _sql_safe(sql): + return {"mode":"SQL_BLOCKED","answer":"Generirani SQL nije siguran. Pokušaj drugo pitanje.","sql_attempt":sql,"sources":[]} + + # Execute + try: + # Use psycopg2 directly to avoid % placeholder collision with ILIKE + _conn = psycopg2.connect(**DB) + _cur = _conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + _cur.execute(sql) + rows = _cur.fetchall() if _cur.description else [] + _conn.close() + except Exception as e: + return {"mode":"SQL_ERROR","answer":f"SQL greška: {e}","sql":sql,"sources":[]} + + # Generate natural answer + data_str = json.dumps(rows[:30], default=str, ensure_ascii=False) + ans_prompt = f"""Pitanje: {req.q} +Rezultati iz baze (JSON): +{data_str} + +Sastavi kratak, konkretan odgovor na hrvatskom jeziku. Ako rezultata nema, kaži to.""" + try: + ans_resp = _rq.post("https://api.deepseek.com/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model":"deepseek-chat", + "messages":[{"role":"user","content": ans_prompt}], + "temperature":0.2, "max_tokens":600}, timeout=20) + answer = ans_resp.json().get("choices",[{}])[0].get("message",{}).get("content","") + except Exception as e: + answer = f"Pronađeno {len(rows)} zapisa. (LLM error: {e})" + + return {"mode":"SQL","answer":answer,"sql":sql,"row_count":len(rows), + "rows":rows[:30],"sources":[]} + except Exception as e: + return {"mode":"SQL_FAIL","answer":f"Greška u SQL agentu: {e}","sources":[]} + + # RAG fallback + return ask_legal_expert(DocAskReq(q=req.q, limit_context=req.limit_context)) + +import json + + +# ═══ HOO KATEGORIZIRANI SPORTAŠI ═══ +@router.get("/kategorizirani/list") +def list_kategorizirani(kategorija: Optional[str] = None, sport: Optional[str] = None): + where = ["c.hoo_kategorija IS NOT NULL"]; params = [] + if kategorija: where.append("c.hoo_kategorija=%s"); params.append(kategorija) + if sport: where.append("LOWER(c.sport)=LOWER(%s)"); params.append(sport) + sql = f"""SELECT c.id, c.ime, c.prezime, c.hoo_kategorija, c.sport, + c.hoo_kategorija_od, c.hoo_kategorija_do, c.mjesto_rodenja, + k.naziv AS klub_naziv + FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {' AND '.join(where)} + ORDER BY c.hoo_kategorija, c.sport, c.prezime, c.ime""" + rows = db_query(sql, params) + return {"count": len(rows), "results": rows} + +@router.get("/kategorizirani/by-sport") +def kategorizirani_by_sport(): + rows = db_query("""SELECT sport, hoo_kategorija, count(*) AS broj + FROM pgz_sport.clanovi WHERE hoo_kategorija IS NOT NULL + GROUP BY sport, hoo_kategorija ORDER BY sport, hoo_kategorija""") + return {"count": len(rows), "results": rows} + +@router.get("/statistika-2025") +def stats_2025(): + """Brojevi sportaša po savezu/sportu prema Sportskom godišnjaku ZS PGŽ 2025.""" + rows = db_query("""SELECT s.naziv AS savez, ss.godina, ss.registriranih + FROM pgz_sport.statistika_saveza ss + JOIN pgz_sport.savezi s ON s.id=ss.savez_id + WHERE ss.godina=2025 AND ss.registriranih > 0 + ORDER BY ss.registriranih DESC""") + return {"count": len(rows), "results": rows, + "ukupno": sum(r['registriranih'] for r in rows), + "izvor": "Sportski godišnjak ZS PGŽ 2025"} + +# === HNS Semafor stil endpointi (29.04.2026 sprint) === + +@router.get("/klubovi/{kid}/clanovi") +def klub_clanovi_pregled(kid: int): + """HNS Semafor stil pregled članstva - svi sportaši kluba sa kratkom statistikom.""" + klub = db_one("""SELECT id, naziv, sport, razina, region, grad, godina_osnutka, + adresa, telefon, web, hns_klub_id, hns_slug, logo_url, source_synced_at + FROM pgz_sport.klubovi WHERE id=%s""", (kid,)) + if not klub: + raise HTTPException(404, "Klub nije pronađen") + + # Sportaši + agg per igrača + sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.broj_dresa, c.pozicija, + c.datum_rodenja, c.mjesto_rodenja, c.uloga, c.reprezentativac, c.aktivan, + c.source, c.source_id, c.source_url, + (SELECT count(*) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS nastupa_total, + (SELECT COALESCE(sum(pogodaka),0) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS pogoci_total, + (SELECT COALESCE(sum(minute),0) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS minute_total, + (SELECT max(datum) FROM pgz_sport.utakmice_log u WHERE u.clan_id=c.id) AS zadnja_utakmica + FROM pgz_sport.clanovi c + WHERE c.klub_id=%s + ORDER BY + CASE c.uloga + WHEN 'predsjednik' THEN 1 + WHEN 'dopredsjednik' THEN 2 + WHEN 'tajnik' THEN 3 + WHEN 'direktor' THEN 4 + WHEN 'član uprave' THEN 5 + WHEN 'član nadzornog odbora' THEN 6 + WHEN 'team_manager' THEN 7 + WHEN 'trener' THEN 10 + WHEN 'pomocni_trener' THEN 11 + WHEN 'trener_vratara' THEN 12 + WHEN 'kondicioni_trener' THEN 13 + WHEN 'fizioterapeut' THEN 14 + WHEN 'lijecnik' THEN 15 + WHEN 'analiticar' THEN 16 + WHEN 'video_analiticar' THEN 17 + WHEN 'igrac' THEN 50 + WHEN 'sportaš' THEN 51 + WHEN 'sportas' THEN 51 + WHEN 'sudac' THEN 60 + WHEN 'ostalo' THEN 90 + ELSE 99 + END, + c.broj_dresa NULLS LAST, c.prezime, c.ime""", (kid,)) + + return { + "klub": klub, + "count": len(sportasi), + "sportasi": sportasi, + "izvor": klub.get("source_synced_at") and "HNS Semafor (auto-sync)" or "Ručno" + } + + +@router.get("/klubovi/sa-clanstvom") +def klubovi_sa_clanstvom(sport: str = None, region: str = "PGŽ", limit: int = 100, offset: int = 0): + """Lista klubova sa brojem članova i izvorom podataka.""" + where = ["k.aktivan=true"]; args = [] + if sport: where.append("k.sport=%s"); args.append(sport) + if region: where.append("k.region=%s"); args.append(region) + where_sql = " AND ".join(where) + args.extend([limit, offset]) + rows = db_query(f"""SELECT k.id, k.naziv, k.sport, k.razina, k.grad, k.godina_osnutka, + k.logo_url, k.hns_klub_id, k.hns_slug, k.source_synced_at, + (SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id) AS broj_clanova, + (SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id AND c.source='hns_semafor') AS hns_clanova + FROM pgz_sport.klubovi k + WHERE {where_sql} + ORDER BY broj_clanova DESC, k.naziv + LIMIT %s OFFSET %s""", tuple(args)) + return {"count": len(rows), "klubovi": rows} + + + +@router.get("/clanovi") +def list_clanovi(sport: Optional[str] = None, klub_id: Optional[int] = None, + kategorija_min: Optional[int] = None, reprezentativac: Optional[bool] = None, + spol: Optional[str] = None, uloga: Optional[str] = None, + q: Optional[str] = None, limit: int = 200, offset: int = 0): + """Filterable list of clanovi for showSportasiModal.""" + where = ["c.aktivan IS NOT FALSE"] + params = [] + if sport: + where.append("(LOWER(c.sport) = LOWER(%s) OR LOWER(k.sport) = LOWER(%s))") + params.extend([sport, sport]) + if klub_id: where.append("c.klub_id = %s"); params.append(klub_id) + if kategorija_min: where.append("c.kategorija_hoo IS NOT NULL AND c.kategorija_hoo <= %s"); params.append(kategorija_min) + if reprezentativac is not None: where.append("c.reprezentativac = %s"); params.append(reprezentativac) + if spol: where.append("c.spol = %s"); params.append(spol) + if uloga: where.append("c.uloga = %s"); params.append(uloga) + if q: + where.append("(c.ime ILIKE %s OR c.prezime ILIKE %s OR k.naziv ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + + sql = f"""SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.spol, + c.kategorija_hoo, c.reprezentativac, c.slika_url, c.broj_dres AS broj_dresa, + c.pozicija, c.klub_id, k.naziv AS klub_naziv, + c.datum_rodjenja, c.godina_rodenja, c.mjesto_rodjenja + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE {' AND '.join(where)} + ORDER BY c.kategorija_hoo NULLS LAST, c.prezime, c.ime + LIMIT %s OFFSET %s""" + params.extend([limit, offset]) + rows = db_query(sql, params) + return {"count": len(rows), "data": rows} + +@router.get("/clanovi/{cid}/full-profile") +def clan_full_profile(cid: int): + """HNS Semafor stil kompletni profil sportaša - dovoljno za GUI render.""" + sp = db_one("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.broj_dresa, c.pozicija, + c.datum_rodenja, c.godina_rodenja, c.mjesto_rodenja, c.adresa, c.grad, c.uloga, + c.dominantna_noga, c.visina_cm, c.tezina_kg, c.biografija, + c.reprezentativac, c.reprezentacija_kategorija, c.kategorija_hoo, + c.licenca_broj, c.licenca_vrijedi_do, c.aktivan, + c.source, c.source_id, c.source_url, c.source_synced_at, c.slug, + c.klub_id, k.naziv AS klub_naziv, k.sport, k.razina, k.region, + k.grad AS klub_grad, k.logo_url AS klub_logo, + k.hns_klub_id, k.hns_slug + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.id=%s""", (cid,)) + if not sp: + raise HTTPException(404, "Sportaš nije pronađen") + + # Sezone agregirane + sezone = db_query(""" + SELECT + CASE WHEN EXTRACT(MONTH FROM datum)>=7 + THEN EXTRACT(YEAR FROM datum)::TEXT||'/'||LPAD(((EXTRACT(YEAR FROM datum)+1)::INT %% 100)::TEXT, 2, '0') + ELSE (EXTRACT(YEAR FROM datum)-1)::TEXT||'/'||LPAD((EXTRACT(YEAR FROM datum)::INT %% 100)::TEXT, 2, '0') + END AS sezona, + natjecanje, count(*) AS nastupi, + COALESCE(SUM(pogodaka),0) AS pogoci, + COALESCE(SUM(zuti_kartoni),0) AS zuti, + COALESCE(SUM(crveni_kartoni),0) AS crveni, + COALESCE(SUM(minute),0) AS minute_total + FROM pgz_sport.utakmice_log + WHERE clan_id=%s AND datum IS NOT NULL + GROUP BY 1, 2 + ORDER BY 1 DESC, 2""", (cid,)) + + # Utakmice (zadnjih 50) + utakmice = db_query("""SELECT id, datum, vrijeme, natjecanje, + klub_dom, klub_dom_logo, klub_gost, klub_gost_logo, + rezultat, pogodaka, zuti_kartoni, crveni_kartoni, minute, + zapocet_kao_starter, source_url + FROM pgz_sport.utakmice_log + WHERE clan_id=%s ORDER BY datum DESC NULLS LAST LIMIT 50""", (cid,)) + + # Karijera - klubovi kroz vrijeme + karijera = db_query("""SELECT k.id, k.naziv, k.logo_url, + min(ul.datum) AS od_dat, max(ul.datum) AS do_dat, + count(*) AS nastupa, + COALESCE(sum(ul.pogodaka),0) AS pogoci + FROM pgz_sport.utakmice_log ul + JOIN pgz_sport.klubovi k ON k.id=ul.za_klub_id + WHERE ul.clan_id=%s + GROUP BY k.id, k.naziv, k.logo_url + ORDER BY min(ul.datum)""", (cid,)) + + # Trenutna sezona stats (top blok) + cur_season = db_one(""" + SELECT count(*) AS nastupi, COALESCE(sum(pogodaka),0) AS pogoci, + COALESCE(sum(zuti_kartoni),0) AS zuti, COALESCE(sum(crveni_kartoni),0) AS crveni, + COALESCE(sum(minute),0) AS minute_total + FROM pgz_sport.utakmice_log + WHERE clan_id=%s + AND datum >= (CASE WHEN EXTRACT(MONTH FROM CURRENT_DATE)>=7 + THEN make_date(EXTRACT(YEAR FROM CURRENT_DATE)::INT, 7, 1) + ELSE make_date(EXTRACT(YEAR FROM CURRENT_DATE)::INT - 1, 7, 1) + END)""", (cid,)) + + # A4_NAGRADE_PATCH: pojedinačne nagrade/medalje sportaša + nagrade = db_query(""" + SELECT godina, sezona, natjecanje, razina_natjecanja, dobna_kategorija, + disciplina, plasman, medalja, napomena, source, source_url + FROM pgz_sport.clan_nagrada + WHERE clan_id=%s + ORDER BY + CASE razina_natjecanja + WHEN 'OI' THEN 1 WHEN 'SP' THEN 2 WHEN 'EP' THEN 3 + WHEN 'SK' THEN 4 WHEN 'EK' THEN 5 WHEN 'DP' THEN 6 ELSE 7 END, + plasman ASC NULLS LAST, godina DESC""", (cid,)) + + # A4_TROFEJI_KLUB: ako sportaš ima klub_id, dohvati klubove trofeje (sezonske) + klub_trofeji = [] + if sp.get('klub_id'): + klub_trofeji = db_query(""" + SELECT sezona, natjecanje, plasiranje, trofej, bodovi + FROM pgz_sport.klub_sezona + WHERE klub_id=%s + ORDER BY sezona DESC LIMIT 30""", (sp['klub_id'],)) + + # A4_PRIZNANJA: najbolji_sportasi nagrade (godišnje povijesne) + priznanja = db_query(""" + SELECT godina, kategorija, klub, sport, napomena + FROM pgz_sport.najbolji_sportasi + WHERE clan_id=%s OR (clan_id IS NULL AND LOWER(ime_prezime) = LOWER(%s)) + ORDER BY godina DESC""", (cid, f"{sp.get('ime','')} {sp.get('prezime','')}")) + + return { + "sportas": sp, + "trenutna_sezona": cur_season or {"nastupi":0,"pogoci":0,"zuti":0,"crveni":0,"minute_total":0}, + "sezone": sezone, + "karijera": karijera, + "utakmice": utakmice, + "nagrade": nagrade, + "klub_trofeji": klub_trofeji, + "priznanja": priznanja, + "totals": { + "nastupa": sum((s.get('nastupi') or 0) for s in sezone), + "pogodaka": sum((s.get('pogoci') or 0) for s in sezone), + "zutih": sum((s.get('zuti') or 0) for s in sezone), + "crvenih": sum((s.get('crveni') or 0) for s in sezone), + "minuta": sum((s.get('minute_total') or 0) for s in sezone), + } + } + +# === SPORT PREGLED ENDPOINTI (29.04.2026 sprint - svi sportovi) === + +@router.get("/sport/svi/stats") +def svi_sportovi_stats(): + """Sumarno za sve sportove - broj klubova, sportaša, saveza, manifestacija.""" + rows = db_query(""" + WITH agg AS ( + SELECT k.sport, + count(distinct k.id) AS klubova, + count(distinct k.grad) AS gradova, + count(distinct c.id) AS sportasa, + count(distinct c.id) FILTER (WHERE (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS kategoriziranih + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id=k.id + WHERE k.sport IS NOT NULL AND k.sport != '' + GROUP BY k.sport + ), + savezi_agg AS ( + SELECT lower(sport) AS sport_l, count(*) AS savez_count + FROM pgz_sport.savezi WHERE sport IS NOT NULL + GROUP BY lower(sport) + ), + manif_agg AS ( + SELECT lower(s.sport) AS sport_l, count(*) AS manif_count + FROM pgz_sport.manifestacije m + JOIN pgz_sport.savezi s ON s.id=m.savez_id + WHERE s.sport IS NOT NULL + GROUP BY lower(s.sport) + ), + nagrade_agg AS ( + SELECT lower(sport) AS sport_l, count(*) AS nagrade_count + FROM pgz_sport.najbolji_sportasi WHERE sport IS NOT NULL + GROUP BY lower(sport) + ) + SELECT a.sport, a.klubova, a.gradova, a.sportasa, a.kategoriziranih, + COALESCE(sa.savez_count, 0) AS saveza, + COALESCE(ma.manif_count, 0) AS manifestacija, + COALESCE(na.nagrade_count, 0) AS nagrada + FROM agg a + LEFT JOIN savezi_agg sa ON sa.sport_l = lower(a.sport) + LEFT JOIN manif_agg ma ON ma.sport_l = lower(a.sport) + LEFT JOIN nagrade_agg na ON na.sport_l = lower(a.sport) + ORDER BY a.klubova DESC + """) + return {"count": len(rows), "sportovi": rows, + "totals": { + "klubova": sum(r['klubova'] for r in rows), + "sportasa": sum(r['sportasa'] for r in rows), + "saveza": sum(r['saveza'] for r in rows), + "manifestacija": sum(r['manifestacija'] for r in rows), + }} + + +@router.get("/sport/{sport_naziv}/pregled") +def sport_pregled(sport_naziv: str): + """Detaljan pregled za jedan sport - klubovi, sportaši, savez, trofeji, najbolji, manifestacije.""" + sport_l = sport_naziv.lower().strip() + + # Sinonimi - savez nazivi često imaju različitu morfologiju (nogomet ↔ Nogometni, rukomet ↔ Rukometni) + SPORT_SYNONYMS = { + 'nogomet': ['nogomet', 'nogometni'], + 'rukomet': ['rukomet', 'rukometni'], + 'košarka': ['košarka', 'košarkaški', 'kosarka', 'kosarkaski'], + 'kosarka': ['košarka', 'košarkaški', 'kosarka', 'kosarkaski'], + 'vaterpolo': ['vaterpolo', 'vaterpolski'], + 'odbojka': ['odbojka', 'odbojkaški', 'odbojkaski'], + 'tenis': ['tenis', 'teniski'], + 'stolni tenis': ['stolni tenis', 'stolnoteniski'], + 'plivanje': ['plivanje', 'plivački', 'plivacki'], + 'biciklizam': ['biciklizam', 'biciklistički', 'biciklisticki'], + 'boks': ['boks', 'boksački', 'boksacki'], + 'boćanje': ['boćanje', 'bocanje', 'boćarski', 'bocarski'], + 'kuglanje': ['kuglanje', 'kuglački', 'kuglacki'], + 'streljaštvo': ['streljaštvo', 'streljaski', 'streljački'], + 'streličarstvo': ['streličarstvo', 'strelicarstvo'], + 'judo': ['judo'], + 'karate': ['karate'], + 'taekwondo': ['taekwondo'], + 'kickboxing': ['kickboxing'], + 'jedriličarstvo': ['jedriličarstvo', 'jedrilicarstvo', 'jedriličarski', 'jedrilicarski'], + 'šah': ['šah', 'sah', 'šahovski', 'sahovski'], + 'sah': ['šah', 'sah', 'šahovski', 'sahovski'], + 'pikado': ['pikado'], + 'ribolov': ['ribolov', 'ribolovni', 'športsko ribolovni'], + 'skijanje': ['skijanje', 'skijaški', 'skijaski'], + 'atletika': ['atletika', 'atletski'], + 'gimnastika': ['gimnastika'], + 'planinarstvo': ['planinarstvo', 'planinarski'], + 'motosport': ['motosport', 'motociklizam', 'auto-moto'], + 'rekreacija': ['rekreacija', 'rekreacijski', 'sportska rekreacija'], + 'parasport': ['parasport', 'parasportski', 'paraolimpijski', 'osoba s invaliditetom'], + 'multisport': ['multisport', 'sportski savez'], + 'lov': ['lov', 'lovački', 'lovacki'], + 'ples': ['ples', 'plesni'], + 'borilački sport': ['borilački', 'borilacki'], + } + sport_synonyms = SPORT_SYNONYMS.get(sport_l, [sport_l]) + sport_pattern = '|'.join(sport_synonyms) # za regex + sport_ilike_pattern = '|'.join(f'%{s}%' for s in sport_synonyms) + + # Saveze - matching ILIKE ANY + savezi = db_query("""SELECT id, naziv, skraceni_naziv, godina_osnutka, predsjednik, tajnik, + adresa, grad, telefon, email, web, razina + FROM pgz_sport.savezi + WHERE (lower(sport) = ANY(%s) OR lower(naziv) ~ %s) AND aktivan=true + ORDER BY naziv""", (sport_synonyms, sport_pattern)) + + # Klubovi - top 50 po broju članova + klubovi = db_query("""SELECT k.id, k.naziv, k.razina, k.region, k.grad, k.godina_osnutka, + k.logo_url, k.hns_klub_id, + (SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id=k.id) AS broj_clanova, + (SELECT count(*) FROM pgz_sport.clanovi c + WHERE c.klub_id=k.id AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS broj_kategoriziranih + FROM pgz_sport.klubovi k + WHERE lower(k.sport) = ANY(%s) AND k.aktivan=true + ORDER BY broj_clanova DESC, k.naziv + LIMIT 100""", (sport_synonyms,)) + + # Top sportaši - kategorizirani (HOO I, II, III) + top_sportasi = db_query("""SELECT c.id, c.ime, c.prezime, c.slika_url, c.kategorija_hoo, + c.reprezentativac, c.broj_dresa, c.pozicija, c.aktivan, + k.naziv AS klub_naziv, k.id AS klub_id + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE (lower(c.sport) = ANY(%s) OR lower(k.sport) = ANY(%s)) + AND ((c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) OR c.reprezentativac=true OR c.kategorija_hoo IS NOT NULL) + ORDER BY c.kategorija_hoo NULLS LAST, c.prezime + LIMIT 50""", (sport_synonyms, sport_synonyms)) + + # Trofeji povijesni iz klub_sezona + trofeji = db_query("""SELECT ks.klub_naziv, ks.sezona, ks.natjecanje, ks.plasiranje, + ks.trofej, ks.napomena, k.id AS klub_id + FROM pgz_sport.klub_sezona ks + LEFT JOIN pgz_sport.klubovi k ON k.id=ks.klub_id + WHERE k.id IS NOT NULL AND lower(k.sport) = ANY(%s) + ORDER BY ks.sezona DESC NULLS LAST, ks.plasiranje + LIMIT 100""", (sport_synonyms,)) + + # Najbolji sportaši kroz godine + najbolji = db_query("""SELECT godina, kategorija, ime_prezime, klub, napomena + FROM pgz_sport.najbolji_sportasi + WHERE lower(sport) = ANY(%s) OR lower(sport) ~ %s + ORDER BY godina DESC, kategorija + LIMIT 100""", (sport_synonyms, sport_pattern)) + + # Manifestacije + manifestacije = db_query("""SELECT m.id, m.naziv, m.mjesto, m.organizator, m.razina, + m.broj_ucesnika, m.godina_od, m.spol_kategorija, + s.naziv AS savez_naziv + FROM pgz_sport.manifestacije m + LEFT JOIN pgz_sport.savezi s ON s.id=m.savez_id + WHERE (lower(s.sport) = ANY(%s) OR lower(s.naziv) ~ %s) AND m.aktivna=true + ORDER BY m.naziv + LIMIT 100""", (sport_synonyms, sport_pattern)) + + # Stats sumarno za ovaj sport (sa M/Ž razdvajanjem) + stats = db_one("""SELECT + (SELECT count(*) FROM pgz_sport.klubovi WHERE lower(sport)=ANY(%s) AND aktivan=true) AS broj_klubova, + (SELECT count(distinct grad) FROM pgz_sport.klubovi WHERE lower(sport)=ANY(%s)) AS broj_gradova, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true + WHERE lower(k.sport)=ANY(%s)) AS broj_sportasa, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true + WHERE lower(k.sport)=ANY(%s) AND c.spol='M') AS broj_sportasa_m, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id AND k.aktivan=true + WHERE lower(k.sport)=ANY(%s) AND c.spol='Ž') AS broj_sportasa_z, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL)) AS broj_kategoriziranih, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) AND c.spol='M') AS broj_kategoriziranih_m, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND (c.kategoriziran=true OR c.kategorija_hoo IS NOT NULL) AND c.spol='Ž') AS broj_kategoriziranih_z, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true) AS broj_reprezentativaca, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true AND c.spol='M') AS broj_reprezentativaca_m, + (SELECT count(*) FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE lower(k.sport)=ANY(%s) AND c.reprezentativac=true AND c.spol='Ž') AS broj_reprezentativaca_z""", + (sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, + sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms, sport_synonyms)) + + # Latest scrape - ako je nogomet, daj zadnju utakmicu + zadnja_utakmica = None + if sport_l == 'nogomet': + zadnja_utakmica = db_one("""SELECT max(datum) AS datum FROM pgz_sport.utakmice_log""") + + return { + "sport": sport_naziv, + "stats": stats, + "savezi": savezi, + "klubovi": klubovi, + "top_sportasi": top_sportasi, + "trofeji": trofeji, + "najbolji": najbolji, + "manifestacije": manifestacije, + "zadnja_aktivnost": zadnja_utakmica + } + + +# ============ GOOGLE AI ENRICHMENT ============ +class EnrichRequest(BaseModel): + entity_type: str + entity_id: Optional[int] = None + query: str + +@router.post("/enrich/google-ai") +def enrich_google_ai(req: EnrichRequest): + """Search internet + LLM synthesis + save to docs. + Uses DuckDuckGo HTML for search (no API key) + Groq Llama 3.3 70b.""" + import urllib.request, urllib.parse, json as jj, gzip, re as re2 + + query = req.query.strip() + if not query: + return {"summary": "Nema upita.", "sources": []} + + # Step 1: Search DuckDuckGo HTML (no API key) + sources = [] + try: + ddg_url = f"https://duckduckgo.com/html/?q={urllib.parse.quote(query)}" + req_h = urllib.request.Request(ddg_url, headers={ + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + 'Accept-Encoding': 'gzip' + }) + with urllib.request.urlopen(req_h, timeout=10) as r: + data = r.read() + if r.headers.get('Content-Encoding') == 'gzip': + data = gzip.decompress(data) + html = data.decode('utf-8', errors='replace') + # Parse top results + import sys; print(f"DDG html len={len(html)}", file=sys.stderr); results = re2.findall(r']*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)', html) + for url, title in results[:5]: + # Decode DDG redirect + m = re2.search(r'uddg=([^&]+)', url) + if m: + url = urllib.parse.unquote(m.group(1)) + sources.append({"url": url, "title": title.strip()[:120]}) + except Exception as e: + sources = [{"url": f"https://www.google.com/search?q={urllib.parse.quote(query)}", "title": "Google search"}] + + # Step 2: Fetch top 2-3 sources + fetched = [] + for s in sources[:3]: + try: + req_h = urllib.request.Request(s["url"], headers={ + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + 'Accept-Encoding': 'gzip' + }) + with urllib.request.urlopen(req_h, timeout=8) as r: + data = r.read() + if r.headers.get('Content-Encoding') == 'gzip': + data = gzip.decompress(data) + src_html = data.decode('utf-8', errors='replace') + text = re2.sub(r']*>.*?', ' ', src_html, flags=re2.DOTALL) + text = re2.sub(r']*>.*?', ' ', text, flags=re2.DOTALL) + text = re2.sub(r'<[^>]+>', ' ', text) + text = re2.sub(r'\s+', ' ', text).strip()[:3000] + fetched.append({"url": s["url"], "title": s["title"], "text": text}) + except Exception: + pass + + # Step 3: LLM synthesis via Groq + summary = "" + facts = [] + + if fetched: + sources_block = "\n\n".join(f"IZVOR: {f['title']}\nURL: {f['url']}\nTEKST: {f['text']}" for f in fetched) + prompt = f"""Sintetiziraj informacije o "{query}" iz sljedećih izvora. Odgovori na hrvatskom, kratko i jasno (max 200 riječi). + +{sources_block} + +Format odgovora (JSON): +{{"summary": "kratki opis u 2-3 rečenice", "facts": ["činjenica 1", "činjenica 2", "činjenica 3"]}} + +Odgovori SAMO sa JSON objektom, bez markdown wrapper-a.""" + + try: + import os + gk = os.environ.get("GROQ_API_KEY") + if not gk: + with open("/opt/rinet-gpu/.env.master") as f: + for line in f: + if line.startswith("GROQ_API_KEY="): + gk = line.split("=",1)[1].strip() + break + if gk: + groq_req = urllib.request.Request( + "https://api.groq.com/openai/v1/chat/completions", + data=jj.dumps({ + "model": "llama-3.3-70b-versatile", + "messages": [{"role":"user","content":prompt}], + "max_tokens": 800, + "temperature": 0.2 + }).encode(), + headers={"Authorization": f"Bearer {gk}", "Content-Type":"application/json", "User-Agent":"Mozilla/5.0"} + ) + with urllib.request.urlopen(groq_req, timeout=20) as r: + resp = jj.loads(r.read()) + content = resp["choices"][0]["message"]["content"].strip() + content = re2.sub(r'^```(?:json)?\s*', '', content) + content = re2.sub(r'\s*```$', '', content).strip() + try: + obj = jj.loads(content) + summary = obj.get("summary", "") + facts = obj.get("facts", []) + except: + summary = content[:500] + except Exception as e: + summary = f"AI sinteza nije uspjela: {e}" + + # Step 4: Save to dokumenti for RAG + saved = False + if summary and len(summary) > 50: + try: + doc_id = db_one("""INSERT INTO pgz_sport.dokumenti + (title, sadrzaj, vrsta, izvor_url, organizacija, kratak_opis, izdano_datum, aktivan) + VALUES (%s, %s, 'enrichment', %s, 'AI Enrichment', %s, CURRENT_DATE, true) + RETURNING id""", + (f"AI Enrichment: {req.query[:120]}", + f"{summary}\n\nKljučne činjenice:\n" + "\n".join(f"- {f}" for f in facts) + + f"\n\nIzvori:\n" + "\n".join(f"{s['title']}: {s['url']}" for s in sources), + sources[0]["url"] if sources else None, + summary[:300])) + saved = bool(doc_id) + except Exception: + pass + + return { + "summary": summary, + "facts": facts, + "sources": sources[:5], + "saved_to_db": saved, + "google_search_url": f"https://www.google.com/search?q={urllib.parse.quote(query)}" + } + + + +# ============ KLUB WEB ENRICHMENT ============ +class KlubEnrichRequest(BaseModel): + klub_id: int + urls: Optional[List[str]] = None # if None, try klub.web + +@router.post("/enrich/klub-web") +def enrich_klub_web(req: KlubEnrichRequest): + """Scrape klub web for roster + uprava + stručni stožer. + Uses Groq Llama to extract structured roster from HTML. + Returns list of upserted clanovi + counts by uloga.""" + import urllib.request, urllib.parse, json as jj, gzip, re as re2, os + + klub = db_one("""SELECT id, naziv, web, sport, region FROM pgz_sport.klubovi WHERE id=%s""", (req.klub_id,)) + if not klub: + return {"error": "klub not found", "klub_id": req.klub_id} + + urls = req.urls or [] + if not urls and klub.get("web"): + web = klub["web"].strip().rstrip("/") + # Try common subpaths for HR football clubs + urls = [web + p for p in ["/uprava/", "/momcad/", "/strucni-stozer/", "/igraci/", "/tim/", "/klub/"]] + urls.append(web) # also home + + if not urls: + return {"error": "no web URL for klub", "klub_id": req.klub_id, "klub_naziv": klub.get("naziv")} + + UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + + all_text = [] + fetched_urls = [] + for u in urls[:6]: + try: + r_h = urllib.request.Request(u, headers={"User-Agent": UA, "Accept-Encoding": "gzip"}) + with urllib.request.urlopen(r_h, timeout=10) as r: + data = r.read() + if r.headers.get("Content-Encoding") == "gzip": + data = gzip.decompress(data) + src_html = data.decode("utf-8", errors="replace") + # Strip scripts/styles + text = re2.sub(r"]*>.*?", " ", src_html, flags=re2.DOTALL) + text = re2.sub(r"]*>.*?", " ", text, flags=re2.DOTALL) + # Keep , , tags as markers + text = re2.sub(r"<(h[1-6])[^>]*>", "\n## ", text) + text = re2.sub(r"", "\n", text) + text = re2.sub(r"<(strong|b)[^>]*>", "**", text) + text = re2.sub(r"", "**", text) + text = re2.sub(r"]*>", "\n", text) + text = re2.sub(r"<[^>]+>", " ", text) + text = re2.sub(r" ", " ", text) + text = re2.sub(r"\s+", " ", text).strip()[:8000] + if text and len(text) > 100: + all_text.append(f"=== URL: {u} ===\n{text}") + fetched_urls.append(u) + except Exception: + pass + + if not all_text: + return {"error": "could not fetch any URL", "tried": urls} + + combined = "\n\n".join(all_text)[:18000] + + # LLM extraction via Groq + prompt = f"""Iz priloženih HTML stranica nogometnog/sportskog kluba "{klub.get('naziv')}" izvuci strukturirani JSON popis osoba. + +Stranice mogu sadržavati: +- UPRAVU (predsjednik, dopredsjednik, tajnik, direktor, član uprave) +- STRUČNI STOŽER (glavni trener, pomoćni trener, trener vratara, kondicijski trener, fizioterapeut, liječnik, video-analitičar) +- IGRAČE prve momčadi (ime, prezime, broj dresa) + +Vrati SAMO JSON array. Format svake osobe: +{{"ime":"X", "prezime":"Y", "uloga":"predsjednik|dopredsjednik|tajnik|direktor|trener|pomocni_trener|trener_vratara|kondicioni_trener|fizioterapeut|lijecnik|igrac|ostalo", "broj_dresa": null|N, "pozicija": null|"GK"|"DF"|"MF"|"FW", "napomena": null|"opis"}} + +PRAVILA: +- Ne izmišljaj. Ako neka osoba nije jasno spomenuta, preskoči je. +- Igrače stavi u uloga="igrac" SAMO ako su jasno na popisu igrača prve momčadi. +- Predsjednik/uprava NIKAD nemaju uloga="igrac". +- Brojeve dresa parsiraj iz formata "Ime Prezime (BROJ)" → broj_dresa=BROJ. + +Stranice: +{combined} + +Vrati SAMO JSON array, bez markdown, bez objašnjenja.""" + + try: + gk = os.environ.get("GROQ_API_KEY") + if not gk: + return {"error": "no GROQ_API_KEY"} + groq_req = urllib.request.Request( + "https://api.groq.com/openai/v1/chat/completions", + data=jj.dumps({ + "model": "llama-3.3-70b-versatile", + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 4000, + "temperature": 0.1 + }).encode(), + headers={"Authorization": f"Bearer {gk}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0"} + ) + with urllib.request.urlopen(groq_req, timeout=40) as r: + resp = jj.loads(r.read()) + content = resp["choices"][0]["message"]["content"].strip() + content = re2.sub(r"^```(?:json)?\s*", "", content) + content = re2.sub(r"\s*```$", "", content).strip() + # Find JSON array + m_arr = re2.search(r"\[[\s\S]*\]", content) + if not m_arr: + return {"error": "LLM did not return JSON array", "raw": content[:500]} + try: + people = jj.loads(m_arr.group(0)) + except Exception as e: + return {"error": f"JSON parse: {e}", "raw": content[:500]} + except Exception as e: + return {"error": f"LLM call: {e}"} + + if not isinstance(people, list): + return {"error": "not a list", "people": people} + + # Upsert each person + inserted = 0 + updated = 0 + skipped = 0 + by_uloga = {} + for p in people: + if not isinstance(p, dict): continue + ime = (p.get("ime") or "").strip() + prezime = (p.get("prezime") or "").strip() + if not ime or not prezime: skipped += 1; continue + uloga = (p.get("uloga") or "ostalo").strip() + broj = p.get("broj_dresa") + try: broj = int(broj) if broj else None + except: broj = None + poz = p.get("pozicija") + nap = p.get("napomena") + + # Check if exists by name + klub + existing = db_one("""SELECT id, uloga FROM pgz_sport.clanovi + WHERE LOWER(ime) = LOWER(%s) AND LOWER(prezime) = LOWER(%s) + AND klub_id = %s LIMIT 1""", (ime, prezime, req.klub_id)) + + source_url = fetched_urls[0] if fetched_urls else None + + try: + if existing: + db_exec("""UPDATE pgz_sport.clanovi SET + uloga=%s, broj_dres=COALESCE(%s, broj_dres), + pozicija=COALESCE(%s, pozicija), + source='klub_web', source_url=COALESCE(source_url, %s), + last_updated=now() WHERE id=%s""", + (uloga, broj, poz, source_url, existing["id"])) + updated += 1 + else: + db_exec("""INSERT INTO pgz_sport.clanovi + (ime, prezime, uloga, broj_dres, pozicija, klub_id, sport, + source, source_url, napomena, last_updated) + VALUES (%s, %s, %s, %s, %s, %s, %s, 'klub_web', %s, %s, now())""", + (ime, prezime, uloga, broj, poz, req.klub_id, klub.get("sport"), source_url, nap)) + inserted += 1 + by_uloga[uloga] = by_uloga.get(uloga, 0) + 1 + except Exception as e: + skipped += 1 + + # Audit + db_exec("""INSERT INTO pgz_sport.audit_feed (table_name, action, source, source_url, details) + VALUES ('clanovi', 'klub_web_enrich', 'klub_web', %s, %s::jsonb)""", + (fetched_urls[0] if fetched_urls else None, + jj.dumps({"klub_id": req.klub_id, "klub": klub.get("naziv"), + "inserted": inserted, "updated": updated, "by_uloga": by_uloga}))) + + return { + "klub_id": req.klub_id, + "klub_naziv": klub.get("naziv"), + "fetched_urls": fetched_urls, + "people_count": len(people), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + "by_uloga": by_uloga + } + + +@router.get("/audit/data-quality") +def audit_data_quality(user = Depends(require_user)): + """Pokaze koliko podataka je provjereno (sa source_url) vs neprovjereno.""" + require_role(user, ['super_admin','pgz_admin','pgz_user']) + + sportasi = db_query(""" + SELECT + source, + count(*) AS total, + count(source_url) AS sa_izvorom, + count(datum_rodenja) AS sa_dat_rod, + count(godina_rodenja) AS sa_god_rod, + count(mjesto_rodenja) AS sa_mjesto, + count(slika_url) AS sa_slikom, + count(klub_id) AS sa_klubom + FROM pgz_sport.clanovi + GROUP BY source + ORDER BY total DESC + """) + + klubovi = db_query(""" + SELECT + COALESCE(scrape_source, 'manual') AS source, + count(*) AS total, + count(scrape_url) AS sa_izvorom, + count(godina_osnutka) AS sa_godinom, + count(adresa) AS sa_adresom, + count(telefon) AS sa_telefonom, + count(hns_klub_id) AS sa_hns + FROM pgz_sport.klubovi + GROUP BY scrape_source + ORDER BY total DESC + """) + + purge_history = db_query(""" + SELECT created_at, action, target_text, payload + FROM pgz_sport.sys_audit + WHERE action LIKE '%purge%' OR action LIKE '%clean%' + ORDER BY created_at DESC LIMIT 10 + """) + + # Top sumnjivi - manual sa malo info + sumnjivi = db_query(""" + SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.source, + k.naziv AS klub_naziv, + (CASE WHEN c.source_url IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN c.datum_rodenja IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN c.slika_url IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN c.klub_id IS NOT NULL THEN 1 ELSE 0 END) AS quality + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.source = 'manual' + ORDER BY quality ASC, c.id LIMIT 30 + """) + + # Top trusted + trusted = db_query(""" + SELECT c.id, c.ime, c.prezime, c.sport, c.uloga, c.source, c.source_url, + k.naziv AS klub_naziv, c.datum_rodenja, c.godina_rodenja + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id + WHERE c.source IN ('hns_semafor', 'hbs_savez', 'rk_zamet_web') + AND c.datum_rodenja IS NOT NULL + ORDER BY c.source_synced_at DESC NULLS LAST LIMIT 20 + """) + + return { + "sportasi_po_izvoru": sportasi, + "klubovi_po_izvoru": klubovi, + "purge_history": purge_history, + "sumnjivi_zapisi": sumnjivi, + "trusted_zapisi": trusted, + "policy": { + "datum_rodenja": "MORA imati source_url - inače trigger postavlja NULL", + "slika_url": "MORA imati source_url - inače trigger postavlja NULL", + "validation_trigger": "clanovi_validate_source" + }, + "trusted_sources": ["hns_semafor", "hbs_savez", "rk_zamet_web"], + "needs_verification": ["manual"] + } + + + +@router.get("/statistika/clanstvo-po-sportu") +def statistika_clanstvo_po_sportu(sport: str = None): + """Agregirane statistike clanstva po savezima iz PGŽ dostave (Boris Milanovic xlsx 2026)""" + rows = db_query(""" + SELECT sport, kategorija, godiste, zene, muski, veterani, ukupno + FROM pgz_sport.savez_statistika_clanstvo + WHERE (%s IS NULL OR sport = %s) ORDER BY sport, kategorija + """, (sport, sport)) if sport else db_query(""" + SELECT sport, + SUM(COALESCE(zene,0)) as zene, + SUM(COALESCE(muski,0)) as muski, + SUM(COALESCE(veterani,0)) as veterani, + SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(ukupno,0) ELSE 0 END) as seniori_ukupno, + SUM(COALESCE(ukupno,0)) as ukupno_sve_kategorije + FROM pgz_sport.savez_statistika_clanstvo + GROUP BY sport ORDER BY SUM(COALESCE(ukupno,0)) DESC + """) + return {"sport": sport, "count": len(rows), "data": rows} + +@router.get("/statistika/clanstvo-ukupno") +def statistika_clanstvo_ukupno(): + """Totali po spolu i dobnoj skupini kroz sve saveze""" + totals = db_one(""" + SELECT + SUM(COALESCE(zene,0)) as ukupno_zene, + SUM(COALESCE(muski,0)) as ukupno_muski, + SUM(COALESCE(veterani,0)) as ukupno_veterani, + COUNT(DISTINCT sport) as broj_saveza, + SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(zene,0) ELSE 0 END) as seniori_zene, + SUM(CASE WHEN kategorija ILIKE '%%seniori%%' THEN COALESCE(muski,0) ELSE 0 END) as seniori_muski + FROM pgz_sport.savez_statistika_clanstvo + """) + po_kat = db_query(""" + SELECT kategorija, godiste, + SUM(COALESCE(zene,0)) as zene, + SUM(COALESCE(muski,0)) as muski, + SUM(COALESCE(ukupno,0)) as ukupno + FROM pgz_sport.savez_statistika_clanstvo + GROUP BY kategorija, godiste ORDER BY kategorija + """) + return {**totals, "po_kategoriji": po_kat} + + +@router.get("/javne-potrebe") +def javne_potrebe_pgz(godina: int = None): + """Javne potrebe u sportu PGŽ po godinama (ZSP PGŽ podaci)""" + if godina: + rows = db_query(""" + SELECT id, naslov, kategorija, sadrzaj, url, scraped_at + FROM pgz_sport.zsp_dokumenti + WHERE kategorija='javne_potrebe' AND naslov ILIKE %s + ORDER BY scraped_at DESC + """, (f'%{godina}%',)) + else: + rows = db_query(""" + SELECT id, naslov, kategorija, sadrzaj, url, scraped_at + FROM pgz_sport.zsp_dokumenti + WHERE kategorija IN ('javne_potrebe','natjecaj_sufinanciranje') + ORDER BY naslov DESC + """) + return {"count": len(rows), "data": rows} + +@router.get("/hoo-pravilnici") +def hoo_pravilnici(kategorija: str = None): + """HOO pravilnici i kriteriji kategorizacije""" + if kategorija: + rows = db_query("SELECT * FROM pgz_sport.hoo_pravilnici WHERE kategorija=%s ORDER BY scraped_at DESC", (kategorija,)) + else: + rows = db_query("SELECT id, naslov, kategorija, url, scraped_at FROM pgz_sport.hoo_pravilnici ORDER BY kategorija, naslov") + return {"count": len(rows), "data": rows} + +@router.get("/rno-udruge") +def rno_udruge(sport: str = None, grad: str = None): + """Registar sportskih udruga PGŽ (RNO podaci)""" + sql = "SELECT * FROM pgz_sport.rno_sportske_udruge WHERE 1=1" + params = [] + if sport: sql += " AND djelatnost ILIKE %s"; params.append(f'%{sport}%') + if grad: sql += " AND grad ILIKE %s"; params.append(f'%{grad}%') + sql += " ORDER BY naziv" + rows = db_query(sql, params) + return {"count": len(rows), "data": rows} + +@router.get("/zsp-dokumenti") +def zsp_dokumenti(kategorija: str = None): + """ZSP PGŽ i RSS dokumenti (savezi, programi, nagrade)""" + if kategorija: + rows = db_query(""" + SELECT id, naslov, kategorija, url, izvor, scraped_at + FROM pgz_sport.zsp_dokumenti WHERE kategorija=%s ORDER BY naslov + """, (kategorija,)) + else: + rows = db_query(""" + SELECT kategorija, COUNT(*) as broj, MAX(scraped_at) as zadnji_scrape + FROM pgz_sport.zsp_dokumenti GROUP BY kategorija ORDER BY kategorija + """) + return {"count": len(rows), "data": rows} + + +# ============================================================ +# PGŽ Boris Milanović stats — official Excel data 30.04.2026 +# ============================================================ + +@router.get("/pgz/savez-stats") +def pgz_savez_stats(): + """Službena statistika PGŽ saveza (Boris Milanović Excel 30.04.2026).""" + rows = db_query(""" + SELECT savez, + sum(natjecateljki) AS žene, + sum(natjecatelja) AS muški, + sum(veterani) AS veterani, + sum(ukupno) AS ukupno + FROM pgz_sport.savez_stats_oficijalno + GROUP BY savez + ORDER BY ukupno DESC NULLS LAST + """) + summary = db_one(""" + SELECT count(DISTINCT savez) AS broj_saveza, + sum(ukupno) AS ukupno_sportasa, + sum(natjecateljki) AS žene, + sum(natjecatelja) AS muški, + sum(veterani) AS veterani + FROM pgz_sport.savez_stats_oficijalno + """) + return {"savezi": rows, "summary": summary, "izvor": "Boris Milanović PGŽ - 30.04.2026"} + + +@router.get("/pgz/savez-stats/{savez_naziv}") +def pgz_savez_detail(savez_naziv: str): + """Detaljna kategorijska razdioba za jedan savez.""" + rows = db_query(""" + SELECT kategorija, natjecateljki, natjecatelja, veterani, ukupno + FROM pgz_sport.savez_stats_oficijalno + WHERE savez = %s + ORDER BY id + """, (savez_naziv,)) + if not rows: + raise HTTPException(404, f"Savez '{savez_naziv}' nije pronađen") + return {"savez": savez_naziv, "kategorije": rows, "izvor": "Boris Milanović PGŽ - 30.04.2026"} + + +@router.get("/pgz/sport-organizacije") +def pgz_sport_organizacije(kategorija: str = None, sufinanciran: bool = None, limit: int = 200): + """Klubovi po kategoriji organizacije (sport_klub, sport_savez, sportski_ribolov...) + Default = samo sportske organizacije.""" + where = ["aktivan=true"]; args = [] + if kategorija: + where.append("kategorija_organizacije=%s"); args.append(kategorija) + else: + where.append("kategorija_organizacije IN ('sport_klub','sport_savez','sportski_ribolov','planinarstvo')") + if sufinanciran is not None: + where.append("pgz_sufinanciran=%s"); args.append(sufinanciran) + args.append(limit) + rows = db_query(f""" + SELECT id, naziv, sport, razina, grad, godina_osnutka, + kategorija_organizacije, pgz_sufinanciran, scrape_source + FROM pgz_sport.klubovi + WHERE {' AND '.join(where)} + ORDER BY naziv + LIMIT %s + """, tuple(args)) + return {"count": len(rows), "klubovi": rows} + + +@router.get("/pgz/cleanup-summary") +def pgz_cleanup_summary(): + """Summary cleanup operacije 30.04.2026 - što je sport, što je obrisano.""" + rows = db_query(""" + SELECT + COALESCE(kategorija_organizacije, '(unclassified)') AS kategorija, + count(*) FILTER (WHERE aktivan=true) AS aktivni, + count(*) FILTER (WHERE aktivan=false) AS deaktivirani, + count(*) AS ukupno, + count(*) FILTER (WHERE pgz_sufinanciran=true) AS pgz_sufinanciran + FROM pgz_sport.klubovi + GROUP BY kategorija_organizacije + ORDER BY ukupno DESC + """) + summary = db_one(""" + SELECT + count(*) AS total, + count(*) FILTER (WHERE aktivan=true) AS aktivni, + count(*) FILTER (WHERE aktivan=false) AS deaktivirani, + count(*) FILTER (WHERE pgz_sufinanciran=true) AS sufinancirani + FROM pgz_sport.klubovi + """) + return {"by_category": rows, "summary": summary, + "info": "Cleanup 30.04.2026 - Boris Milanović PGŽ zahtjev", + "backup_table": "pgz_sport.klubovi_pre_cleanup_20260430"} + + +@router.get("/pgz/enrichment-gap") +def pgz_enrichment_gap(): + """Dashboard za Borisa - gap između Boris baseline (savez_stats) i naših podataka.""" + rows = db_query(""" + SELECT savez_naziv, sport, imamo_klubova, imamo_clanova, + boris_baseline, gap, pct_complete, status + FROM pgz_sport.v_enrichment_gap + ORDER BY gap DESC NULLS LAST + """) + summary = db_one(""" + SELECT + sum(imamo_klubova) AS klubova, + sum(imamo_clanova) AS clanova, + sum(boris_baseline) AS boris_total, + sum(gap) AS gap_total, + ROUND(100.0 * sum(imamo_clanova) / NULLIF(sum(boris_baseline), 0), 1) AS overall_pct, + count(*) FILTER (WHERE status = 'OK') AS ok_savezi, + count(*) FILTER (WHERE status = 'NULA') AS nula_savezi + FROM pgz_sport.v_enrichment_gap + """) + return {"saveza": rows, "summary": summary, + "boris_baseline_source": "Boris Milanović PGŽ Excel 30.04.2026"} + + +@router.get("/pgz/dedup-summary") +def pgz_dedup_summary(): + """Pregled cleanup operacija 30.04.2026.""" + audit = db_query(""" + SELECT created_at, action, target_text, payload + FROM pgz_sport.sys_audit + WHERE created_at >= '2026-04-30' + AND action IN ('cleanup_klubovi', 'planinarstvo_verified', 'dedup_klubovi', 'import_boris_stats') + ORDER BY id DESC + """) + return {"audit_log": audit} + + +# ===== RNO — Registar neprofitnih organizacija ===== +@router.get("/rno") +def get_rno(q: str = "", status: str = "", sort: str = "naziv", limit: int = 100): + """PGZ sport organizacije iz RNO s financijskim podacima""" + filters = ["1=1"] + params = [] + if q: + filters.append("(o.naziv ILIKE %s OR o.oib ILIKE %s OR o.mjesto ILIKE %s)") + params += [f"%{q}%", f"%{q}%", f"%{q}%"] + if status == "active": + filters.append("aktivna = true") + elif status == "inactive": + filters.append("aktivna = false") + + order = {"naziv": "naziv", "prihodi": "pr.prihodi_ukupno DESC NULLS LAST", "rashodi": "pr.rashodi_ukupno DESC NULLS LAST"}.get(sort, "naziv") + + sql = f""" + SELECT o.rno_broj, o.naziv, o.oib, o.mjesto, o.pravni_oblik, + o.adresa, o.email, o.web, o.aktivna, o.sifra_djelatnosti, + CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.prihodi_ukupno/7.53450 ELSE pr.prihodi_ukupno END as prihodi, + CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.prihodi_javni/7.53450 ELSE pr.prihodi_javni END as prihodi_javni, + CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.rashodi_ukupno/7.53450 ELSE pr.rashodi_ukupno END as rashodi, + CASE WHEN COALESCE(pr.godina,0) < 2023 THEN pr.rezultat/7.53450 ELSE pr.rezultat END as rezultat, + pr.godina as fin_godina, + CASE WHEN COALESCE(b.godina,0) < 2023 THEN b.imovina_ukupno/7.53450 ELSE b.imovina_ukupno END as imovina_ukupno + FROM pgz_sport.rno_organizacije o + LEFT JOIN pgz_sport.rno_prras pr ON pr.oib = o.oib AND pr.godina = ( + SELECT MAX(p2.godina) FROM pgz_sport.rno_prras p2 WHERE p2.oib = o.oib + ) + LEFT JOIN pgz_sport.rno_bilanca b ON b.oib = o.oib AND b.godina = pr.godina + WHERE {' AND '.join(filters)} + ORDER BY {order} + LIMIT %s + """ + params.append(limit) + return [dict(r) for r in db_query(sql, params)] + + +# ===== HNS Natjecanja ===== +@router.get("/hns-natjecanja") +def get_hns_natjecanja(season: str = "", org: str = ""): + filters = ["1=1"] + params = [] + if season: + filters.append("sezona = %s") + params.append(season) + if org: + filters.append("org_id = %s") + params.append(int(org)) + sql = f""" + SELECT id, naziv, sezona, org_id, url, sort + FROM pgz_sport.hns_natjecanja + WHERE {' AND '.join(filters)} + ORDER BY sezona DESC, sort, naziv + """ + return [dict(r) for r in db_query(sql, params)] + + +# ===== Godišnjaci AI pretraga ===== +@router.post("/godisnjaci/search") +def search_godisnjaci(body: dict = None): + """AI semantic search through ZSP PGZ godisnjaci 2006-2024""" + import json, requests as req + if not body: + return {"answer": "Nema pitanja.", "sources": []} + + question = body.get("question", "") + if not question: + return {"answer": "Nema pitanja.", "sources": []} + + try: + # Embed the question using Ollama nomic-embed + emb_r = req.post("http://localhost:11434/api/embed", + json={"model": "nomic-embed-text", "input": [question]}, + timeout=15) + if emb_r.status_code != 200: + return {"answer": "Greška pri embeddingu.", "sources": []} + + query_vec = emb_r.json()["embeddings"][0] + + # Search Qdrant pgz_godisnjaci + qdrant_r = req.post("http://10.10.0.2:6333/collections/pgz_godisnjaci/points/search", + json={"vector": query_vec, "limit": 6, "with_payload": True}, + timeout=15) + + hits = qdrant_r.json().get("result", []) + sources = [] + context_parts = [] + + for h in hits: + payload = h.get("payload", {}) + text = payload.get("text", "") + godina = payload.get("godina", "?") + score = h.get("score", 0) + if score > 0.3 and text: + sources.append({"godina": godina, "text": text, "score": round(score, 3)}) + context_parts.append(f"[Godišnjak {godina}]: {text[:400]}") + + if not context_parts: + return {"answer": "Nisam pronašao relevantne podatke u godišnjacima za ovo pitanje.", "sources": []} + + context = "\n\n".join(context_parts[:4]) + + # Use vLLM for answer generation + llm_r = req.post("http://localhost:8001/v1/chat/completions", + json={ + "model": "Qwen/Qwen2.5-7B-Instruct-AWQ", + "messages": [ + {"role": "system", "content": "Ti si AI asistent za PGŽ sport. Odgovori na pitanje koristeći isključivo podatke iz godišnjaka Zajednice sportova PGŽ. Odgovaraj kratko i jasno na hrvatskom jeziku."}, + {"role": "user", "content": f"Kontekst iz godišnjaka:\n{context}\n\nPitanje: {question}"} + ], + "max_tokens": 300, + "temperature": 0.1 + }, + timeout=30) + + if llm_r.status_code == 200: + answer = llm_r.json()["choices"][0]["message"]["content"] + else: + # Fallback: return best matching text + answer = sources[0]["text"][:500] if sources else "Nema odgovora." + + return {"answer": answer, "sources": sources} + + except Exception as e: + return {"answer": f"Greška: {str(e)[:100]}", "sources": []} + + +# ===== BUDGET ANALYTICS — Scoring model za PGŽ proračun ===== + +@router.get("/analytics/budget-score") +def get_budget_score(godina: int = 2025, min_clanova: int = 0, sport: str = ""): + """Bodovanje saveza za PGZ proracunsku odluku (HOO kriteriji, max 100 bodova)""" + sql = """ + WITH stat AS ( + SELECT savez_id, + MAX(registriranih) as registriranih, + MAX(trenera) as trenera, + MAX(reprezentativaca) as repr, + MAX(klubova_clanica) as klubova_stat + FROM pgz_sport.statistika_saveza + WHERE godina <= %(god)s + GROUP BY savez_id + ), + clan_stat AS ( + SELECT k.savez_id, + COUNT(c.id) as n_clanova, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob, + COUNT(DISTINCT k.id) as n_klubova_clanovi + FROM pgz_sport.clanovi c + JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE c.aktivan = true + GROUP BY k.savez_id + ) + SELECT + s.id, + s.naziv, + s.sport, + s.grad, + COALESCE(st.registriranih, 0) as registriranih, + COALESCE(cs.n_clanova, 0) as clanova_u_sustavu, + COALESCE(cs.c_s_dob, 0) as clanova_s_dob, + COALESCE(CASE WHEN cs.n_clanova > 0 THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*100,1) ELSE 0 END, 0) as pct_s_dob, + COALESCE(cs.n_klubova_clanovi, 0) as klubova, + COALESCE(st.trenera, 0) as trenera, + COALESCE(st.repr, 0) as reprezentativaca, + LEAST(25, COALESCE(st.registriranih, 0) / 50) as bod_clanovi, + LEAST(15, COALESCE(cs.n_klubova_clanovi, 0) * 2) as bod_klubovi, + LEAST(15, COALESCE(st.trenera, 0) * 2) as bod_treneri, + LEAST(20, CASE WHEN cs.n_clanova > 0 + THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*20,0) + ELSE 0 END) as bod_evidencija, + LEAST(15, COALESCE(st.repr, 0)) as bod_reprezentativci, + (LEAST(25, COALESCE(st.registriranih, 0) / 50) + + LEAST(15, COALESCE(cs.n_klubova_clanovi, 0) * 2) + + LEAST(15, COALESCE(st.trenera, 0) * 2) + + LEAST(20, CASE WHEN cs.n_clanova > 0 + THEN ROUND(cs.c_s_dob::numeric/cs.n_clanova*20,0) + ELSE 0 END) + + LEAST(15, COALESCE(st.repr, 0)) + ) as score_ukupno + FROM pgz_sport.savezi s + LEFT JOIN stat st ON st.savez_id = s.id + LEFT JOIN clan_stat cs ON cs.savez_id = s.id + WHERE s.aktivan = true + AND COALESCE(cs.n_clanova, st.registriranih, 0) >= %(min_cl)s + """ + if sport: + sql += " AND s.sport ILIKE %(sport)s" + sql += " ORDER BY score_ukupno DESC, registriranih DESC LIMIT 50" + params = {"god": godina, "min_cl": min_clanova, "sport": f"%{sport}%"} + return [dict(r) for r in db_query(sql, params)] + +@router.get("/analytics/proracun-trend") +def get_proracun_trend(): + """Višegodišnji trend proračuna PGŽ za sport s indeksima rasta""" + sql = """ + SELECT godina, + ROUND(proracun_pgz::numeric, 2) as pgz_eur, + ROUND(ukupno_pgz::numeric, 2) as pgz_ukupno_eur, + ROUND(ministarstvo::numeric, 2) as ministarstvo_eur, + ROUND(ukupno::numeric, 2) as ukupno_eur, + ROUND(ukupno::numeric / NULLIF( + LAG(ukupno) OVER (ORDER BY godina), 0 + ) * 100 - 100, 1) as rast_pct + FROM pgz_sport.proracun + ORDER BY godina + """ + return [dict(r) for r in db_query(sql)] + + +@router.get("/analytics/savez-drill") +def get_savez_drill(savez_id: int, godina: int = 2025): + """Deep drill-down za jedan savez""" + savez = db_one("SELECT * FROM pgz_sport.savezi WHERE id = %s", (savez_id,)) + if not savez: + return {"error": "Savez not found"} + + klubovi = db_query(""" + SELECT k.id, k.naziv, k.grad, k.oib, + COUNT(c.id) as n_clanova, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as s_dob + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true + WHERE k.savez_id = %s AND k.aktivan = true + GROUP BY k.id, k.naziv, k.grad, k.oib + ORDER BY n_clanova DESC LIMIT 20 + """, (savez_id,)) + + statistike = db_query(""" + SELECT godina, registriranih, trenera, reprezentativaca, + klubova_clanica, stipendiranih, zaposlenika + FROM pgz_sport.statistika_saveza + WHERE savez_id = %s ORDER BY godina DESC LIMIT 8 + """, (savez_id,)) + + javne = db_query(""" + SELECT godina, iznos_eur, naslov, vrsta + FROM pgz_sport.javne_potrebe + WHERE LOWER(korisnik) LIKE LOWER(%s) OR LOWER(korisnik) LIKE LOWER(%s) + ORDER BY godina DESC LIMIT 10 + """, (f"%{dict(savez).get('naziv','')[0:20]}%", f"%{dict(savez).get('naziv','').split()[-1] if dict(savez).get('naziv','') else ''}%")) + + lij = db_query(""" + SELECT lp.datum_pregleda, lp.vrijedi_do, lp.spreman_za_natjecanje + FROM pgz_sport.lijecnicki_pregledi lp + JOIN pgz_sport.klubovi k ON k.id = lp.klub_id + WHERE k.savez_id = %s + ORDER BY lp.datum_pregleda DESC LIMIT 10 + """, (savez_id,)) + + clanovi_spol = db_one(""" + SELECT + COUNT(c.id) as ukupno, + COUNT(CASE WHEN c.spol='M' THEN 1 END) as muski, + COUNT(CASE WHEN c.spol='Z' THEN 1 END) as zenski, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as s_dob, + MIN(EXTRACT(YEAR FROM age(c.datum_rodjenja)))::int as min_dob, + MAX(EXTRACT(YEAR FROM age(c.datum_rodjenja)))::int as max_dob + FROM pgz_sport.clanovi c + JOIN pgz_sport.klubovi k ON k.id = c.klub_id + WHERE k.savez_id = %s AND c.aktivan = true + """, (savez_id,)) + + return { + "savez": dict(savez), + "klubovi": [dict(k) for k in klubovi], + "statistike": [dict(s) for s in statistike], + "javne_potrebe": [dict(j) for j in javne], + "lijecnicki": [dict(l) for l in lij], + "clanovi_spol": dict(clanovi_spol) if clanovi_spol else {} + } + +@router.get("/analytics/klub-score") +def get_klub_score( + min_clanova: int = 0, + sport: str = "", + savez_id: int = 0, + sort: str = "score" +): + """Bodovanje klubova po sličnim kriterijima""" + filters = ["k.aktivan = true"] + params: list = [] + if min_clanova: + filters.append("COUNT(c.id) >= %s") + params.append(min_clanova) + if sport: + filters.append("k.sport ILIKE %s") + params.append(f"%{sport}%") + if savez_id: + filters.append("k.savez_id = %s") + params.append(savez_id) + + sql = f""" + SELECT k.id, k.naziv, k.grad, k.sport, + s.naziv as savez, + COUNT(c.id) as n_clanova, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob, + CASE WHEN COUNT(c.id) > 0 THEN ROUND(COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*100,1) ELSE 0 END as pct_dob, + COUNT(DISTINCT CASE WHEN c.spol='M' THEN c.id END) as muski, + COUNT(DISTINCT CASE WHEN c.spol='Z' THEN c.id END) as zenski, + ROUND(COUNT(c.id) * 0.5 + + CASE WHEN COUNT(c.id) > 0 THEN COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*30 ELSE 0 END + , 1) as score + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true + LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id + WHERE {' AND '.join(filters[:2])} + GROUP BY k.id, k.naziv, k.grad, k.sport, s.naziv + HAVING COUNT(c.id) >= {min_clanova} + {"AND k.sport ILIKE '"+sport+"'" if sport else ""} + ORDER BY score DESC + LIMIT 50 + """ + # Simplify query + where_parts = ["k.aktivan = true"] + p2 = [] + if sport: + where_parts.append("k.sport ILIKE %s") + p2.append(f"%{sport}%") + if savez_id: + where_parts.append("k.savez_id = %s") + p2.append(savez_id) + + sql2 = f""" + SELECT k.id, k.naziv, k.grad, k.sport, + s.naziv as savez, + COUNT(c.id) as n_clanova, + COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END) as c_s_dob, + CASE WHEN COUNT(c.id) > 0 THEN ROUND(COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*100,1) ELSE 0 END as pct_dob, + COUNT(DISTINCT CASE WHEN c.spol='M' THEN c.id END) as muski, + COUNT(DISTINCT CASE WHEN c.spol='Z' THEN c.id END) as zenski, + ROUND(COUNT(c.id) * 0.5 + + CASE WHEN COUNT(c.id) > 0 + THEN COUNT(CASE WHEN c.datum_rodjenja IS NOT NULL THEN 1 END)::numeric/COUNT(c.id)*30 + ELSE 0 END, 1) as score + FROM pgz_sport.klubovi k + LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id AND c.aktivan = true + LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id + WHERE {' AND '.join(where_parts)} + GROUP BY k.id, k.naziv, k.grad, k.sport, s.naziv + HAVING COUNT(c.id) >= {min_clanova} + ORDER BY score DESC + LIMIT 50 + """ + return [dict(r) for r in db_query(sql2, p2)] + + +# ===== FILTER OPTIONS (for dropdowns) ===== +@router.get("/analytics/filter-options") +def get_filter_options(): + """Vraca dostupne vrijednosti za dropdown filtere""" + sportovi = db_query(""" + SELECT DISTINCT INITCAP(LOWER(TRIM(sport))) as sport + FROM pgz_sport.savezi + WHERE aktivan=true AND sport IS NOT NULL AND TRIM(sport) != '' + ORDER BY 1 + """) + gradovi = db_query(""" + SELECT DISTINCT grad FROM pgz_sport.savezi + WHERE aktivan=true AND grad IS NOT NULL + ORDER BY grad + """) + sport_klub = db_query(""" + SELECT DISTINCT INITCAP(LOWER(TRIM(sport))) as sport + FROM pgz_sport.klubovi + WHERE aktivan=true AND sport IS NOT NULL AND TRIM(sport) != '' + ORDER BY 1 LIMIT 40 + """) + savezi_list = db_query(""" + SELECT id, naziv FROM pgz_sport.savezi + WHERE aktivan=true ORDER BY naziv LIMIT 60 + """) + return { + "sportovi_savezi": [r["sport"] for r in sportovi], + "sportovi_klubovi": [r["sport"] for r in sport_klub], + "gradovi": [r["grad"] for r in gradovi], + "savezi": [{"id": r["id"], "naziv": r["naziv"]} for r in savezi_list] + } + + +@router.get("/sport/objekti") +def get_sport_objekti(tip: str = "", grad: str = "", q: str = ""): + """106 sportskih objekata PGZ s filterima""" + filters = ["aktivan = true"] + params = [] + if tip: + filters.append("LOWER(tip) = LOWER(%s)") + params.append(tip) + if grad: + filters.append("LOWER(grad) = LOWER(%s)") + params.append(grad) + if q: + filters.append("(LOWER(naziv) LIKE LOWER(%s) OR LOWER(adresa) LIKE LOWER(%s))") + params.extend([f"%{q}%", f"%{q}%"]) + + sql = f""" + SELECT id, naziv, tip, grad, adresa, lat, lng, + upravitelj, kapacitet, sportovi, + izgradeno, obnovljeno_god, natkrita, + napomena, web, aktivan + FROM pgz_sport.sportski_objekti + WHERE {' AND '.join(filters)} + ORDER BY grad, naziv + LIMIT 200 + """ + return [dict(r) for r in db_query(sql, params)] + + +# ═══════════════════════════════════════════════════════ +# GRAPH — person/club/savez connections from osobe_funkcije +# ═══════════════════════════════════════════════════════ +@router.get("/graph/connections") +def graph_connections(q: str = "", savez_id: int = None, limit: int = 300): + """D3 force graph nodes+edges from pgz_sport.osobe_funkcije.""" + filters = ["1=1"] + params = [] + if q: + filters.append("(LOWER(of.ime||' '||of.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(s.naziv,''||k.naziv,'')) LIKE LOWER(%s))") + params += [f"%{q}%", f"%{q}%"] + if savez_id: + filters.append("of.savez_id = %s") + params.append(savez_id) + + rows = db_query(f""" + SELECT of.id, of.ime, of.prezime, of.funkcija, of.sport, + of.savez_id, s.naziv as savez_naziv, + of.klub_id, k.naziv as klub_naziv, + of.mandate_od, of.mandate_do + FROM pgz_sport.osobe_funkcije of + LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id + LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id + WHERE {" AND ".join(filters)} + ORDER BY of.prezime, of.ime + LIMIT %s + """, params + [limit]) + + nodes = {} + edges = [] + + def add_node(nid, label, ntype, meta=None): + if nid not in nodes: + nodes[nid] = {"id": nid, "label": label, "type": ntype, **(meta or {})} + + for r in rows: + pid = f"p_{r['id']}" + add_node(pid, f"{r['ime']} {r['prezime']}", "person", + {"funkcija": r.get("funkcija"), "sport": r.get("sport")}) + if r.get("savez_id"): + sid = f"s_{r['savez_id']}" + sn = r.get("savez_naziv") or f"Savez {r['savez_id']}" + add_node(sid, sn[:40]+('…' if len(sn)>40 else ''), "savez") + edges.append({"source": pid, "target": sid, + "label": r.get("funkcija",""), "sport": r.get("sport","")}) + if r.get("klub_id"): + kid = f"k_{r['klub_id']}" + add_node(kid, r.get("klub_naziv") or f"Klub {r['klub_id']}", "klub") + edges.append({"source": pid, "target": kid, + "label": r.get("funkcija",""), "sport": r.get("sport","")}) + + return {"nodes": list(nodes.values()), "edges": edges, + "count": len(nodes), "edge_count": len(edges)} + + +# ═══════════════════════════════════════════════════════ +# POTPORE AGGREGATE — sve izvore +# ═══════════════════════════════════════════════════════ +@router.get("/potpore/aggregate") +def potpore_aggregate(q: str = "", limit: int = 300): + """Aggregate sufinanciranje + javne_potrebe per korisnik.""" + like = f"%{q}%" if q else "%" + rows = db_query(""" + SELECT korisnik, sport, SUM(iznos_eur) as ukupno_eur, + COUNT(*) as n_potpore, MIN(godina) as od_god, MAX(godina) as do_god, + 'sufinanciranje' as tip, + MAX(source_url) as source_url, + STRING_AGG(DISTINCT COALESCE(izvor,''), ', ') as izvori + FROM pgz_sport.sufinanciranje_sport + WHERE LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s) + GROUP BY korisnik, sport + UNION ALL + SELECT korisnik, NULL as sport, SUM(iznos_eur) as ukupno_eur, + COUNT(*) as n_potpore, MIN(godina) as od_god, MAX(godina) as do_god, + 'javne_potrebe' as tip, + MAX(url) as source_url, + STRING_AGG(DISTINCT COALESCE(izvor,''), ', ') as izvori + FROM pgz_sport.javne_potrebe + WHERE LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s) AND korisnik IS NOT NULL + GROUP BY korisnik + ORDER BY ukupno_eur DESC NULLS LAST + LIMIT %s + """, [like, like, limit]) + return {"count": len(rows), "results": rows} + + +# ═══════════════════════════════════════════════════════ +# USER DASHBOARD +# ═══════════════════════════════════════════════════════ +@router.get("/user/dashboard") +def user_dashboard(user=Depends(require_user)): + """Personalized dashboard for logged-in user.""" + ut = user.get("user_type", "") + klub_id = user.get("klub_id") + result = {"user_type": ut, "user": {"email": user.get("email"), "ime": user.get("full_name"), "user_type": ut}} + + if ut in ("klub_admin", "klub_user") and klub_id: + klub = db_one("SELECT * FROM pgz_sport.klubovi WHERE id=%s", (klub_id,)) + stats_row = db_one(""" + SELECT + (SELECT count(*) FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan=true) as clanovi, + (SELECT count(*) FROM pgz_sport.lijecnicki WHERE klub_id=%s AND vrijedi_do < CURRENT_DATE) as med_exp, + (SELECT count(*) FROM pgz_sport.lijecnicki WHERE klub_id=%s AND vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE+30) as med_warn + """, (klub_id, klub_id, klub_id)) + result["klub"] = klub + result["stats"] = stats_row or {} + elif ut in ("pgz_admin", "super_admin", "pgz_user"): + stats = db_one(""" + SELECT + (SELECT count(*) FROM pgz_sport.klubovi WHERE aktivan=true) as klubovi, + (SELECT count(*) FROM pgz_sport.savezi WHERE aktivan=true) as savezi, + (SELECT count(*) FROM pgz_sport.clanovi WHERE aktivan=true) as clanovi, + (SELECT count(*) FROM pgz_sport.lijecnicki WHERE vrijedi_do < CURRENT_DATE) as med_exp, + (SELECT count(*) FROM pgz_sport.pgz_sport_users WHERE aktivan=true) as korisnici + """, ()) + result["stats"] = stats or {} + + return result + +# ═══════════════════════════════════════════════════════ +# GODIŠNJACI — serve PDF and TXT files +# ═══════════════════════════════════════════════════════ +import os +from fastapi.responses import FileResponse, HTMLResponse + +GODISNJACI_DIR = "/opt/pgz-sport/_data/godisnjaci" + +@router.get("/godisnjaci/pdf/{god}") +def godisnjak_pdf(god: int): + path = os.path.join(GODISNJACI_DIR, f"godisnjak_{god}.pdf") + if not os.path.exists(path): + raise HTTPException(404, f"Godišnjak {god} nije pronađen") + return FileResponse(path, media_type="application/pdf", + filename=f"godisnjak_ZSP_PGZ_{god}.pdf") + +@router.get("/godisnjaci/txt/{god}") +def godisnjak_txt(god: int): + path = os.path.join(GODISNJACI_DIR, f"godisnjak_{god}.txt") + if not os.path.exists(path): + raise HTTPException(404, f"Godišnjak {god}.txt nije pronađen") + return FileResponse(path, media_type="text/plain; charset=utf-8", + filename=f"godisnjak_ZSP_PGZ_{god}.txt") + +# ═══════════════════════════════════════════════ +# GODIŠNJACI — serve original PGZ PDFs +# ═══════════════════════════════════════════════ +import re as _re, os as _os + +GODISNJACI_PGZ_DIR = "/opt/pgz-sport/_downloads/godisnjaci_szpgz" +GODISNJACI_DATA_DIR = "/opt/pgz-sport/_data/godisnjaci" + +def _god_map(): + m = {} + if not _os.path.exists(GODISNJACI_PGZ_DIR): + return m + for f in sorted(_os.listdir(GODISNJACI_PGZ_DIR)): + mt = _re.search(r'(?:godisnjak[_-]?)(\d{4})', f, _re.IGNORECASE) + if mt: + y = int(mt.group(1)) + if 2006 <= y <= 2025: + size = _os.path.getsize(_os.path.join(GODISNJACI_PGZ_DIR, f)) + display = _re.sub(r'^[a-f0-9]{16}_', '', f) + display = _re.sub(r'_\d{4}-\d{2}-\d{2}-\d+_\w+', '', display).replace('.pdf','') + m[y] = {"file": f, "display": display, "size_kb": size//1024, "god": y} + return m + +@router.get("/godisnjaci/popis") +def godisnjaci_popis(): + """Lista svih godišnjaka ZSP PGŽ s meta podacima.""" + m = _god_map() + rows = sorted(m.values(), key=lambda x: x["god"], reverse=True) + return {"count": len(rows), "results": rows} + +@router.get("/godisnjaci/pgz-pdf/{god}") +def godisnjak_pgz_pdf(god: int): + """Serviraj originalni PGZ PDF godišnjaka.""" + m = _god_map() + if god not in m: + raise HTTPException(404, f"Godišnjak {god} nije dostupan") + path = _os.path.join(GODISNJACI_PGZ_DIR, m[god]["file"]) + return FileResponse(path, media_type="application/pdf", + filename=f"ZSP-PGZ-Sportski-godisnjak-{god}.pdf") + + +# ═══════════════════════════════════════════════════════ +# PRORAČUN — breakdown by sport + recipient +# ═══════════════════════════════════════════════════════ +@router.get("/analytics/proracun-sport") +def proracun_sport(godina: int = None): + """Raspodjela sufinanciranja po sportu za godinu.""" + import datetime + yr = godina or datetime.date.today().year + rows = db_query(""" + SELECT sport, + sum(iznos_eur) as ukupno, + count(*) as n_stavki, + STRING_AGG(DISTINCT izvor, ', ') as izvori + FROM pgz_sport.sufinanciranje_sport + WHERE godina = %s AND iznos_eur > 0 + GROUP BY sport + ORDER BY ukupno DESC + """, (yr,)) + # Get detail recipients + detail = db_query(""" + SELECT korisnik, sport, iznos_eur, vrsta, izvor, source_url + FROM pgz_sport.sufinanciranje_sport + WHERE godina = %s AND iznos_eur > 0 + ORDER BY iznos_eur DESC + LIMIT 200 + """, (yr,)) + total = float(db_exec("SELECT COALESCE(sum(iznos_eur),0) FROM pgz_sport.sufinanciranje_sport WHERE godina=%s", (yr,)) or 0) + return {"godina": yr, "total": total, "po_sportu": rows, "detalji": detail} + + +# ═══════════════════════════════════════════════════════ +# POTPORE — by year filter +# ═══════════════════════════════════════════════════════ +@router.get("/potpore/by-year") +def potpore_by_year(godina: int = None, q: str = ""): + """Sufinanciranje za specifičnu godinu.""" + import datetime + yr = godina or datetime.date.today().year + like = f"%{q}%" if q else "%" + rows = db_query(""" + SELECT korisnik, sport, iznos_eur, vrsta, napomena, izvor, source_url, godina, + (SELECT k.id FROM pgz_sport.klubovi k WHERE LOWER(k.naziv) LIKE LOWER('%%'||LEFT(korisnik,20)||'%%') AND k.aktivan=true LIMIT 1) as klub_id + FROM pgz_sport.sufinanciranje_sport + WHERE godina = %s AND LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s) + ORDER BY iznos_eur DESC NULLS LAST + LIMIT 500 + """, (yr, like)) + total = sum(float(r.get('iznos_eur') or 0) for r in rows) + return {"godina": yr, "count": len(rows), "total": total, "results": rows} + + +# ═══════════════════════════════════════════════════════ +# MULTI-CHAIR conflict of interest +# ═══════════════════════════════════════════════════════ +@router.get("/graph/multi-chair") +def multi_chair(): + """Persons sitting in multiple organizations.""" + rows = db_query(""" + SELECT ime, prezime, MAX(of.oib) as oib, + count(DISTINCT COALESCE(of.savez_id::text, of.klub_id::text, of.organizacija)) as n_orgs, + STRING_AGG(DISTINCT COALESCE(s.naziv, k.naziv, of.organizacija), ' | ' + ORDER BY COALESCE(s.naziv, k.naziv, of.organizacija)) as orgs, + STRING_AGG(DISTINCT of.funkcija, ', ') as funkcije + FROM pgz_sport.osobe_funkcije of + LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id + LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id + GROUP BY LOWER(ime), LOWER(prezime), ime, prezime + HAVING count(DISTINCT COALESCE(of.savez_id::text, of.klub_id::text, of.organizacija)) >= 2 + ORDER BY n_orgs DESC, prezime + """) + return {"count": len(rows), "results": rows} + + +@router.get("/graph/iframe", response_class=HTMLResponse) +def graph_iframe(savez_id: int = None, q: str = "", limit: int = 300): + """Serve complete D3 force graph as HTML — iframe approach.""" + # Get data + filters = ["1=1"] + params = [] + if q: + filters.append("(LOWER(of.ime||' '||of.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(s.naziv,'')) LIKE LOWER(%s))") + params += [f"%{q}%", f"%{q}%"] + if savez_id: + filters.append("of.savez_id = %s") + params.append(savez_id) + + rows = db_query(f""" + SELECT of.id, of.ime, of.prezime, of.funkcija, of.sport, of.oib, + of.savez_id, s.naziv as savez_naziv, + of.klub_id, k.naziv as klub_naziv + FROM pgz_sport.osobe_funkcije of + LEFT JOIN pgz_sport.savezi s ON of.savez_id = s.id + LEFT JOIN pgz_sport.klubovi k ON of.klub_id = k.id + WHERE {" AND ".join(filters)} + ORDER BY of.prezime, of.ime LIMIT %s + """, params + [limit]) + + nodes = {} + edges = [] + + def add_node(nid, label, ntype, meta=None): + if nid not in nodes: + nodes[nid] = {"id": nid, "label": label, "type": ntype, **(meta or {})} + + # Count multi-chair + person_org_count = {} + for r in rows: + pid = f"p_{r['id']}" + person_org_count[pid] = person_org_count.get(pid, 0) + 1 + + for r in rows: + pid = f"p_{r['id']}" + add_node(pid, f"{r['ime']} {r['prezime']}", "person", + {"funkcija": r.get("funkcija"), "sport": r.get("sport"), + "oib": r.get("oib"), "multiChair": person_org_count.get(pid,0) > 1}) + if r.get("savez_id"): + sid = f"s_{r['savez_id']}" + sn = r.get("savez_naziv") or f"Savez {r['savez_id']}" + add_node(sid, sn[:40]+("…" if len(sn)>40 else ""), "savez") + edges.append({"source": pid, "target": sid, + "label": (r.get("funkcija") or "")[:30], "sport": r.get("sport","")}) + if r.get("klub_id"): + kid = f"k_{r['klub_id']}" + kn = r.get("klub_naziv") or f"Klub {r['klub_id']}" + add_node(kid, kn[:35]+("…" if len(kn)>35 else ""), "klub") + edges.append({"source": pid, "target": kid, + "label": (r.get("funkcija") or "")[:30], "sport": r.get("sport","")}) + + import json as _json + nodes_json = _json.dumps(list(nodes.values())) + edges_json = _json.dumps(edges) + + html = """ + + + + + + + +
+
+
+
+
+
+
+
+
Osoba
+
Savez
+
Klub
+
+
+ + +""" + return html + + + + +@router.get("/med/pregled-status") +def med_pregled_status(q: str = "", klub_id: int = None): + """Status medicinskih pregleda clanova.""" + import datetime + today = datetime.date.today() + warn_date = today + datetime.timedelta(days=30) + + filters = ["1=1"] + params = [] + if q: + filters.append("(LOWER(c.ime||' '||c.prezime) LIKE LOWER(%s) OR LOWER(COALESCE(k.naziv,'')) LIKE LOWER(%s))") + params += [f"%{q}%", f"%{q}%"] + if klub_id: + filters.append("c.klub_id = %s") + params.append(klub_id) + + rows = db_query(f""" + SELECT c.id, c.ime, c.prezime, c.datum_rodenja, c.licenca_vrijedi_do AS med_expiry, + c.kategorija, c.hoo_kategorija, c.sport, c.pozicija, + k.naziv as klub_naziv, k.id as klub_id + FROM pgz_sport.clanovi c + LEFT JOIN pgz_sport.klubovi k ON c.klub_id = k.id + WHERE {" AND ".join(filters)} + ORDER BY c.licenca_vrijedi_do ASC NULLS LAST, c.prezime + LIMIT 500 + """, params) + + for r in rows: + exp = r.get("med_expiry") + if not exp: + r["status"] = "unknown" + elif exp < today: + r["status"] = "expired" + elif exp <= warn_date: + r["status"] = "warning" + else: + r["status"] = "ok" + + expired = sum(1 for r in rows if r["status"] == "expired") + warning = sum(1 for r in rows if r["status"] == "warning") + ok = sum(1 for r in rows if r["status"] == "ok") + unknown = sum(1 for r in rows if r["status"] == "unknown") + + return { + "count": len(rows), "expired": expired, "warning": warning, + "ok": ok, "unknown": unknown, "rows": rows + } + + +@router.get("/erp/putni-nalozi") +def erp_putni_nalozi(klub_id: int = None, status: str = None): + """Lista putnih naloga.""" + filters = ["1=1"] + params = [] + if klub_id: + filters.append("pn.klub_id = %s"); params.append(klub_id) + if status: + filters.append("pn.status = %s"); params.append(status) + + # Table might not exist yet + try: + rows = db_query(f""" + SELECT pn.*, k.naziv as klub_naziv, + n.naziv as natjecanje_naziv + FROM pgz_sport.putni_nalozi pn + LEFT JOIN pgz_sport.klubovi k ON pn.klub_id = k.id + LEFT JOIN pgz_sport.natjecanja n ON pn.natjecanje_id = n.id + WHERE {" AND ".join(filters)} + ORDER BY pn.datum DESC LIMIT 100 + """, params) + return {"count": len(rows), "rows": rows} + except Exception: + return {"count": 0, "rows": [], "note": "Tablica putni_nalozi u razvoju"} + + +@router.post("/erp/putni-nalozi") +def erp_putni_nalog_create(body: dict = Body({})): + """Kreiraj novi putni nalog.""" + try: + db_exec(""" + CREATE TABLE IF NOT EXISTS pgz_sport.putni_nalozi ( + id SERIAL PRIMARY KEY, + klub_id INTEGER, + natjecanje_id INTEGER, + destinacija TEXT, + datum DATE, + broj_sudionika INTEGER DEFAULT 1, + iznos_eur NUMERIC(10,2), + napomena TEXT, + status TEXT DEFAULT 'na_cekanju', + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + db_exec(""" + INSERT INTO pgz_sport.putni_nalozi + (klub_id, natjecanje_id, destinacija, datum, broj_sudionika, iznos_eur, napomena) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, (body.get("klub_id"), body.get("natjecanje_id"), body.get("destinacija"), + body.get("datum"), body.get("broj_sudionika", 1), + body.get("iznos_eur"), body.get("napomena"))) + return {"ok": True} + except Exception as e: + raise HTTPException(500, str(e)) + + +@router.get("/dms/documents") +def dms_documents(klub_id: int = None, limit: int = 20): + """Lista uploadanih dokumenata.""" + try: + rows = db_query(""" + SELECT id, naziv, filename, mime_type, size_kb, url, klub_id, created_at + FROM pgz_sport.dms_documents + ORDER BY created_at DESC LIMIT %s + """, (limit,)) + return {"count": len(rows), "rows": rows} + except Exception: + return {"count": 0, "rows": [], "note": "DMS tablica u razvoju"} + + +@router.post("/dms/upload") +async def dms_upload(file: UploadFile = File(...)): + """Upload dokumenta u DMS.""" + import os, uuid, aiofiles + upload_dir = "/opt/pgz-sport/uploads/dms" + os.makedirs(upload_dir, exist_ok=True) + + ext = os.path.splitext(file.filename)[1] + fname = f"{uuid.uuid4().hex}{ext}" + fpath = os.path.join(upload_dir, fname) + + content = await file.read() + with open(fpath, "wb") as f: + f.write(content) + + size_kb = len(content) // 1024 + url = f"/api/v2/dms/file/{fname}" + + try: + db_exec(""" + CREATE TABLE IF NOT EXISTS pgz_sport.dms_documents ( + id SERIAL PRIMARY KEY, naziv TEXT, filename TEXT, + mime_type TEXT, size_kb INTEGER, url TEXT, + klub_id INTEGER, created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + db_exec(""" + INSERT INTO pgz_sport.dms_documents (naziv, filename, mime_type, size_kb, url) + VALUES (%s, %s, %s, %s, %s) + """, (file.filename, fname, file.content_type, size_kb, url)) + except Exception: + pass + + return {"ok": True, "url": url, "size_kb": size_kb} + +# ═══════════════════════════════════════════════════════════════════ +# Fajl: graph_3d_endpoint.py (patch chunk for pgz_sport_v2_router.py) +# Verzija: 1.0.0 +# Datum: 04.05.2026 +# Autor: Damir Radulić +# Lokacija: /opt/pgz-sport/pgz_sport_v2_router.py (append before last line) +# Svrha: 3D person-network endpoint — multi-chair detection +# Zavisi od: db_query helper iz pgz_sport_v2_router +# Utječe na: novi /api/v2/graph/3d-network endpoint +# ═══════════════════════════════════════════════════════════════════ + +@router.get("/graph/3d-network") +def graph_3d_network(min_orgs: int = 2, top_n: int = 100, sport: str = ""): + """3D person-network graf. Multi-chair osobe + njihove organizacije. + + Args: + min_orgs: minimalan broj organizacija po osobi (default 2 = multi-chair) + top_n: max broj osoba (sortirano po n_orgs desc) + sport: filter po sportu saveza (opcionalno) + + Returns: + { + "nodes": [{"id": "p:dragan-naglic", "name": "Dragan Naglić", + "type": "person", "n_orgs": 6, "val": 6}, ...], + "links": [{"source": "p:dragan-naglic", "target": "klub:123", + "role": "predsjednik"}, ...], + "stats": {"persons": N, "orgs": M, "links": L, "multichair": K} + } + """ + # All person-org relationships + sql = """ + WITH all_links AS ( + SELECT lower(trim(predsjednik)) AS person_key, + initcap(trim(predsjednik)) AS person_name, + 'klub:'||k.id AS org_id, + k.naziv AS org_name, 'klub' AS org_type, + 'predsjednik' AS role, + k.sport + FROM pgz_sport.klubovi k + WHERE predsjednik IS NOT NULL AND length(trim(predsjednik)) > 5 + AND (%(sport)s = '' OR k.sport = %(sport)s) + UNION ALL + SELECT lower(trim(tajnik)), initcap(trim(tajnik)), + 'klub:'||k.id, k.naziv, 'klub', 'tajnik', k.sport + FROM pgz_sport.klubovi k + WHERE tajnik IS NOT NULL AND length(trim(tajnik)) > 5 + AND (%(sport)s = '' OR k.sport = %(sport)s) + UNION ALL + SELECT lower(trim(predsjednik)), initcap(trim(predsjednik)), + 'savez:'||s.id, s.naziv, 'savez', 'predsjednik', NULL + FROM pgz_sport.savezi s + WHERE predsjednik IS NOT NULL AND length(trim(predsjednik)) > 5 + UNION ALL + SELECT lower(trim(tajnik)), initcap(trim(tajnik)), + 'savez:'||s.id, s.naziv, 'savez', 'tajnik', NULL + FROM pgz_sport.savezi s + WHERE tajnik IS NOT NULL AND length(trim(tajnik)) > 5 + ), + person_stats AS ( + SELECT person_key, max(person_name) AS person_name, + count(DISTINCT org_id) AS n_orgs, + string_agg(DISTINCT org_type, ',') AS org_types + FROM all_links + GROUP BY person_key + HAVING count(DISTINCT org_id) >= %(min_orgs)s + ORDER BY count(DISTINCT org_id) DESC + LIMIT %(top_n)s + ) + SELECT al.person_key, ps.person_name, al.org_id, al.org_name, + al.org_type, al.role, al.sport, ps.n_orgs + FROM all_links al + JOIN person_stats ps ON al.person_key = ps.person_key + """ + rows = db_query(sql, {"min_orgs": min_orgs, "top_n": top_n, "sport": sport}) + + nodes = {} + links = [] + multichair = 0 + + for r in rows: + pid = "p:" + r["person_key"].replace(" ", "-").replace(",", "")[:60] + if pid not in nodes: + n_orgs = r["n_orgs"] + nodes[pid] = { + "id": pid, + "name": r["person_name"], + "type": "multichair" if n_orgs >= 2 else "person", + "n_orgs": n_orgs, + "val": min(n_orgs * 3, 30) # 3D graf node size + } + if n_orgs >= 2: + multichair += 1 + + oid = r["org_id"] + if oid not in nodes: + nodes[oid] = { + "id": oid, + "name": r["org_name"][:40], + "type": r["org_type"], + "sport": r["sport"], + "val": 5 + } + + links.append({ + "source": pid, + "target": oid, + "role": r["role"] + }) + + return { + "nodes": list(nodes.values()), + "links": links, + "stats": { + "persons": sum(1 for n in nodes.values() if n["type"] in ("person","multichair")), + "multichair": multichair, + "orgs": sum(1 for n in nodes.values() if n["type"] in ("klub","savez")), + "links": len(links), + "min_orgs": min_orgs, + "top_n": top_n, + "sport": sport or "svi" + } + } + + +@router.get("/graph/3d-iframe", response_class=HTMLResponse) +def graph_3d_iframe(min_orgs: int = 2, top_n: int = 100, sport: str = ""): + """v2.0 — 3D ForceGraph s drill-down detail panel + year/sport filtri + highlight search.""" + import os + p = os.path.join(os.path.dirname(__file__), "static", "sport_3d_v2.html") + if os.path.exists(p): + with open(p, encoding="utf-8") as f: + return f.read() + return "

3D iframe not found

" + + +# ═══════════════════════════════════════════════════════════════════════════ +# CC4-Sub1 (05.05.2026) — fill audit gaps: /api/v2/{klubovi,savezi,sport/} +# Author: cc4-sub1@rinet.one +# ═══════════════════════════════════════════════════════════════════════════ + +@router.get("/klubovi") +def v2_klubovi_list(q: Optional[str] = None, savez_id: Optional[int] = None, + sport: Optional[str] = None, grad: Optional[str] = None, + limit: int = 500): + """v2 alias for /api/klubovi — minimal listing for portal/CRM panels.""" + where = ["aktivan"] + params: List[Any] = [] + if q: + where.append("(naziv ILIKE %s OR oib ILIKE %s OR sport ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%", f"%{q}%"]) + if savez_id: + where.append("savez_id=%s"); params.append(savez_id) + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + if grad: + where.append("grad ILIKE %s"); params.append(f"%{grad}%") + params.append(max(1, min(limit, 2000))) + sql = f"""SELECT id, naziv, oib, sport, grad, savez_id, + region, broj_clanova, predsjednik, email, telefon, web + FROM pgz_sport.klubovi + WHERE {' AND '.join(where)} + ORDER BY naziv COLLATE "hr-HR-x-icu" + LIMIT %s""" + rows = db_query(sql, params) + return {"ok": True, "count": len(rows), "rows": rows} + + +@router.get("/savezi") +def v2_savezi_list(q: Optional[str] = None, razina: Optional[str] = None, + sport: Optional[str] = None, limit: int = 500): + """v2 alias for /api/savezi — minimal listing.""" + where = ["aktivan"] + params: List[Any] = [] + if q: + where.append("(naziv ILIKE %s OR sport ILIKE %s)") + params.extend([f"%{q}%", f"%{q}%"]) + if razina: + where.append("razina = %s"); params.append(razina) + if sport: + where.append("sport ILIKE %s"); params.append(f"%{sport}%") + params.append(max(1, min(limit, 2000))) + sql = f"""SELECT id, naziv, sport, razina, sjediste_zupanija, + godina_osnutka, predsjednik, email, web, + (SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova + FROM pgz_sport.savezi s + WHERE {' AND '.join(where)} + ORDER BY naziv COLLATE "hr-HR-x-icu" + LIMIT %s""" + rows = db_query(sql, params) + return {"ok": True, "count": len(rows), "rows": rows} + + +@router.get("/sport") +@router.get("/sport/") +def v2_sport_index(): + """v2 sport index — discovery endpoint listing sport-related sub-routes.""" + return { + "ok": True, + "service": "pgz_sport v2 sport namespace", + "endpoints": { + "GET /api/v2/sport/svi/stats": "all-sports aggregate stats", + "GET /api/v2/sport/{sport_naziv}/pregled": "drill-down per-sport view", + "POST /api/v2/sport/ask": "RAG sport agent (Q&A)", + "POST /api/v2/sport/lawyer": "RAG legal/regulation agent", + "GET /api/v2/sport/objekti": "sport facilities listing", + }, + } + diff --git a/_data/dokumenti_uploads/1777963915_37e2c3cbff6a_cc4_doc.txt b/_data/dokumenti_uploads/1777963915_37e2c3cbff6a_cc4_doc.txt new file mode 100644 index 0000000..ced735f --- /dev/null +++ b/_data/dokumenti_uploads/1777963915_37e2c3cbff6a_cc4_doc.txt @@ -0,0 +1 @@ +Test dokument za upload — Pravilnik o sportu 2026 diff --git a/_data/dokumenti_uploads/1777963943_8d76ef077804_sm.txt b/_data/dokumenti_uploads/1777963943_8d76ef077804_sm.txt new file mode 100644 index 0000000..53e274a --- /dev/null +++ b/_data/dokumenti_uploads/1777963943_8d76ef077804_sm.txt @@ -0,0 +1 @@ +smoke diff --git a/auth/auth_v2.py b/auth/auth_v2.py index 4af5761..5e05d83 100644 --- a/auth/auth_v2.py +++ b/auth/auth_v2.py @@ -514,7 +514,29 @@ def update_me(req: UpdateMeReq, request: Request, user = Depends(require_user)): db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)}, updated_at=now() WHERE id=%s", tuple(vals)) ip, ua = _client(request) audit(user["id"], "profile.update", meta={"fields": [f.split("=")[0] for f in fields]}, ip=ip, ua=ua) - return me(user) + # Re-fetch fresh user data and return same shape as GET /me + fresh = db_one("SELECT * FROM pgz_sport.users WHERE id=%s", (user["id"],)) + if not fresh: + raise HTTPException(404, "User not found after update") + enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type, + klub_id, savez_id, must_change_pwd, aktivan, status, + last_login, oib, telefon, phone, preferred_language, created_at, + avatar_url, gdpr_consent_at, google_picture + FROM pgz_sport.users WHERE id=%s""", (user["id"],)) + tenant = _resolve_tenant(enriched) + roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id + FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id + WHERE ur.user_id=%s AND ur.active=true""", (user["id"],)) + try: + twofa = db_one("SELECT secret IS NOT NULL AS enabled FROM pgz_sport.user_2fa WHERE user_id=%s", + (user["id"],)) or {"enabled": False} + except Exception: + twofa = {"enabled": False} + return {**enriched, + "tier": _tier_for(enriched.get("user_type") or ""), + "must_change_pwd": bool(enriched.get("must_change_pwd")), + "two_factor_enabled": bool(twofa.get("enabled")), + **tenant, "roles": roles} # ─────────────────────────── AVATAR UPLOAD ─────────────────────────── import shutil, pathlib