Files
pgz-sport/_audit/sub5_klubovi/run_sub5.py
T
damir 8e136351f9 CRISIS FIX: login flow + mobile responsive + token expiry handling
ROOT CAUSE ISOLATED:
Backend POST /api/auth/login, GET/PUT /api/auth/me, POST avatar, POST /logout
all return 200 OK (verified curl). Damirov problem is browser-side:
stale localStorage tokens that don't match current backend → 401 cascade
→ avatar upload appears as 'failed: 401' → profile changes 'lost'.

FIXES:
1. apiAuth() in app.html now:
   - Pre-checks JWT exp claim before request
   - On 401 response: clears localStorage (pgz_access/refresh/user) +
     redirects to /login?reason=unauthorized
   - On JWT expired: redirects to /login?reason=expired

2. login.html displays toast for ?reason=expired/unauthorized

3. Mobile responsive CSS (max-width: 768px):
   - app.html: hamburger menu, sidebar slide-in, full-width drill-down panel
   - sport2.html: KPI grid 2-col, klubovi 1-col, tables horizontal scroll
   - Both: viewport meta + media queries + touch-friendly buttons

4. Mobile menu toggle button + backdrop overlay added

VERIFIED E2E (curl):
- POST /auth/login → 200 + JWT
- GET /auth/me → 200 + telefon persisted
- PUT /auth/me → 200, DB row updated
- POST /auth/me/avatar → 200, file saved + avatar_url returned
- POST /auth/logout → 200, token revoked (next /me returns 401)
2026-05-05 09:14:46 +02:00

288 lines
10 KiB
Python

#!/usr/bin/env python3
# sub5_klubovi runner — W5 PGZ Sport data quality
# author: dradulic@outlook.com / damir@rinet.one
# date: 2026-05-05
# purpose: 5a adresa-as-naziv flagging, 5b lovacka drustva sport reclassification,
# 5c RSS/ZSPGZ membership cross-check (best-effort)
import os, json, re, datetime as dt, sys
import psycopg2
import psycopg2.extras
PG = dict(host='10.10.0.2', port=6432, dbname='rinet_v3',
user='rinet', password='R1net2026!SecureDB#v7')
OUT_DIR = '/opt/pgz-sport/_audit/sub5_klubovi'
os.makedirs(OUT_DIR, exist_ok=True)
NOW = dt.date.today().isoformat() # 2026-05-05
# Heuristics for inferring naziv from sport+sjediste
SPORT_PREFIX = {
'odbojka': 'OK',
'nogomet': 'NK',
'rukomet': 'RK',
'košarka': 'KK',
'kosarka': 'KK',
'boćanje': 'BK',
'bocanje': 'BK',
'tenis': 'TK',
'plivanje': 'PK',
'atletika': 'AK',
'streljaštvo': 'SK',
'streljastvo': 'SK',
'jedrenje': 'JK',
'vaterpolo': 'VK',
'kuglanje': 'KGK',
'šah': 'ŠK',
'sah': 'ŠK',
}
def conn():
return psycopg2.connect(**PG)
def task_5a(cur):
"""Identify clubs with bogus naziv (address/url/email/heading) and flag in napomena."""
cur.execute("""
SELECT id, naziv, sjediste, savez_id, sport, napomena, grad
FROM pgz_sport.klubovi
WHERE
naziv ~* '\\d{5}'
OR naziv ~* '^www\\.'
OR naziv ~* '^https?://'
OR naziv ~ '@.*\\.'
OR naziv ~* '^(propozicije|ždrijeb|zdrijeb|satnica|video[ ]+seminar|raspored)'
OR naziv ~ ',\\s*\\d{2}\\s*\\d{3}'
ORDER BY id
""")
rows = cur.fetchall()
actions = []
for r in rows:
rid, naziv, sjediste, savez_id, sport, napomena, grad = r
original = naziv
kind = 'unknown'
if re.match(r'^www\.', naziv, re.I) or re.match(r'^https?://', naziv, re.I):
kind = 'url'
elif re.search(r'@.*\.', naziv) and ' ' not in naziv.strip():
kind = 'email'
elif re.search(r',\s*\d{2}\s*\d{3}', naziv) or re.search(r'\d{5}', naziv):
kind = 'address'
elif re.match(r'^(propozicije|ždrijeb|zdrijeb|satnica|video|raspored|seminar)', naziv, re.I):
kind = 'heading/event'
# Try to infer naziv only for address-kind with high confidence
suggestion = None
confidence = 0.0
sport_l = (sport or '').lower()
prefix = SPORT_PREFIX.get(sport_l)
# Try to extract grad from naziv if it's an address (e.g. "..., 51 000 Rijeka")
m = re.search(r',\s*\d{2}\s*\d{3}\s*([\w\s\-šđč枊ĐČĆŽ]+?)\s*$', naziv)
addr_grad = m.group(1).strip() if m else None
if kind == 'address' and prefix and addr_grad:
suggestion = f'{prefix} [VERIFY-{addr_grad.upper()}]'
confidence = 0.5 # below threshold of 0.9 — DO NOT auto-rename
elif kind == 'url' and prefix:
# URL → maybe extract club name from domain
dom_m = re.search(r'(?:www\.|//)([a-z0-9\-]+)', naziv, re.I)
dom = dom_m.group(1) if dom_m else ''
suggestion = f'{prefix} [VERIFY-from-URL-{dom}]'
confidence = 0.4
# Build napomena prefix
new_napomena_chunk = f'sub5a_{NOW}: TODO_FIX_NAME — naziv looks like {kind}; original="{original}"'
if napomena:
new_napomena = napomena.rstrip() + ' | ' + new_napomena_chunk
else:
new_napomena = new_napomena_chunk
# Apply update — DO NOT change naziv (confidence < 0.9 always for these)
cur.execute("""
UPDATE pgz_sport.klubovi
SET napomena = %s,
updated_at = now(),
aktivan = false
WHERE id = %s
""", (new_napomena, rid))
actions.append(dict(
id=rid,
original_naziv=original,
kind=kind,
suggestion=suggestion,
confidence=confidence,
sport=sport,
sjediste=sjediste,
savez_id=savez_id,
action='flagged_in_napomena+aktivan=false (no rename, conf<0.9)'
))
return actions
def task_5b(cur):
"""All 49 'kulturno-umjetnicko' rows are LOVAČKA DRUŠTVA — reclassify to sport='lovstvo'."""
cur.execute("""
SELECT id, naziv, sport, sjediste, savez_id, napomena
FROM pgz_sport.klubovi
WHERE sport = 'kulturno-umjetnicko'
ORDER BY id
""")
rows = cur.fetchall()
actions = []
sample_ids = []
for r in rows:
rid, naziv, sport, sjediste, savez_id, napomena = r
is_lovacko = bool(re.match(r'^\s*"?\s*(hrvatsko\s+)?lovačko\s+društvo', naziv, re.I)) or 'LOVAČKO' in naziv.upper()
is_kud_marker = bool(re.search(r'\b(kud|kulturno-umjetn|folklor|tamburaš|tamburaski)', naziv, re.I))
if is_lovacko and not is_kud_marker:
new_sport = 'lovstvo'
reason = 'naziv počinje sa "Lovačko društvo" — nije KUD, kategorija lovstvo'
chunk = f'sub5b_{NOW}: bio sport=kulturno-umjetnicko, vraćen na lovstvo (LD prefix detected)'
new_napomena = (napomena.rstrip() + ' | ' + chunk) if napomena else chunk
cur.execute("""
UPDATE pgz_sport.klubovi
SET sport = %s, napomena = %s, updated_at = now()
WHERE id = %s
""", (new_sport, new_napomena, rid))
actions.append(dict(
id=rid, naziv=naziv,
sport_before='kulturno-umjetnicko',
sport_after=new_sport,
reason=reason
))
else:
# Genuinely a KUD
actions.append(dict(
id=rid, naziv=naziv,
sport_before='kulturno-umjetnicko',
sport_after='kulturno-umjetnicko',
reason='ostavljen — naziv ne ukazuje na sportsku/lovačku klasifikaciju'
))
sample_ids.append(rid)
return actions
def task_5c(cur):
"""Cross-check membership lists from sport-pgz.hr.
Findings: sport-pgz.hr publishes only savezi membership of ZSPGZ, NOT individual
clubs. Individual clubs only appear in NSPGZ glasnik (PDF) and per-savez
websites (most non-existent or paywalled). 5c is therefore PARTIAL-BLOCKED.
"""
sources = []
# zspgz savez slugs we found
zspgz_savez_slugs = [
'atletski-savez-pgz', 'bocarski-savez-pgz', 'boksacki-savez-pgz',
'jedrilicarski-savez-pgz', 'judo-savez-pgz', 'karate-savez-pgz',
'kickboxing-savez-pgz', 'kosarkaski-savez-pgz', 'kuglacki-savez-pgz',
'nogometni-savez-pgz', 'odbojkaski-savez-pgz', 'pikado-savez-pgz',
'plivacki-savez-pgz', 'rukometni-savez-pgz',
'savez-za-sportski-ribolov-na-moru-pgz', 'sanjkaski-savez-pgz',
'skijaski-savez-pgz', 'stolnoteniski-savez-pgz',
'strelicarski-savez-pgz', 'udruga-streljackih-klubova-pgz',
'sahovski-savez-pgz', 'sportsko-ribolovni-savez-pgz',
'taekwondo-savez-pgz', 'teniski-savez-pgz', 'triatlon-savez-pgz',
'vaterpolo-savez-pgz', 'savez-skolskih-sportskih-drustava-pgz',
'savez-sportova-osoba-s-invaliditetom-pgz',
'savez-sportske-rekreacije-sport-za-sve-pgz',
'rijecki-sportski-savez', 'rijecki-sportski-sveucilisni-savez',
]
sources.append(dict(
url='https://sport-pgz.hr/clanice-zajednice',
status='200 OK',
type='ZSPGZ savezi members (NOT individual clubs)',
n_found=len(zspgz_savez_slugs),
n_flagged=0,
note=('ZSPGZ portal lists only SAVEZE pages, not individual klubove. '
'Individual clubs only available via NSPGZ glasnik PDFs / per-savez sites '
'(most non-existent or paywalled). Cross-check protiv klubova nije moguć '
'autonomno bez parsiranja PDF-ova.'),
))
sources.append(dict(
url='https://rss-rijeka.hr/clanovi',
status='no DNS / unreachable',
type='RSS Rijeka member-clubs',
n_found=0,
n_flagged=0,
note='Domain not resolvable. RSS Rijeka info-page exists on sport-pgz.hr/rijecki-sportski-savez but lists only PGZ-savezi (Atletski, Boćarski, ...), not individual clubs.',
))
sources.append(dict(
url='https://www.zssr-pgz.hr',
status='no DNS / unreachable',
type='ŽSSR PGŽ membership',
n_found=0,
n_flagged=0,
note='Domain unreachable. Use info-page on sport-pgz.hr.',
))
sources.append(dict(
url='https://www.nspgz.hr',
status='200 OK',
type='Nogometni savez PGŽ',
n_found=0,
n_flagged=0,
note='Has /komisija/registracije-klubovi-igraci, but no machine-readable list. Glasniks su PDF; potreban OCR + parsing.',
))
# Identify klubovi that have empty savez_id and might need flagging — this
# is structural evidence rather than membership-derived.
cur.execute("""
SELECT COUNT(*) FROM pgz_sport.klubovi
WHERE savez_id IS NULL AND aktivan = true
AND naziv NOT ILIKE '%[VERIFY]%'
AND naziv NOT ILIKE '%[MERGED%'
AND naziv NOT ILIKE '%[UNRESOLVED]%'
""")
no_savez_count = cur.fetchone()[0]
return dict(sources=sources, no_savez_active_klubovi=no_savez_count, flagged=[])
def main():
c = conn()
c.autocommit = False
cur = c.cursor()
print('=== sub5a — adresa-as-naziv flagging ===')
a5a = task_5a(cur)
print(f'5a: {len(a5a)} klubova flagged')
print('=== sub5b — KUD verify / lovačka reclassification ===')
a5b = task_5b(cur)
corrected = sum(1 for a in a5b if a['sport_after'] != a['sport_before'])
print(f'5b: {len(a5b)} reviewed, {corrected} reclassified to lovstvo')
print('=== sub5c — membership cross-check ===')
a5c = task_5c(cur)
print(f'5c: {len(a5c["sources"])} sources probed')
c.commit()
cur.close()
c.close()
out = dict(
ts=dt.datetime.now().isoformat(),
sub5a=a5a,
sub5b=a5b,
sub5c=a5c,
summary=dict(
sub5a_flagged=len(a5a),
sub5b_reclassified=corrected,
sub5b_total_reviewed=len(a5b),
sub5c_blocked_sources=sum(1 for s in a5c['sources'] if s['n_found'] == 0),
),
)
with open(os.path.join(OUT_DIR, 'sub5_run.json'), 'w') as f:
json.dump(out, f, ensure_ascii=False, indent=2)
print(f'Saved → {OUT_DIR}/sub5_run.json')
return out
if __name__ == '__main__':
main()