CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended: - Was: /api/admin/* only - Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT - Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password, /password/reset, /reset-password, /setup-password, /google; /api/gdpr/consent; any path ending /avatar - 14 mutating endpoints verified to return 401 without token #2 Avatar upload demo mode (routers/clan_panel_router.py): - Anonymous → returns {demo_mode:true, slika_url:null, message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'}, no FS write, no DB write - Authenticated (valid JWT, allowed role) → real save as before - Auth check now uses auth.auth_v2.decode_token (proper secret + revocation) instead of the broken local _resolve_role #3 Mock mailer (auth/mailer.py): - send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl - send_password_reset, send_invite helpers with HR text + HTML alt - Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default) - forgot-password and admin invite both call mailer; audit logs mail status #5 Rate limiting on /api/auth/login: - Per-user: 5 wrong attempts → 5-minute DB-backed lockout (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES. - Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429 Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful login clears the IP counter. - Failed attempts respond '(N/5) — račun je zaključan na 5 minuta' - New audit actions: login.ratelimit.ip; login.fail meta now includes fails count, locked, lock_minutes #4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14 mutating endpoints, public path whitelist, demo-mode avatar + real save, forgot-password e-mail to mailbox, no-leak unknown email, 5-fail lockout, 423 during lockout, audit coverage.
This commit is contained in:
@@ -0,0 +1,771 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="hr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>PGŽ Sport · Admin Dashboard</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>A</text></svg>">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #06080d;
|
||||||
|
--bg-2: #0d1117;
|
||||||
|
--bg-3: #161b22;
|
||||||
|
--border: #1f2937;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-2: #8b949e;
|
||||||
|
--text-3: #6e7681;
|
||||||
|
--accent: #00f0ff;
|
||||||
|
--accent-2: #00b8d4;
|
||||||
|
--green: #56d364;
|
||||||
|
--yellow: #d29922;
|
||||||
|
--red: #f85149;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||||
|
.sidebar {
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 20px 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.brand h1 {
|
||||||
|
font-size: 16px; font-weight: 700; color: var(--accent);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.brand .sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 20px; cursor: pointer;
|
||||||
|
color: var(--text-2); font-size: 13px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: var(--bg-3); color: var(--text); }
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(0,240,255,0.05);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
.nav-item .icon { font-size: 16px; width: 18px; }
|
||||||
|
.tenant-switch {
|
||||||
|
margin: auto 12px 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tenant-switch label { font-size: 11px; color: var(--text-3); display: block; margin-bottom: 4px; }
|
||||||
|
.tenant-switch select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.main { padding: 20px 28px; overflow-y: auto; }
|
||||||
|
.header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 20px; padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.header h2 { font-size: 22px; font-weight: 700; }
|
||||||
|
.header .meta { color: var(--text-3); font-size: 12px; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px; margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.kpi-card {
|
||||||
|
background: var(--bg-2); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 16px;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.kpi-card::before {
|
||||||
|
content: ''; position: absolute; top: 0; left: 0;
|
||||||
|
width: 3px; height: 100%; background: var(--accent);
|
||||||
|
}
|
||||||
|
.kpi-card.green::before { background: var(--green); }
|
||||||
|
.kpi-card.yellow::before { background: var(--yellow); }
|
||||||
|
.kpi-card.purple::before { background: var(--purple); }
|
||||||
|
.kpi-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.kpi-value { font-size: 28px; font-weight: 700; color: var(--text); margin-top: 6px; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
.kpi-sub { font-size: 11px; color: var(--text-2); margin-top: 4px; }
|
||||||
|
.section {
|
||||||
|
background: var(--bg-2); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 18px; margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.section h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--accent); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th { text-align: left; padding: 8px 10px; color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
|
||||||
|
td { padding: 10px; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||||
|
tr:hover { background: var(--bg-3); }
|
||||||
|
td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge.green { background: rgba(86,211,100,0.15); color: var(--green); }
|
||||||
|
.badge.yellow { background: rgba(210,153,34,0.15); color: var(--yellow); }
|
||||||
|
.badge.red { background: rgba(248,81,73,0.15); color: var(--red); }
|
||||||
|
.badge.gray { background: rgba(110,118,129,0.15); color: var(--text-3); }
|
||||||
|
.search {
|
||||||
|
width: 100%; max-width: 320px;
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
padding: 8px 12px; border-radius: 6px;
|
||||||
|
color: var(--text); font-family: inherit; font-size: 13px;
|
||||||
|
}
|
||||||
|
.search:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.tab-content { display: none; }
|
||||||
|
.tab-content.active { display: block; }
|
||||||
|
.iframe-wrap {
|
||||||
|
background: var(--bg-2); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; overflow: hidden; height: 600px;
|
||||||
|
}
|
||||||
|
.iframe-wrap iframe { width: 100%; height: 100%; border: 0; }
|
||||||
|
.spinner {
|
||||||
|
display: inline-block; width: 14px; height: 14px;
|
||||||
|
border: 2px solid var(--border); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.tenants-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.tenant-card {
|
||||||
|
background: var(--bg-2); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 18px;
|
||||||
|
}
|
||||||
|
.tenant-card .name { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.tenant-card .slug { font-size: 11px; color: var(--text-3); font-family: 'JetBrains Mono', monospace; }
|
||||||
|
.tenant-card .stats { margin-top: 12px; display: flex; gap: 16px; }
|
||||||
|
.tenant-card .stats .stat { font-size: 12px; color: var(--text-2); }
|
||||||
|
.tenant-card .stats .stat strong { color: var(--accent); display: block; font-size: 16px; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<h1>PGŽ SPORT</h1>
|
||||||
|
<div class="sub">Admin Dashboard v1.1</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-item active" data-tab="dashboard">
|
||||||
|
<span class="icon">⊞</span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-tab="erp">
|
||||||
|
<span class="icon">€</span>
|
||||||
|
<span>ERP — Financije</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-tab="crm">
|
||||||
|
<span class="icon">◯</span>
|
||||||
|
<span>CRM — Klubovi</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-tab="osobe">
|
||||||
|
<span class="icon">⊙</span>
|
||||||
|
<span>Kontakti</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-tab="graph3d">
|
||||||
|
<span class="icon">▣</span>
|
||||||
|
<span>3D Graf</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-tab="tenants">
|
||||||
|
<span class="icon">⌂</span>
|
||||||
|
<span>Tenants</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item" data-tab="reports">
|
||||||
|
<span class="icon">≡</span>
|
||||||
|
<span>Reports</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tenant-switch">
|
||||||
|
<label>Aktivan tenant</label>
|
||||||
|
<select id="tenantSel"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:14px 14px 4px 14px;font-size:9.5px;color:var(--text-dim,#8a95b4);text-transform:uppercase;letter-spacing:1.2px;font-weight:700">Portali</div>
|
||||||
|
<a class="nav-item" href="/sport/login"><span class="icon">🔑</span><span>Prijava</span></a>
|
||||||
|
<a class="nav-item" href="/sport/app"><span class="icon">📱</span><span>Aplikacija</span></a>
|
||||||
|
<a class="nav-item active" href="/sport/admin"><span class="icon">🛡</span><span>Administracija</span></a>
|
||||||
|
<a class="nav-item" href="/sport/crm"><span class="icon">👥</span><span>CRM</span></a>
|
||||||
|
<a class="nav-item" href="/sport/erp"><span class="icon">💰</span><span>ERP</span></a>
|
||||||
|
<a class="nav-item" href="/sport/kpi"><span class="icon">📈</span><span>KPI</span></a>
|
||||||
|
<a class="nav-item" href="/sport/audit"><span class="icon">📋</span><span>Audit</span></a>
|
||||||
|
<a class="nav-item" href="/sport/static/sport2.html" target="_blank"><span class="icon">🌐</span><span>Public portal</span></a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="header">
|
||||||
|
<h2 id="pageTitle">Dashboard</h2>
|
||||||
|
<span class="meta" id="metaInfo">učitavam…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<div class="tab-content active" id="tab-dashboard">
|
||||||
|
<div class="kpi-grid" id="kpiGrid"></div>
|
||||||
|
<div class="section">
|
||||||
|
<h3>Top Klubovi (po aktivnosti)</h3>
|
||||||
|
<table id="topKlubovi"><thead><tr><th>Naziv</th><th>Sport</th><th>Grad</th><th class="num">Članovi</th><th class="num">Računi</th></tr></thead><tbody></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ERP -->
|
||||||
|
<div class="tab-content" id="tab-erp">
|
||||||
|
<div class="kpi-grid" id="erpKpi"></div>
|
||||||
|
|
||||||
|
<!-- M5: OCR drag-and-drop upload -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>📷 OCR — Skeniraj račun (gorivo, cestarina, hotel…)</h3>
|
||||||
|
<div id="ocrDrop" style="border:2px dashed var(--border);border-radius:8px;padding:30px;text-align:center;cursor:pointer;background:var(--bg-3);transition:.15s">
|
||||||
|
<div style="font-size:32px;color:var(--accent);margin-bottom:6px">⤓</div>
|
||||||
|
<div style="font-size:14px;font-weight:600">Povuci PDF/JPG/PNG ovdje ili klikni za odabir</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-3);margin-top:6px">Tesseract OCR + Ri.NET AI Engine izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke</div>
|
||||||
|
<input id="ocrFile" type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff,.webp" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div id="ocrStatus" style="margin-top:10px;font-size:12px;color:var(--text-2);min-height:18px"></div>
|
||||||
|
<div id="ocrResult" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Izdavatelj</label><input id="oc_vendor_name" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">OIB izdavatelja</label><input id="oc_vendor_oib" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Broj računa</label><input id="oc_invoice_no" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Datum</label><input id="oc_invoice_date" type="date" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Iznos neto</label><input id="oc_amount_net" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">PDV</label><input id="oc_amount_vat" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Brutto (UKUPNO)</label><input id="oc_amount_gross" type="number" step="0.01" class="search" style="max-width:none;width:100%;border-color:var(--accent)"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">IBAN</label><input id="oc_iban" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Vrsta troška</label>
|
||||||
|
<select id="oc_kind" class="search" style="max-width:none;width:100%">
|
||||||
|
<option value="gorivo">Gorivo</option>
|
||||||
|
<option value="cestarina">Cestarina</option>
|
||||||
|
<option value="hotel">Hotel</option>
|
||||||
|
<option value="restoran">Restoran</option>
|
||||||
|
<option value="oprema">Oprema</option>
|
||||||
|
<option value="ostalo" selected>Ostalo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Klub</label>
|
||||||
|
<select id="oc_klub" class="search" style="max-width:none;width:100%"></select>
|
||||||
|
</div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Valuta</label>
|
||||||
|
<select id="oc_currency" class="search" style="max-width:none;width:100%"><option>EUR</option><option>HRK</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px"><label style="font-size:11px;color:var(--text-3)">Opis</label><input id="oc_description" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-3)">Sirovi OCR tekst (preview)</summary>
|
||||||
|
<pre id="oc_raw" style="font-size:11px;background:var(--bg);padding:10px;border-radius:4px;margin-top:6px;max-height:200px;overflow:auto;white-space:pre-wrap"></pre>
|
||||||
|
</details>
|
||||||
|
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
|
||||||
|
<button id="ocSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">💾 Spremi račun</button>
|
||||||
|
<button id="ocCancel" style="padding:8px 14px;background:var(--bg-3);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer">Odustani</button>
|
||||||
|
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- M6: Putni nalozi creation form -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;font-size:13px">
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Klub</label><select id="pn_klub" class="search" style="max-width:none;width:100%"></select></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Voditelj</label><input id="pn_voditelj" class="search" style="max-width:none;width:100%" placeholder="Ime Prezime"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Putnici (zarezom razdvojeno)</label><input id="pn_putnici" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Svrha</label><input id="pn_svrha" class="search" style="max-width:none;width:100%" placeholder="Natjecanje, treninzi…"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Od grada</label><input id="pn_od" class="search" style="max-width:none;width:100%" value="Rijeka"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Do grada</label><input id="pn_do" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Polazak</label><input id="pn_from" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Povratak</label><input id="pn_to" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Zemlja</label><input id="pn_country" class="search" style="max-width:none;width:100%" value="Hrvatska"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Tip vozila</label>
|
||||||
|
<select id="pn_vehicle" class="search" style="max-width:none;width:100%">
|
||||||
|
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Registracija</label><input id="pn_plate" class="search" style="max-width:none;width:100%"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">Kilometara</label><input id="pn_km" type="number" step="1" class="search" style="max-width:none;width:100%" value="0"></div>
|
||||||
|
<div><label style="font-size:11px;color:var(--text-3)">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="search" style="max-width:none;width:100%" value="0.50"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
|
||||||
|
Unesi datume za live obračun dnevnica…
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;display:flex;gap:8px">
|
||||||
|
<button id="pnSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">📝 Kreiraj putni nalog</button>
|
||||||
|
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Računi</h3>
|
||||||
|
<table id="invTable"><thead><tr><th>Broj</th><th>Dobavljač</th><th>Klub</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h3>Putni nalozi / izdaci</h3>
|
||||||
|
<table id="expTable"><thead><tr><th>Broj</th><th>Klub</th><th>Destinacija</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CRM klubovi -->
|
||||||
|
<div class="tab-content" id="tab-crm">
|
||||||
|
<input type="text" class="search" id="klubSearch" placeholder="Traži klub po imenu, OIB-u, gradu, sportu...">
|
||||||
|
<div class="section" style="margin-top: 14px;">
|
||||||
|
<h3>Klubovi</h3>
|
||||||
|
<table id="klubTable"><thead><tr><th>Naziv</th><th>OIB</th><th>Sport</th><th>Grad</th><th>Email</th><th class="num">Članovi</th><th class="num">Računi</th></tr></thead><tbody></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Osobe -->
|
||||||
|
<div class="tab-content" id="tab-osobe">
|
||||||
|
<input type="text" class="search" id="osobaSearch" placeholder="Traži po imenu, prezimenu, OIB-u...">
|
||||||
|
<div class="section" style="margin-top: 14px;">
|
||||||
|
<h3>Kontakti / Članovi</h3>
|
||||||
|
<table id="osobeTable"><thead><tr><th>Ime</th><th>Prezime</th><th>OIB</th><th>Klub</th><th>Pozicija</th><th>Email</th><th>Status</th></tr></thead><tbody></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3D Graph -->
|
||||||
|
<div class="tab-content" id="tab-graph3d">
|
||||||
|
<div class="section">
|
||||||
|
<h3>3D Sport Graph</h3>
|
||||||
|
<p style="color: var(--text-3); margin-bottom: 12px;">Interaktivni 3D prikaz svih klubova, saveza i osoba s drill-down na detalje.</p>
|
||||||
|
<div class="iframe-wrap">
|
||||||
|
<iframe id="graph3dIframe" loading="lazy"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tenants -->
|
||||||
|
<div class="tab-content" id="tab-tenants">
|
||||||
|
<div class="section">
|
||||||
|
<h3>Multi-tenant Management</h3>
|
||||||
|
<p style="color: var(--text-3); margin-bottom: 16px;">Tenants u sustavu. Svaki tenant ima vlastiti scope klubova, financija i konfiguracije.</p>
|
||||||
|
<div class="tenants-grid" id="tenantsGrid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reports -->
|
||||||
|
<div class="tab-content" id="tab-reports">
|
||||||
|
<div class="section">
|
||||||
|
<h3>Top 10 Klubova (po dokumentima i računima)</h3>
|
||||||
|
<table id="repTable"><thead><tr><th>Naziv</th><th>Sport</th><th>Grad</th><th class="num">Računi</th><th class="num">Članovi</th></tr></thead><tbody></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/admin/api';
|
||||||
|
let currentTenant = 1;
|
||||||
|
let dashboardData = null;
|
||||||
|
let tenantsList = [];
|
||||||
|
|
||||||
|
const $ = sel => document.querySelector(sel);
|
||||||
|
const $$ = sel => document.querySelectorAll(sel);
|
||||||
|
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
return await r.json();
|
||||||
|
} catch (e) { console.error('Fetch fail:', url, e); return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (typeof n !== 'number') return n;
|
||||||
|
return new Intl.NumberFormat('hr-HR').format(n);
|
||||||
|
}
|
||||||
|
function fmtEur(n) { return n != null ? '€' + fmt(Math.round(n)) : '—'; }
|
||||||
|
function fmtDate(d) { return d ? d.substring(0, 10) : '—'; }
|
||||||
|
|
||||||
|
function badge(text, color) { return '<span class="badge ' + color + '">' + (text || '—') + '</span>'; }
|
||||||
|
|
||||||
|
function statusBadge(s) {
|
||||||
|
if (!s) return badge('—', 'gray');
|
||||||
|
const s2 = s.toLowerCase();
|
||||||
|
if (['paid', 'approved', 'active', 'completed'].includes(s2)) return badge(s, 'green');
|
||||||
|
if (['pending', 'submitted', 'draft', 'open'].includes(s2)) return badge(s, 'yellow');
|
||||||
|
if (['overdue', 'rejected', 'cancelled', 'failed'].includes(s2)) return badge(s, 'red');
|
||||||
|
return badge(s, 'gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const d = await fetchJSON(`${API}/dashboard?tenant_id=${currentTenant}`);
|
||||||
|
if (!d) return;
|
||||||
|
dashboardData = d;
|
||||||
|
|
||||||
|
const k = d.kpi;
|
||||||
|
$('#kpiGrid').innerHTML = `
|
||||||
|
<div class="kpi-card"><div class="kpi-label">Klubovi</div><div class="kpi-value">${fmt(k.klubovi_total)}</div><div class="kpi-sub">${fmt(k.klubovi_aktivni_90d)} aktivnih /90d</div></div>
|
||||||
|
<div class="kpi-card green"><div class="kpi-label">Osobe</div><div class="kpi-value">${fmt(k.osobe)}</div><div class="kpi-sub">članovi i kontakti</div></div>
|
||||||
|
<div class="kpi-card yellow"><div class="kpi-label">Računi</div><div class="kpi-value">${fmt(k.invoices)}</div><div class="kpi-sub">${fmtEur(k.invoices_total_eur)}</div></div>
|
||||||
|
<div class="kpi-card purple"><div class="kpi-label">Putni nalozi</div><div class="kpi-value">${fmt(k.expenses)}</div><div class="kpi-sub">${fmtEur(k.expenses_total_eur)}</div></div>
|
||||||
|
<div class="kpi-card"><div class="kpi-label">Aktivnost</div><div class="kpi-value">${fmt(k.activity_30d)}</div><div class="kpi-sub">audit eventova /30d</div></div>
|
||||||
|
<div class="kpi-card green"><div class="kpi-label">Dokumenti</div><div class="kpi-value">${fmt(k.dokumenti_7d)}</div><div class="kpi-sub">novih /7d</div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Top klubovi
|
||||||
|
const top = await fetchJSON(`${API}/reports/top_klubovi?tenant_id=${currentTenant}&limit=8`);
|
||||||
|
if (top && top.top_klubovi) {
|
||||||
|
$('#topKlubovi tbody').innerHTML = top.top_klubovi.map(k => `
|
||||||
|
<tr><td>${k.naziv}</td><td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
|
||||||
|
<td class="num">${fmt(k.clanovi)}</td><td class="num">${fmt(k.invoices)}</td></tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#metaInfo').textContent = `Tenant: ${d.tenant.display_name} · OIB: ${d.tenant.oib || '—'} · ${new Date().toLocaleString('hr-HR')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadERP() {
|
||||||
|
const s = await fetchJSON(`${API}/erp/summary?tenant_id=${currentTenant}`);
|
||||||
|
if (s) {
|
||||||
|
$('#erpKpi').innerHTML = `
|
||||||
|
<div class="kpi-card"><div class="kpi-label">Računi total</div><div class="kpi-value">${fmt(s.invoices.total)}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_total)}</div></div>
|
||||||
|
<div class="kpi-card green"><div class="kpi-label">Plaćeno</div><div class="kpi-value">${fmt(s.invoices.paid)}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_paid)}</div></div>
|
||||||
|
<div class="kpi-card yellow"><div class="kpi-label">Neplaćeno</div><div class="kpi-value">${fmt(s.invoices.pending + s.invoices.overdue + (s.invoices.other||0))}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_unpaid)}</div></div>
|
||||||
|
<div class="kpi-card purple"><div class="kpi-label">Putni nalozi</div><div class="kpi-value">${fmt(s.expenses.total)}</div><div class="kpi-sub">${fmtEur(s.expenses.sum_total)}</div></div>
|
||||||
|
<div class="kpi-card"><div class="kpi-label">Plaćanja /90d</div><div class="kpi-value">${fmt(s.payments_90d.total)}</div><div class="kpi-sub">${fmtEur(s.payments_90d.sum_total)}</div></div>
|
||||||
|
<div class="kpi-card green"><div class="kpi-label">Proračun</div><div class="kpi-value">${fmtEur(s.proracun.sum_planirano)}</div><div class="kpi-sub">${s.proracun.n} godina · izvršeno: ${fmtEur(s.proracun.sum_izvrseno)}</div></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inv = await fetchJSON(`${API}/erp/invoices?tenant_id=${currentTenant}&limit=20`);
|
||||||
|
if (inv && inv.invoices) {
|
||||||
|
$('#invTable tbody').innerHTML = inv.invoices.length ? inv.invoices.map(i => `
|
||||||
|
<tr><td>${i.invoice_no || '—'}</td><td>${i.vendor_name || '—'}</td>
|
||||||
|
<td>${i.klub_naziv || '—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
|
||||||
|
<td>${statusBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>
|
||||||
|
`).join('') : '<tr><td colspan="6" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const exp = await fetchJSON(`${API}/erp/expenses?tenant_id=${currentTenant}&limit=20`);
|
||||||
|
if (exp && exp.expenses) {
|
||||||
|
$('#expTable tbody').innerHTML = exp.expenses.length ? exp.expenses.map(e => `
|
||||||
|
<tr><td>${e.report_no || '—'}</td><td>${e.klub_naziv || '—'}</td>
|
||||||
|
<td>${e.destination || '—'}</td><td class="num">${fmtEur(e.cost_total)}</td>
|
||||||
|
<td>${statusBadge(e.status)}</td><td>${fmtDate(e.created_at)}</td></tr>
|
||||||
|
`).join('') : '<tr><td colspan="6" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCRM(q='') {
|
||||||
|
const url = `${API}/crm/klubovi?tenant_id=${currentTenant}&limit=50${q ? '&q=' + encodeURIComponent(q) : ''}`;
|
||||||
|
const d = await fetchJSON(url);
|
||||||
|
if (d && d.klubovi) {
|
||||||
|
$('#klubTable tbody').innerHTML = d.klubovi.map(k => `
|
||||||
|
<tr><td><strong>${k.naziv}</strong></td><td>${k.oib || '—'}</td>
|
||||||
|
<td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
|
||||||
|
<td>${k.email || '—'}</td><td class="num">${fmt(k.clanovi)}</td>
|
||||||
|
<td class="num">${fmt(k.invoices_count)}</td></tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOsobe(q='') {
|
||||||
|
const url = `${API}/crm/osobe?limit=50${q ? '&q=' + encodeURIComponent(q) : ''}`;
|
||||||
|
const d = await fetchJSON(url);
|
||||||
|
if (d && d.osobe) {
|
||||||
|
$('#osobeTable tbody').innerHTML = d.osobe.map(o => `
|
||||||
|
<tr><td>${o.ime}</td><td><strong>${o.prezime}</strong></td>
|
||||||
|
<td>${o.oib || '—'}</td><td>${o.klub_naziv || '—'}</td>
|
||||||
|
<td>${o.pozicija || '—'}</td><td>${o.email || '—'}</td>
|
||||||
|
<td>${o.aktivan ? badge('Aktivan', 'green') : badge('Neaktivan', 'gray')}</td></tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTenants() {
|
||||||
|
const d = await fetchJSON(`${API}/tenants`);
|
||||||
|
if (d && d.tenants) {
|
||||||
|
$('#tenantsGrid').innerHTML = d.tenants.map(t => `
|
||||||
|
<div class="tenant-card">
|
||||||
|
<div class="name">${t.display_name}</div>
|
||||||
|
<div class="slug">@${t.slug} · ${t.type} · ${t.oib || 'no OIB'}</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><strong>${fmt(t.klubovi_count || 0)}</strong>klubovi</div>
|
||||||
|
<div class="stat"><strong>${statusBadge(t.status).match(/>([^<]+)</)[1]}</strong>status</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReports() {
|
||||||
|
const d = await fetchJSON(`${API}/reports/top_klubovi?tenant_id=${currentTenant}&limit=20`);
|
||||||
|
if (d && d.top_klubovi) {
|
||||||
|
$('#repTable tbody').innerHTML = d.top_klubovi.map(k => `
|
||||||
|
<tr><td>${k.naziv}</td><td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
|
||||||
|
<td class="num">${fmt(k.invoices)}</td><td class="num">${fmt(k.clanovi)}</td></tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function load3D() {
|
||||||
|
const f = $('#graph3dIframe');
|
||||||
|
if (!f.src) f.src = '/3d';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTenantSelector() {
|
||||||
|
const d = await fetchJSON(`${API}/tenants`);
|
||||||
|
if (d && d.tenants) {
|
||||||
|
tenantsList = d.tenants;
|
||||||
|
$('#tenantSel').innerHTML = d.tenants.map(t =>
|
||||||
|
`<option value="${t.id}" ${t.id === currentTenant ? 'selected' : ''}>${t.display_name}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateTab(name) {
|
||||||
|
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
|
||||||
|
$$('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
erp: 'ERP — Financije',
|
||||||
|
crm: 'CRM — Klubovi',
|
||||||
|
osobe: 'Kontakti',
|
||||||
|
graph3d: '3D Graf',
|
||||||
|
tenants: 'Multi-tenant',
|
||||||
|
reports: 'Reports'
|
||||||
|
};
|
||||||
|
$('#pageTitle').textContent = titles[name] || name;
|
||||||
|
|
||||||
|
if (name === 'dashboard') loadDashboard();
|
||||||
|
if (name === 'erp') loadERP();
|
||||||
|
if (name === 'crm') loadCRM();
|
||||||
|
if (name === 'osobe') loadOsobe();
|
||||||
|
if (name === 'graph3d') load3D();
|
||||||
|
if (name === 'tenants') loadTenants();
|
||||||
|
if (name === 'reports') loadReports();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === M5: OCR upload (drag-and-drop) ===
|
||||||
|
const ERP_API = '/api/erp';
|
||||||
|
|
||||||
|
async function ocrLoadKlubSelectors() {
|
||||||
|
const sels = [document.getElementById('oc_klub'), document.getElementById('pn_klub')].filter(Boolean);
|
||||||
|
if (!sels.length) return;
|
||||||
|
// Use main API for klubovi list (admin-scoped)
|
||||||
|
const d = await fetch(`/api/klubovi?limit=400`).then(r => r.json()).catch(() => null);
|
||||||
|
if (!d) return;
|
||||||
|
const arr = Array.isArray(d) ? d : (d.rows || d.items || []);
|
||||||
|
const opts = '<option value="">— odaberi klub —</option>' + arr.map(k => `<option value="${k.id}">${k.naziv}</option>`).join('');
|
||||||
|
sels.forEach(s => { if (s) s.innerHTML = opts; });
|
||||||
|
}
|
||||||
|
|
||||||
|
let ocrParsed = null;
|
||||||
|
let ocrUploadId = null;
|
||||||
|
|
||||||
|
function ocrSetStatus(msg, color) {
|
||||||
|
const el = document.getElementById('ocrStatus');
|
||||||
|
if (el) { el.textContent = msg || ''; el.style.color = color || 'var(--text-2)'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ocrHandleFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
ocrSetStatus('⏳ Učitavam datoteku…', 'var(--yellow)');
|
||||||
|
const klubVal = document.getElementById('oc_klub')?.value || '';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (klubVal) fd.append('klub_id', klubVal);
|
||||||
|
fd.append('tenant_id', currentTenant || 1);
|
||||||
|
fd.append('invoice_kind', document.getElementById('oc_kind')?.value || 'ostalo');
|
||||||
|
let r = await fetch(`${ERP_API}/ocr/upload`, {method: 'POST', body: fd});
|
||||||
|
if (!r.ok) { ocrSetStatus('❌ Upload pao: ' + r.status, 'var(--red)'); return; }
|
||||||
|
const j = await r.json();
|
||||||
|
ocrUploadId = j.upload_id;
|
||||||
|
ocrSetStatus(`✓ Uploaded (id=${ocrUploadId}, ${j.size} B). Pokrećem OCR + LLM ekstrakciju…`, 'var(--accent)');
|
||||||
|
|
||||||
|
const fd2 = new FormData();
|
||||||
|
fd2.append('upload_id', ocrUploadId);
|
||||||
|
fd2.append('use_llm', 'true');
|
||||||
|
r = await fetch(`${ERP_API}/ocr/parse`, {method: 'POST', body: fd2});
|
||||||
|
if (!r.ok) { ocrSetStatus('❌ Parse pao: ' + r.status, 'var(--red)'); return; }
|
||||||
|
const p = await r.json();
|
||||||
|
if (!p.ok) { ocrSetStatus('❌ ' + (p.error || 'Parse fail'), 'var(--red)'); return; }
|
||||||
|
ocrParsed = p.extracted || {};
|
||||||
|
document.getElementById('oc_vendor_name').value = ocrParsed.vendor_name || '';
|
||||||
|
document.getElementById('oc_vendor_oib').value = ocrParsed.vendor_oib || '';
|
||||||
|
document.getElementById('oc_invoice_no').value = ocrParsed.invoice_no || '';
|
||||||
|
document.getElementById('oc_invoice_date').value = ocrParsed.invoice_date || '';
|
||||||
|
document.getElementById('oc_amount_net').value = ocrParsed.amount_net ?? '';
|
||||||
|
document.getElementById('oc_amount_vat').value = ocrParsed.amount_vat ?? '';
|
||||||
|
document.getElementById('oc_amount_gross').value = ocrParsed.amount_gross ?? '';
|
||||||
|
document.getElementById('oc_vat_rate').value = ocrParsed.vat_rate ?? '';
|
||||||
|
document.getElementById('oc_iban').value = ocrParsed.iban || '';
|
||||||
|
document.getElementById('oc_kind').value = ocrParsed.category || 'ostalo';
|
||||||
|
document.getElementById('oc_currency').value = ocrParsed.currency || 'EUR';
|
||||||
|
document.getElementById('oc_description').value = ocrParsed.description || '';
|
||||||
|
document.getElementById('oc_raw').textContent = (p.raw_text_preview || '').slice(0, 4000);
|
||||||
|
document.getElementById('ocrResult').style.display = 'block';
|
||||||
|
ocrSetStatus(`✓ OCR ${p.ocr_method} (${p.raw_chars} znakova). Provjeri polja i klikni "Spremi račun".`, 'var(--green)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ocrInitDrop() {
|
||||||
|
const drop = document.getElementById('ocrDrop');
|
||||||
|
const inp = document.getElementById('ocrFile');
|
||||||
|
if (!drop || !inp) return;
|
||||||
|
drop.addEventListener('click', () => inp.click());
|
||||||
|
inp.addEventListener('change', e => { if (e.target.files[0]) ocrHandleFile(e.target.files[0]); });
|
||||||
|
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; }));
|
||||||
|
['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--border)'; }));
|
||||||
|
drop.addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) ocrHandleFile(f); });
|
||||||
|
document.getElementById('ocCancel')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('ocrResult').style.display = 'none';
|
||||||
|
ocrParsed = null; ocrUploadId = null; ocrSetStatus('');
|
||||||
|
inp.value = '';
|
||||||
|
});
|
||||||
|
document.getElementById('ocSave')?.addEventListener('click', async () => {
|
||||||
|
const klub = document.getElementById('oc_klub').value;
|
||||||
|
if (!klub) { document.getElementById('ocSaveStatus').textContent = 'Odaberi klub'; return; }
|
||||||
|
const body = {
|
||||||
|
klub_id: parseInt(klub),
|
||||||
|
tenant_id: currentTenant || 1,
|
||||||
|
upload_id: ocrUploadId,
|
||||||
|
invoice_kind: document.getElementById('oc_kind').value || 'ostalo',
|
||||||
|
invoice_no: document.getElementById('oc_invoice_no').value,
|
||||||
|
vendor_name: document.getElementById('oc_vendor_name').value,
|
||||||
|
vendor_oib: document.getElementById('oc_vendor_oib').value,
|
||||||
|
invoice_date: document.getElementById('oc_invoice_date').value,
|
||||||
|
amount_net: parseFloat(document.getElementById('oc_amount_net').value) || null,
|
||||||
|
amount_vat: parseFloat(document.getElementById('oc_amount_vat').value) || null,
|
||||||
|
amount_gross: parseFloat(document.getElementById('oc_amount_gross').value),
|
||||||
|
vat_rate: parseFloat(document.getElementById('oc_vat_rate').value) || null,
|
||||||
|
iban_to: document.getElementById('oc_iban').value || null,
|
||||||
|
currency: document.getElementById('oc_currency').value || 'EUR',
|
||||||
|
category: document.getElementById('oc_kind').value || 'ostalo',
|
||||||
|
description: document.getElementById('oc_description').value || null,
|
||||||
|
};
|
||||||
|
document.getElementById('ocSaveStatus').textContent = '⏳ Spremam…';
|
||||||
|
const r = await fetch(`${ERP_API}/invoices`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
document.getElementById('ocSaveStatus').textContent = `✓ Spremljen kao #${j.invoice.id}`;
|
||||||
|
document.getElementById('ocSaveStatus').style.color = 'var(--green)';
|
||||||
|
setTimeout(() => { document.getElementById('ocrResult').style.display = 'none'; loadERP(); }, 1500);
|
||||||
|
} else {
|
||||||
|
document.getElementById('ocSaveStatus').textContent = '❌ ' + (j.detail || 'Greška');
|
||||||
|
document.getElementById('ocSaveStatus').style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === M6: Putni nalog form with live dnevnice preview ===
|
||||||
|
let pnPreviewTimer = null;
|
||||||
|
async function pnRefreshPreview() {
|
||||||
|
const df = document.getElementById('pn_from')?.value;
|
||||||
|
const dt = document.getElementById('pn_to')?.value;
|
||||||
|
const country = document.getElementById('pn_country')?.value || 'Hrvatska';
|
||||||
|
const km = parseFloat(document.getElementById('pn_km')?.value || 0);
|
||||||
|
const km_rate = parseFloat(document.getElementById('pn_kmrate')?.value || 0.5);
|
||||||
|
const tgt = document.getElementById('pn_preview');
|
||||||
|
if (!df || !dt) { if (tgt) tgt.textContent = 'Unesi datume za live obračun dnevnica…'; return; }
|
||||||
|
const url = `${ERP_API}/putni-nalog/dnevnice/preview?date_from=${encodeURIComponent(df)}&date_to=${encodeURIComponent(dt)}&country=${encodeURIComponent(country)}&km=${km}&km_rate=${km_rate}`;
|
||||||
|
const r = await fetch(url).then(r => r.json()).catch(() => null);
|
||||||
|
if (!r || !r.ok) { tgt.textContent = '⚠ Neuspješan obračun'; return; }
|
||||||
|
const d = r.preview;
|
||||||
|
tgt.innerHTML = `
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
|
||||||
|
<div><div style="color:var(--text-3);font-size:11px">Sati</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.hours}h</div></div>
|
||||||
|
<div><div style="color:var(--text-3);font-size:11px">Pune dnevnice</div><div style="font-size:18px;color:var(--accent);font-family:'JetBrains Mono'">${d.days_full} × €${d.rate_full}</div></div>
|
||||||
|
<div><div style="color:var(--text-3);font-size:11px">Pola dnevnica</div><div style="font-size:18px;color:var(--yellow);font-family:'JetBrains Mono'">${d.days_half} × €${d.rate_half}</div></div>
|
||||||
|
<div><div style="color:var(--text-3);font-size:11px">Dnevnice ukupno</div><div style="font-size:18px;color:var(--green);font-family:'JetBrains Mono'">€${d.dnevnica_amount_total}</div></div>
|
||||||
|
<div><div style="color:var(--text-3);font-size:11px">Kilometara</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.km_driven} km</div></div>
|
||||||
|
<div><div style="color:var(--text-3);font-size:11px">Kilometrina</div><div style="font-size:18px;font-family:'JetBrains Mono'">€${d.km_amount}</div></div>
|
||||||
|
<div><div style="color:var(--text-3);font-size:11px">Zemlja</div><div style="font-size:14px;font-family:'JetBrains Mono'">${d.country}</div></div>
|
||||||
|
<div><div style="color:var(--text-3);font-size:11px">PROCJENA UKUPNO</div><div style="font-size:22px;color:var(--accent);font-family:'JetBrains Mono';font-weight:700">€${d.total_estimated}</div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pnInit() {
|
||||||
|
['pn_from','pn_to','pn_country','pn_km','pn_kmrate'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener('input', () => {
|
||||||
|
clearTimeout(pnPreviewTimer);
|
||||||
|
pnPreviewTimer = setTimeout(pnRefreshPreview, 250);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('pnSave')?.addEventListener('click', async () => {
|
||||||
|
const klub = document.getElementById('pn_klub').value;
|
||||||
|
if (!klub) { document.getElementById('pnSaveStatus').textContent = 'Odaberi klub'; return; }
|
||||||
|
const body = {
|
||||||
|
klub_id: parseInt(klub),
|
||||||
|
tenant_id: currentTenant || 1,
|
||||||
|
voditelj_ime: document.getElementById('pn_voditelj').value,
|
||||||
|
putnici: (document.getElementById('pn_putnici').value || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
svrha: document.getElementById('pn_svrha').value,
|
||||||
|
od_grada: document.getElementById('pn_od').value,
|
||||||
|
do_grada: document.getElementById('pn_do').value,
|
||||||
|
datum_polaska: document.getElementById('pn_from').value,
|
||||||
|
datum_povratka: document.getElementById('pn_to').value,
|
||||||
|
country: document.getElementById('pn_country').value,
|
||||||
|
vehicle_type: document.getElementById('pn_vehicle').value,
|
||||||
|
registracija_vozila: document.getElementById('pn_plate').value,
|
||||||
|
kilometara: parseFloat(document.getElementById('pn_km').value) || 0,
|
||||||
|
km_rate: parseFloat(document.getElementById('pn_kmrate').value) || 0.5,
|
||||||
|
};
|
||||||
|
document.getElementById('pnSaveStatus').textContent = '⏳ Spremam…';
|
||||||
|
const r = await fetch(`${ERP_API}/putni-nalog`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
const pn = j.putni_nalog;
|
||||||
|
document.getElementById('pnSaveStatus').innerHTML = `✓ Putni nalog #${pn.id} kreiran (€${pn.cost_total})`;
|
||||||
|
document.getElementById('pnSaveStatus').style.color = 'var(--green)';
|
||||||
|
loadERP();
|
||||||
|
} else {
|
||||||
|
document.getElementById('pnSaveStatus').textContent = '❌ ' + (j.detail || 'Greška');
|
||||||
|
document.getElementById('pnSaveStatus').style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ocrInitDrop();
|
||||||
|
pnInit();
|
||||||
|
ocrLoadKlubSelectors();
|
||||||
|
|
||||||
|
// Init
|
||||||
|
$$('.nav-item').forEach(n => n.addEventListener('click', () => activateTab(n.dataset.tab)));
|
||||||
|
|
||||||
|
let searchTimeout;
|
||||||
|
$('#klubSearch').addEventListener('input', e => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => loadCRM(e.target.value), 300);
|
||||||
|
});
|
||||||
|
$('#osobaSearch').addEventListener('input', e => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => loadOsobe(e.target.value), 300);
|
||||||
|
});
|
||||||
|
$('#tenantSel').addEventListener('change', e => {
|
||||||
|
currentTenant = parseInt(e.target.value);
|
||||||
|
activateTab($('.nav-item.active').dataset.tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await loadTenantSelector();
|
||||||
|
await loadDashboard();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="hr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Audit Log — PGŽ Sport</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
|
<style>
|
||||||
|
:root { --bg0:#08090e; --bg1:#11141d; --bg2:#1a1f2c; --txt:#e6e9ef; --muted:#7a8294;
|
||||||
|
--pgz-blue:#003087; --pgz-gold:#F4C430; --green:#1a8754; --red:#dc3545; --orange:#fd7e14; }
|
||||||
|
* { box-sizing:border-box; margin:0; padding:0; }
|
||||||
|
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg0); color:var(--txt); padding:24px; line-height:1.5; }
|
||||||
|
h1 { color:var(--pgz-gold); margin-bottom:6px; }
|
||||||
|
.sub { color:var(--muted); margin-bottom:24px; }
|
||||||
|
.toolbar { display:flex; gap:12px; margin-bottom:18px; flex-wrap:wrap; }
|
||||||
|
.btn { background:var(--pgz-blue); color:white; border:none; padding:9px 16px; border-radius:6px; cursor:pointer; font-weight:500; }
|
||||||
|
.btn:hover { background:#0040b8; }
|
||||||
|
.btn.secondary { background:var(--bg2); }
|
||||||
|
input,select { background:var(--bg2); color:var(--txt); border:1px solid #2a3144; padding:9px 12px; border-radius:6px; min-width:160px; }
|
||||||
|
table { width:100%; border-collapse:collapse; background:var(--bg1); border-radius:8px; overflow:hidden; margin-top:8px; }
|
||||||
|
th { background:var(--bg2); padding:12px; text-align:left; color:var(--pgz-gold); font-size:0.85rem; text-transform:uppercase; }
|
||||||
|
td { padding:11px 12px; border-top:1px solid #1a1f2c; font-size:0.92rem; }
|
||||||
|
tr:hover { background:#13182a; }
|
||||||
|
.badge { padding:3px 9px; border-radius:11px; font-size:0.75rem; font-weight:600; }
|
||||||
|
.b-create { background:rgba(26,135,84,0.2); color:#7fdca5; }
|
||||||
|
.b-update { background:rgba(253,126,20,0.2); color:#ffaa66; }
|
||||||
|
.b-delete { background:rgba(220,53,69,0.2); color:#ff7e85; }
|
||||||
|
.b-seal { background:rgba(244,196,48,0.2); color:var(--pgz-gold); }
|
||||||
|
.tx-link { color:#5fa8d3; text-decoration:none; font-family:monospace; font-size:0.85rem; }
|
||||||
|
.tx-link:hover { text-decoration:underline; }
|
||||||
|
.empty { padding:60px; text-align:center; color:var(--muted); }
|
||||||
|
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
|
||||||
|
.stat { background:var(--bg1); padding:14px; border-radius:8px; border-left:3px solid var(--pgz-blue); }
|
||||||
|
.stat .v { font-size:1.6rem; font-weight:700; color:var(--pgz-gold); }
|
||||||
|
.stat .l { color:var(--muted); font-size:0.82rem; text-transform:uppercase; margin-top:3px; }
|
||||||
|
body{padding:20px}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
|
||||||
|
<script src="/sport/static/shared/sidebar.js" defer data-active="audit"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📜 Audit Log</h1>
|
||||||
|
<div class="sub">Kompletna povijest izmjena s blockchain pečatima na Polygon PoS</div>
|
||||||
|
|
||||||
|
<div class="stats" id="stats">
|
||||||
|
<div class="stat"><div class="v" id="s-total">—</div><div class="l">Ukupno akcija</div></div>
|
||||||
|
<div class="stat"><div class="v" id="s-today">—</div><div class="l">Danas</div></div>
|
||||||
|
<div class="stat"><div class="v" id="s-sealed">—</div><div class="l">Polygon zapečaćeno</div></div>
|
||||||
|
<div class="stat"><div class="v" id="s-users">—</div><div class="l">Aktivni korisnici</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<input id="f-q" placeholder="🔍 Pretraži..." />
|
||||||
|
<select id="f-action">
|
||||||
|
<option value="">Sve akcije</option>
|
||||||
|
<option value="create">CREATE</option>
|
||||||
|
<option value="update">UPDATE</option>
|
||||||
|
<option value="delete">DELETE</option>
|
||||||
|
<option value="seal">SEAL</option>
|
||||||
|
</select>
|
||||||
|
<select id="f-resource">
|
||||||
|
<option value="">Svi resursi</option>
|
||||||
|
<option value="users">Korisnici</option>
|
||||||
|
<option value="klubovi">Klubovi</option>
|
||||||
|
<option value="invoices">Računi</option>
|
||||||
|
<option value="putni_nalozi">Putni nalozi</option>
|
||||||
|
<option value="sufinanciranje">Sufinanciranje</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" onclick="load()">Filtriraj</button>
|
||||||
|
<button class="btn secondary" onclick="window.location.href='/app'">← Natrag na app</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Vrijeme</th>
|
||||||
|
<th>Korisnik</th>
|
||||||
|
<th>Akcija</th>
|
||||||
|
<th>Resurs</th>
|
||||||
|
<th>Detalji</th>
|
||||||
|
<th>Polygon Tx</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody">
|
||||||
|
<tr><td colspan="6" class="empty">⏳ Učitavam...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function load() {
|
||||||
|
const q = document.getElementById('f-q').value;
|
||||||
|
const action = document.getElementById('f-action').value;
|
||||||
|
const resource = document.getElementById('f-resource').value;
|
||||||
|
const tbody = document.getElementById('tbody');
|
||||||
|
|
||||||
|
let url = '/sport/api/audit/log?limit=200';
|
||||||
|
if (q) url += '&q=' + encodeURIComponent(q);
|
||||||
|
if (action) url += '&action=' + action;
|
||||||
|
if (resource) url += '&resource=' + resource;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
const items = data.items || data.entries || data || [];
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty">📭 Nema zapisa</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = items.map(item => {
|
||||||
|
const action = (item.action || 'unknown').toLowerCase();
|
||||||
|
const klasa = action.includes('seal') ? 'b-seal' :
|
||||||
|
action.includes('create') ? 'b-create' :
|
||||||
|
action.includes('update') ? 'b-update' :
|
||||||
|
action.includes('delete') ? 'b-delete' : 'b-update';
|
||||||
|
const tx = item.tx_hash || item.polygon_tx || '';
|
||||||
|
const txLink = tx ? `<a href="https://polygonscan.com/tx/${tx}" target="_blank" class="tx-link">${tx.substring(0,16)}...</a>` : '<span style="color:#5a6072">—</span>';
|
||||||
|
const ts = new Date(item.created_at || item.timestamp).toLocaleString('hr-HR');
|
||||||
|
const details = item.details || item.diff || item.message || '';
|
||||||
|
const detStr = typeof details === 'object' ? JSON.stringify(details).substring(0,80)+'...' : String(details).substring(0,80);
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td>${ts}</td>
|
||||||
|
<td>${item.user_email || item.user_name || item.actor || '—'}</td>
|
||||||
|
<td><span class="badge ${klasa}">${(item.action || '').toUpperCase()}</span></td>
|
||||||
|
<td>${item.resource_type || item.resource || item.target || '—'}</td>
|
||||||
|
<td>${detStr}</td>
|
||||||
|
<td>${txLink}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" class="empty">⚠ Greška: ${e.message}</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
try {
|
||||||
|
const sr = await fetch('/sport/api/audit/stats');
|
||||||
|
if (sr.ok) {
|
||||||
|
const s = await sr.json();
|
||||||
|
document.getElementById('s-total').textContent = s.total || '—';
|
||||||
|
document.getElementById('s-today').textContent = s.today || '—';
|
||||||
|
document.getElementById('s-sealed').textContent = s.sealed || '—';
|
||||||
|
document.getElementById('s-users').textContent = s.users || '—';
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
setInterval(load, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,866 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# ─────────────────────────── 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")
|
||||||
|
|
||||||
|
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:
|
||||||
|
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")):
|
||||||
|
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"],))
|
||||||
|
audit(u["id"], "login.fail", meta={"reason":"bad_password"}, ip=ip, ua=ua)
|
||||||
|
raise HTTPException(401, "Neispravni podaci")
|
||||||
|
|
||||||
|
# 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"],))
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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
|
||||||
|
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})
|
||||||
|
audit(u["id"], "password.forgot.issue",
|
||||||
|
meta={"email": email, "ttl_hours": RESET_TTL.total_seconds()/3600},
|
||||||
|
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."}
|
||||||
|
# In production, e-mailer would deliver the link. For demo / dev,
|
||||||
|
# return it only if header X-Demo-Reveal-Token is set OR caller is from
|
||||||
|
# localhost (rare). Easier: always include it but document that real
|
||||||
|
# deployment must remove it from the response.
|
||||||
|
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("/auth/reset-password", token)
|
||||||
|
resp["expires_in_seconds"] = int(RESET_TTL.total_seconds())
|
||||||
|
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}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="hr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>RINET KPI Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, sans-serif; background: #0a0e1a; color: #d0d8e8; margin: 0; padding: 20px; }
|
||||||
|
h1 { color: #4af; margin: 0 0 20px; font-size: 24px; }
|
||||||
|
h2 { color: #6cf; margin: 20px 0 8px; font-size: 16px; border-bottom: 1px solid #2a3a4a; padding-bottom: 4px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||||
|
.card { background: #14192a; padding: 12px 16px; border-radius: 6px; border-left: 3px solid #4af; }
|
||||||
|
.card .label { color: #88a; font-size: 11px; text-transform: uppercase; }
|
||||||
|
.card .value { color: #fff; font-size: 22px; font-weight: bold; margin: 4px 0; }
|
||||||
|
.card .sub { color: #aab; font-size: 12px; }
|
||||||
|
.card.good { border-left-color: #4f4; }
|
||||||
|
.card.warn { border-left-color: #fa4; }
|
||||||
|
.card.bad { border-left-color: #f44; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #2a3a4a; font-size: 12px; }
|
||||||
|
th { color: #6cf; font-weight: normal; text-transform: uppercase; font-size: 10px; }
|
||||||
|
tr:hover { background: #1a2030; }
|
||||||
|
.updated { color: #678; font-size: 11px; }
|
||||||
|
.refresh { background: #4af; color: #fff; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; }
|
||||||
|
body{padding:20px}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
|
||||||
|
<script src="/sport/static/shared/sidebar.js" defer data-active="kpi"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>RINET KPI Dashboard <span class="updated" id="updated"></span> <button class="refresh" onclick="load()">↻</button></h1>
|
||||||
|
<div id="root">Loading...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function load() {
|
||||||
|
document.getElementById('updated').textContent = '...';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/admin/api/kpi');
|
||||||
|
const d = await r.json();
|
||||||
|
|
||||||
|
if (d.error) {
|
||||||
|
document.getElementById('root').innerHTML = '<div class="card bad">Error: ' + d.error + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const haluClass = d.queries.halu_pct > 5 ? 'bad' : d.queries.halu_pct > 1 ? 'warn' : 'good';
|
||||||
|
const clusterTotal = Object.values(d.cluster).reduce((a,b)=>a+b, 0);
|
||||||
|
const clusterUnhealthy = Object.entries(d.cluster).filter(([s,n]) => !['healthy','skipped'].includes(s)).reduce((a,[s,n])=>a+n, 0);
|
||||||
|
const clusterClass = clusterUnhealthy > 0 ? 'bad' : 'good';
|
||||||
|
const incClass = d.open_incidents > 0 ? 'warn' : 'good';
|
||||||
|
const embClass = d.knowledge.embed_pct >= 99 ? 'good' : d.knowledge.embed_pct >= 95 ? 'warn' : 'bad';
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<h2>Queries (Production)</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card good"><div class="label">Last 1h</div><div class="value">${d.queries.h1}</div></div>
|
||||||
|
<div class="card good"><div class="label">Last 24h</div><div class="value">${d.queries.h24}</div></div>
|
||||||
|
<div class="card ${haluClass}"><div class="label">Halucinacije 24h</div><div class="value">${d.queries.halucinacije_h24}</div><div class="sub">${d.queries.halu_pct}%</div></div>
|
||||||
|
<div class="card good"><div class="label">Avg latency</div><div class="value">${d.queries.avg_latency_sec}s</div></div>
|
||||||
|
<div class="card good"><div class="label">Avg confidence</div><div class="value">${d.queries.avg_confidence}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Knowledge Base</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card good"><div class="label">Total facts</div><div class="value">${d.knowledge.total.toLocaleString()}</div></div>
|
||||||
|
<div class="card good"><div class="label">Added 1h / 24h</div><div class="value">+${d.knowledge.added_h1} / +${d.knowledge.added_h24}</div></div>
|
||||||
|
<div class="card ${embClass}"><div class="label">Embed coverage</div><div class="value">${d.knowledge.embed_pct}%</div><div class="sub">${d.knowledge.embed_pending} pending</div></div>
|
||||||
|
<div class="card good"><div class="label">Training Q&A</div><div class="value">${d.training.total.toLocaleString()}</div><div class="sub">+${d.training.added_h24} / 24h, ${d.training.from_capture} from capture</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Cluster Health</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card ${clusterClass}"><div class="label">Healthy</div><div class="value">${d.cluster.healthy || 0} / ${clusterTotal}</div></div>
|
||||||
|
<div class="card ${incClass}"><div class="label">Open incidents</div><div class="value">${d.open_incidents}</div></div>
|
||||||
|
<div class="card good"><div class="label">Skipped</div><div class="value">${d.cluster.skipped || 0}</div><div class="sub">PG/Redis/cold by design</div></div>
|
||||||
|
<div class="card ${clusterUnhealthy>0?'bad':'good'}"><div class="label">Unhealthy</div><div class="value">${clusterUnhealthy}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Top Sources (24h scrape)</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Source</th><th>Count</th></tr>
|
||||||
|
${d.top_sources_h24.map(s => `<tr><td>${s.source}</td><td>${s.count.toLocaleString()}</td></tr>`).join('')}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Top Models (24h)</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Model</th><th>Calls</th><th>Avg latency</th></tr>
|
||||||
|
${d.top_models_h24.map(m => `<tr><td>${m.model || '-'}</td><td>${m.count}</td><td>${m.avg_latency}s</td></tr>`).join('')}
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('root').innerHTML = html;
|
||||||
|
document.getElementById('updated').textContent = new Date().toLocaleTimeString();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('root').innerHTML = '<div class="card bad">Network error: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
setInterval(load, 30000); // 30s refresh
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="hr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>PGŽ Sport · Prijava</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>P</text></svg>">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #06080d;
|
||||||
|
--bg-2: #0d1117;
|
||||||
|
--bg-3: #161b22;
|
||||||
|
--border: #1f2937;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-2: #8b949e;
|
||||||
|
--text-3: #6e7681;
|
||||||
|
--accent: #00f0ff;
|
||||||
|
--accent-2: #00b8d4;
|
||||||
|
--green: #56d364;
|
||||||
|
--red: #f85149;
|
||||||
|
--yellow: #d29922;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
body { grid-template-columns: 1fr; }
|
||||||
|
.left { display: none; }
|
||||||
|
}
|
||||||
|
.left {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 30% 20%, rgba(0,240,255,0.08), transparent 60%),
|
||||||
|
radial-gradient(ellipse at 70% 80%, rgba(188,140,255,0.05), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.left::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(0,240,255,0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0,240,255,0.04) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
mask: radial-gradient(ellipse at center, black 30%, transparent 80%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 700; font-size: 22px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
box-shadow: 0 0 24px rgba(0,240,255,0.3);
|
||||||
|
}
|
||||||
|
.brand-text h1 {
|
||||||
|
font-size: 20px; font-weight: 700; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.brand-text .sub {
|
||||||
|
font-size: 12px; color: var(--text-3);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.hero { position: relative; z-index: 1; max-width: 460px; }
|
||||||
|
.hero h2 {
|
||||||
|
font-size: 36px; font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
.hero h2 span { color: var(--accent); }
|
||||||
|
.hero p {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.features {
|
||||||
|
display: grid; gap: 12px;
|
||||||
|
}
|
||||||
|
.feat {
|
||||||
|
display: flex; gap: 12px;
|
||||||
|
font-size: 13px; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.feat .ico {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0,240,255,0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-size: 12px; font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.footer-left {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
font-size: 11px; color: var(--text-3);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 36px 32px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.card .lead {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,240,255,0.12);
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin: 14px 0 22px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.row label {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.row label input { accent-color: var(--accent); }
|
||||||
|
.row a { color: var(--accent); text-decoration: none; }
|
||||||
|
.row a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
transition: background 0.15s, transform 0.05s;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) { background: var(--accent-2); }
|
||||||
|
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.btn .spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.25);
|
||||||
|
border-top-color: var(--bg);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
vertical-align: -3px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
background: rgba(248,81,73,0.1);
|
||||||
|
border: 1px solid rgba(248,81,73,0.4);
|
||||||
|
color: #ffb4af;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.alert.show { display: block; }
|
||||||
|
.alert.success {
|
||||||
|
background: rgba(86,211,100,0.1);
|
||||||
|
border-color: rgba(86,211,100,0.4);
|
||||||
|
color: #b6f0bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
margin: 18px 0;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.divider::before, .divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo {
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.demo:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
.demo strong { color: var(--accent); }
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 22px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.footer-right a {
|
||||||
|
color: var(--text-2);
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
.footer-right a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Cookie banner */
|
||||||
|
.cookie {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px; left: 16px; right: 16px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: none;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.cookie.show { display: block; }
|
||||||
|
.cookie h4 { font-size: 14px; margin-bottom: 6px; }
|
||||||
|
.cookie p { font-size: 12px; color: var(--text-2); margin-bottom: 12px; }
|
||||||
|
.cookie-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.cookie-actions button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-2);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cookie-actions button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cookie-actions button:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
.cookie a { color: var(--accent); text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
|
||||||
|
<script src="/sport/static/shared/sidebar.js" defer data-active="login"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="left">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark">P</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>PGŽ Sport</h1>
|
||||||
|
<div class="sub">ERP/CRM Platforma</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero">
|
||||||
|
<h2>Operativna platforma <span>za sport</span> u Primorsko-goranskoj županiji.</h2>
|
||||||
|
<p>Jedinstvena baza klubova, saveza i sportaša. Računovodstvo, članarine, liječnički pregledi, sufinanciranja — sve na jednom mjestu.</p>
|
||||||
|
<div class="features">
|
||||||
|
<div class="feat"><div class="ico">✓</div><div>Multi-tenant arhitektura — PGŽ, savezi, klubovi sa svojim view-om</div></div>
|
||||||
|
<div class="feat"><div class="ico">✓</div><div>OCR za račune, automatska ekstrakcija polja, putni nalozi</div></div>
|
||||||
|
<div class="feat"><div class="ico">✓</div><div>Članarine s HUB-3 uplatnicama i blockchain audit log</div></div>
|
||||||
|
<div class="feat"><div class="ico">✓</div><div>GDPR-compliant (Art. 17, 20) · 2FA · audit svih akcija</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-left">
|
||||||
|
PGŽ ODJEL ZA SPORT · v3.0 · 2026
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Prijava</h3>
|
||||||
|
<div class="lead">Unesite svoje podatke za pristup platformi.</div>
|
||||||
|
|
||||||
|
<div id="alert" class="alert"></div>
|
||||||
|
|
||||||
|
<form id="loginForm" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">E-mail</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="username" placeholder="ime.prezime@pgz.hr">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Lozinka</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div class="field" id="totpField" style="display:none">
|
||||||
|
<label for="totp">Kod autentifikatora (2FA)</label>
|
||||||
|
<input type="text" id="totp" name="totp" inputmode="numeric" pattern="[0-9 ]*" autocomplete="one-time-code" placeholder="123456" maxlength="8" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label><input type="checkbox" id="remember" checked> Zapamti me</label>
|
||||||
|
<a href="#" id="forgotLink">Zaboravljena lozinka?</a>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" id="submitBtn">Prijavi se</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider">Demo računi</div>
|
||||||
|
<div style="display:grid;gap:8px">
|
||||||
|
<div class="demo" data-email="damir@pgz.hr" data-pwd="PGZ2026!">
|
||||||
|
<strong>PGŽ admin</strong> · damir@pgz.hr / PGZ2026!
|
||||||
|
</div>
|
||||||
|
<div class="demo" data-email="pero@atletika.pgz.hr" data-pwd="PGZ2026!">
|
||||||
|
<strong>Savez admin</strong> · pero@atletika.pgz.hr
|
||||||
|
</div>
|
||||||
|
<div class="demo" data-email="ana@akkvarner.hr" data-pwd="PGZ2026!">
|
||||||
|
<strong>Klub admin</strong> · ana@akkvarner.hr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-right">
|
||||||
|
<a href="/sport2.html">Javni portal</a>
|
||||||
|
·
|
||||||
|
<a href="#" id="privacyLink">Politika privatnosti</a>
|
||||||
|
·
|
||||||
|
<a href="#" id="cookieLink">Kolačići</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GDPR cookie consent -->
|
||||||
|
<div id="cookie" class="cookie">
|
||||||
|
<h4>🍪 Kolačići</h4>
|
||||||
|
<p>Koristimo nužne kolačiće za prijavu i sigurnost sesije. Po vašem odobrenju koristimo i analitičke kolačiće za poboljšanje platforme. <a href="#" id="cookieMore">Više…</a></p>
|
||||||
|
<div class="cookie-actions">
|
||||||
|
<button class="primary" id="cookieAccept">Prihvati sve</button>
|
||||||
|
<button id="cookieNecessary">Samo nužni</button>
|
||||||
|
<button id="cookieReject">Odbij sve</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api';
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
|
||||||
|
// ---------- Login ----------
|
||||||
|
function showAlert(msg, type) {
|
||||||
|
const a = $('#alert');
|
||||||
|
a.textContent = msg;
|
||||||
|
a.className = 'alert show' + (type === 'success' ? ' success' : '');
|
||||||
|
if (type === 'success') {
|
||||||
|
setTimeout(() => a.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin(email, password, totp) {
|
||||||
|
const btn = $('#submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner"></span>Prijavljujem…';
|
||||||
|
try {
|
||||||
|
const body = { email, password };
|
||||||
|
if (totp) body.totp = totp;
|
||||||
|
const r = await fetch(API + '/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
if (r.status === 401 && (data.detail === '2FA_REQUIRED' || /2FA/i.test(data.detail||''))) {
|
||||||
|
// Show TOTP field and stop
|
||||||
|
$('#totpField').style.display = '';
|
||||||
|
$('#totp').focus();
|
||||||
|
showAlert('Unesite kod iz autentifikatora.');
|
||||||
|
} else {
|
||||||
|
showAlert(data.detail || 'Neispravni podaci');
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Prijavi se';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Store tokens
|
||||||
|
const store = $('#remember').checked ? localStorage : sessionStorage;
|
||||||
|
store.setItem('pgz_access', data.access_token);
|
||||||
|
store.setItem('pgz_refresh', data.refresh_token);
|
||||||
|
store.setItem('pgz_user', JSON.stringify(data.user));
|
||||||
|
showAlert('Prijava uspješna. Preusmjeravam…', 'success');
|
||||||
|
// Redirect by role
|
||||||
|
setTimeout(() => {
|
||||||
|
const role = (data.user.role || '').toLowerCase();
|
||||||
|
if (['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz',
|
||||||
|
'savez_admin','savez_user','klub_admin','klub_user','klub_trener'].includes(role)) {
|
||||||
|
|
||||||
|
// Smart redirect po roli
|
||||||
|
const role = data.user.role;
|
||||||
|
const redirectMap = {
|
||||||
|
'pgz_admin': '/app',
|
||||||
|
'savez_admin': '/app',
|
||||||
|
'klub_admin': '/app',
|
||||||
|
'super_admin': '/admin'
|
||||||
|
};
|
||||||
|
location.href = redirectMap[role] || '/app';
|
||||||
|
|
||||||
|
} else {
|
||||||
|
location.href = '/';
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
} catch (e) {
|
||||||
|
showAlert('Greška mreže: ' + e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Prijavi se';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#loginForm').addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = $('#email').value.trim().toLowerCase();
|
||||||
|
const pwd = $('#password').value;
|
||||||
|
const totp = ($('#totp').value || '').trim().replace(/\s/g,'') || null;
|
||||||
|
if (!email || !pwd) return;
|
||||||
|
doLogin(email, pwd, totp);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.demo').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
$('#email').value = el.dataset.email;
|
||||||
|
$('#password').value = el.dataset.pwd;
|
||||||
|
$('#email').focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#forgotLink').addEventListener('click', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = ($('#email').value || prompt('Unesite e-mail:') || '').trim().toLowerCase();
|
||||||
|
if (!email) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/auth/password/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
showAlert(data.message || 'Zahtjev poslan administratoru.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Greška: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Cookie consent ----------
|
||||||
|
const consentKey = 'pgz_consent';
|
||||||
|
function showConsent() {
|
||||||
|
if (!localStorage.getItem(consentKey)) {
|
||||||
|
$('#cookie').classList.add('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveConsent(necessary, analytics, marketing) {
|
||||||
|
const session_id = localStorage.getItem('pgz_session_id') ||
|
||||||
|
(() => { const s = crypto.randomUUID(); localStorage.setItem('pgz_session_id', s); return s; })();
|
||||||
|
localStorage.setItem(consentKey, JSON.stringify({ necessary, analytics, marketing, ts: Date.now() }));
|
||||||
|
$('#cookie').classList.remove('show');
|
||||||
|
try {
|
||||||
|
await fetch(API + '/gdpr/consent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ necessary, analytics, marketing, session_id })
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
$('#cookieAccept').addEventListener('click', () => saveConsent(true, true, true));
|
||||||
|
$('#cookieNecessary').addEventListener('click', () => saveConsent(true, false, false));
|
||||||
|
$('#cookieReject').addEventListener('click', () => saveConsent(true, false, false));
|
||||||
|
$('#cookieLink').addEventListener('click', e => { e.preventDefault(); localStorage.removeItem(consentKey); showConsent(); });
|
||||||
|
$('#privacyLink').addEventListener('click', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/gdpr/policy');
|
||||||
|
const d = await r.json();
|
||||||
|
alert('PGŽ Sport — Politika privatnosti v' + d.version +
|
||||||
|
'\n\nKontroler: ' + d.controller +
|
||||||
|
'\nKontakt: ' + d.contact +
|
||||||
|
'\nDPO: ' + d.dpo +
|
||||||
|
'\n\nVaša prava:\n' + d.rights.join('\n'));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
$('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privacyLink').click(); });
|
||||||
|
|
||||||
|
// Skip login if already authenticated
|
||||||
|
(async () => {
|
||||||
|
const tok = localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access');
|
||||||
|
if (tok) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/auth/me', { headers: { Authorization: 'Bearer ' + tok }});
|
||||||
|
if (r.ok) {
|
||||||
|
location.href = '/app';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
showConsent();
|
||||||
|
$('#email').focus();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# erp/permissions.py — PGŽ Sport ERP RBAC helpers (M5/M6)
|
||||||
|
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
|
||||||
|
# Date: 2026-05-04
|
||||||
|
# Description: Centralizirane provjere ovlasti za račune i putne naloge.
|
||||||
|
#
|
||||||
|
# Uloge (pgz_sport.roles):
|
||||||
|
# super_admin, pgz_admin, savez_admin, klub_admin, klub_user, clan, viewer
|
||||||
|
#
|
||||||
|
# Korisnik (dict iz auth_v2.get_current_user) ima: id, user_type, klub_id, savez_id.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import psycopg2, psycopg2.extras
|
||||||
|
|
||||||
|
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
|
||||||
|
password="R1net2026!SecureDB#v7")
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
c = psycopg2.connect(**DB); c.autocommit = True; return c
|
||||||
|
|
||||||
|
|
||||||
|
# ── role helpers ──────────────────────────────────────────────────────
|
||||||
|
def is_super(user) -> bool:
|
||||||
|
return bool(user) and user.get("user_type") == "super_admin"
|
||||||
|
|
||||||
|
def is_pgz_admin(user) -> bool:
|
||||||
|
return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin")
|
||||||
|
|
||||||
|
def is_savez_admin(user) -> bool:
|
||||||
|
return bool(user) and user.get("user_type") == "savez_admin"
|
||||||
|
|
||||||
|
def is_klub_admin(user) -> bool:
|
||||||
|
return bool(user) and user.get("user_type") == "klub_admin"
|
||||||
|
|
||||||
|
def is_klub_user(user) -> bool:
|
||||||
|
return bool(user) and user.get("user_type") in ("klub_admin", "klub_user")
|
||||||
|
|
||||||
|
|
||||||
|
def klub_savez(klub_id: int) -> Optional[int]:
|
||||||
|
"""Vraća savez_id kojem klub pripada (preko klubovi.savez_id ili user_klub_links)."""
|
||||||
|
if not klub_id: return None
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (klub_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
return r["savez_id"] if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_see_klub(user, klub_id: Optional[int]) -> bool:
|
||||||
|
"""Tko može VIDJETI klub: super, pgz, savez (ako klub u savezu), klub_admin/user (ako vlastiti klub)."""
|
||||||
|
if not user or not klub_id:
|
||||||
|
return is_pgz_admin(user)
|
||||||
|
if is_pgz_admin(user):
|
||||||
|
return True
|
||||||
|
if is_klub_user(user):
|
||||||
|
return user.get("klub_id") == klub_id
|
||||||
|
if is_savez_admin(user):
|
||||||
|
return klub_savez(klub_id) == user.get("savez_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── INVOICES ──────────────────────────────────────────────────────────
|
||||||
|
def can_view_invoice(user, invoice: Dict[str, Any]) -> bool:
|
||||||
|
"""Pgž admin vidi sve. Savez admin svoje saveze. Klub admin/user vlastiti klub."""
|
||||||
|
if not invoice: return False
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
return user_can_see_klub(user, invoice.get("klub_id"))
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit_invoice(user, invoice: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Edit (izmjena polja, korekcija OCR-a) — samo klub_admin vlastitog kluba ILI pgz_admin.
|
||||||
|
Savez admin može komentirati, ali NE editirati.
|
||||||
|
Plaćeni računi su read-only osim za pgz_admin.
|
||||||
|
"""
|
||||||
|
if not invoice: return False
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
if invoice.get("payment_status") in ("paid",):
|
||||||
|
return False
|
||||||
|
if is_klub_admin(user):
|
||||||
|
return user.get("klub_id") == invoice.get("klub_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_pay_invoice(user, invoice: Dict[str, Any]) -> bool:
|
||||||
|
"""Označi kao plaćen — klub_admin vlastitog kluba ili pgz_admin."""
|
||||||
|
if not invoice: return False
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
if is_klub_admin(user):
|
||||||
|
return user.get("klub_id") == invoice.get("klub_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_comment_invoice(user, invoice: Dict[str, Any]) -> bool:
|
||||||
|
"""Komentirati može pgz_admin, savez_admin (svog saveza) i klub_admin (svog kluba)."""
|
||||||
|
if not invoice: return False
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
if is_savez_admin(user):
|
||||||
|
return klub_savez(invoice.get("klub_id")) == user.get("savez_id")
|
||||||
|
if is_klub_admin(user):
|
||||||
|
return user.get("klub_id") == invoice.get("klub_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def invoice_actions(user, invoice: Dict[str, Any]) -> Dict[str, bool]:
|
||||||
|
"""UI hint — koji gumbi su dostupni."""
|
||||||
|
return {
|
||||||
|
"view": can_view_invoice(user, invoice),
|
||||||
|
"edit": can_edit_invoice(user, invoice),
|
||||||
|
"pay": can_pay_invoice(user, invoice) and invoice.get("payment_status") != "paid",
|
||||||
|
"comment": can_comment_invoice(user, invoice),
|
||||||
|
"delete": is_pgz_admin(user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── PUTNI NALOZI ──────────────────────────────────────────────────────
|
||||||
|
def can_view_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||||
|
if not pn: return False
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
if is_savez_admin(user):
|
||||||
|
return klub_savez(pn.get("klub_id")) == user.get("savez_id")
|
||||||
|
if is_klub_user(user):
|
||||||
|
if user.get("klub_id") == pn.get("klub_id"):
|
||||||
|
return True
|
||||||
|
# Voditelj vidi svoj
|
||||||
|
return pn.get("user_id") == user.get("id") if user else False
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||||
|
"""Edit dozvoljen samo na statusima draft/odbijen, i samo voditelju ili klub_admin/pgz."""
|
||||||
|
if not pn: return False
|
||||||
|
status = (pn.get("status") or "draft").lower()
|
||||||
|
if status not in ("draft", "odbijen"):
|
||||||
|
return is_pgz_admin(user)
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
if is_klub_admin(user):
|
||||||
|
return user.get("klub_id") == pn.get("klub_id")
|
||||||
|
# Voditelj
|
||||||
|
return pn.get("user_id") == user.get("id") if user else False
|
||||||
|
|
||||||
|
|
||||||
|
def can_submit_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||||
|
"""Slanje (draft → poslan) — voditelj ili klub_admin."""
|
||||||
|
if not pn: return False
|
||||||
|
if (pn.get("status") or "draft").lower() not in ("draft",):
|
||||||
|
return False
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
if is_klub_admin(user):
|
||||||
|
return user.get("klub_id") == pn.get("klub_id")
|
||||||
|
return pn.get("user_id") == user.get("id") if user else False
|
||||||
|
|
||||||
|
|
||||||
|
def can_approve_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||||
|
"""Odobravanje (poslan → odobren ili odbijen) — klub_admin svog kluba ili pgz_admin."""
|
||||||
|
if not pn: return False
|
||||||
|
if (pn.get("status") or "").lower() not in ("poslan", "submitted", "draft"):
|
||||||
|
return False
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
if is_klub_admin(user):
|
||||||
|
return user.get("klub_id") == pn.get("klub_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_pay_putni_nalog(user, pn: Dict[str, Any]) -> bool:
|
||||||
|
"""Isplata (odobren → isplaćen) — klub_admin ili pgz_admin."""
|
||||||
|
if not pn: return False
|
||||||
|
if (pn.get("status") or "").lower() not in ("odobren", "approved", "zatvoren"):
|
||||||
|
return False
|
||||||
|
if is_pgz_admin(user): return True
|
||||||
|
if is_klub_admin(user):
|
||||||
|
return user.get("klub_id") == pn.get("klub_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def putni_nalog_actions(user, pn: Dict[str, Any]) -> Dict[str, bool]:
|
||||||
|
return {
|
||||||
|
"view": can_view_putni_nalog(user, pn),
|
||||||
|
"edit": can_edit_putni_nalog(user, pn),
|
||||||
|
"submit": can_submit_putni_nalog(user, pn),
|
||||||
|
"approve": can_approve_putni_nalog(user, pn),
|
||||||
|
"reject": can_approve_putni_nalog(user, pn),
|
||||||
|
"pay": can_pay_putni_nalog(user, pn),
|
||||||
|
"delete": is_pgz_admin(user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Audit logging helper ──────────────────────────────────────────────
|
||||||
|
def audit_invoice(user, invoice_id: int, op: str, field: Optional[str] = None,
|
||||||
|
old=None, new=None):
|
||||||
|
try:
|
||||||
|
with _db() as c:
|
||||||
|
c.cursor().execute(
|
||||||
|
"""INSERT INTO pgz_sport.audit_log
|
||||||
|
(tablica, operacija, record_id, korisnik, promijenjeno_polje,
|
||||||
|
stara_vrijednost, nova_vrijednost)
|
||||||
|
VALUES ('pgz_sport.invoices', %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(op, invoice_id,
|
||||||
|
(user.get("email") if user else "anon"),
|
||||||
|
field,
|
||||||
|
None if old is None else str(old)[:500],
|
||||||
|
None if new is None else str(new)[:500]),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def audit_putni(user, pn_id: int, op: str, field: Optional[str] = None,
|
||||||
|
old=None, new=None):
|
||||||
|
try:
|
||||||
|
with _db() as c:
|
||||||
|
c.cursor().execute(
|
||||||
|
"""INSERT INTO pgz_sport.audit_log
|
||||||
|
(tablica, operacija, record_id, korisnik, promijenjeno_polje,
|
||||||
|
stara_vrijednost, nova_vrijednost)
|
||||||
|
VALUES ('pgz_sport.expense_reports', %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(op, pn_id,
|
||||||
|
(user.get("email") if user else "anon"),
|
||||||
|
field,
|
||||||
|
None if old is None else str(old)[:500],
|
||||||
|
None if new is None else str(new)[:500]),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_audit(table: str, record_id: int, limit: int = 50):
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT timestamp, operacija, korisnik, promijenjeno_polje,
|
||||||
|
stara_vrijednost, nova_vrijednost
|
||||||
|
FROM pgz_sport.audit_log
|
||||||
|
WHERE tablica=%s AND record_id=%s
|
||||||
|
ORDER BY timestamp DESC LIMIT %s""",
|
||||||
|
(table, record_id, limit),
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6)
|
||||||
|
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
|
||||||
|
# Date: 2026-05-04
|
||||||
|
# Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025).
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
from fastapi import APIRouter, Body, HTTPException, Query, Header
|
||||||
|
|
||||||
|
try:
|
||||||
|
from erp.permissions import (
|
||||||
|
can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog,
|
||||||
|
can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions,
|
||||||
|
audit_putni, fetch_audit, is_pgz_admin,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
def can_view_putni_nalog(u, p): return True
|
||||||
|
def can_edit_putni_nalog(u, p): return True
|
||||||
|
def can_submit_putni_nalog(u, p): return True
|
||||||
|
def can_approve_putni_nalog(u, p): return True
|
||||||
|
def can_pay_putni_nalog(u, p): return True
|
||||||
|
def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False}
|
||||||
|
def audit_putni(u, pid, op, field=None, old=None, new=None): pass
|
||||||
|
def fetch_audit(t, r, limit=50): return []
|
||||||
|
def is_pgz_admin(u): return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from auth.auth_v2 import get_current_user as _auth_user
|
||||||
|
except Exception:
|
||||||
|
_auth_user = None
|
||||||
|
|
||||||
|
ADMIN_TOKEN = "admin-pgz-2026"
|
||||||
|
|
||||||
|
def _resolve_user(authorization):
|
||||||
|
if _auth_user:
|
||||||
|
try:
|
||||||
|
u = _auth_user(authorization)
|
||||||
|
if u: return u
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN:
|
||||||
|
return {"id": 0, "email": "admin@token", "user_type": "pgz_admin",
|
||||||
|
"klub_id": None, "savez_id": None, "_synthetic": True}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"])
|
||||||
|
|
||||||
|
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
|
||||||
|
password="R1net2026!SecureDB#v7")
|
||||||
|
|
||||||
|
# === HR pravilnik 2025 — dnevnice ===
|
||||||
|
# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h.
|
||||||
|
# Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €).
|
||||||
|
DNEVNICA_DOM_FULL = 26.54 # EUR
|
||||||
|
DNEVNICA_DOM_HALF = 13.27 # EUR
|
||||||
|
KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil)
|
||||||
|
|
||||||
|
# Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo).
|
||||||
|
DNEVNICE_INO = {
|
||||||
|
"Italija": 35.00,
|
||||||
|
"Italy": 35.00,
|
||||||
|
"Slovenija": 30.00,
|
||||||
|
"Slovenia": 30.00,
|
||||||
|
"Austrija": 35.00,
|
||||||
|
"Austria": 35.00,
|
||||||
|
"Mađarska": 30.00,
|
||||||
|
"Madarska": 30.00,
|
||||||
|
"Hungary": 30.00,
|
||||||
|
"Bosna i Hercegovina": 30.00,
|
||||||
|
"BiH": 30.00,
|
||||||
|
"Bosnia": 30.00,
|
||||||
|
"Srbija": 30.00,
|
||||||
|
"Serbia": 30.00,
|
||||||
|
"Crna Gora": 30.00,
|
||||||
|
"Montenegro": 30.00,
|
||||||
|
"Njemačka": 50.00,
|
||||||
|
"Germany": 50.00,
|
||||||
|
"Francuska": 50.00,
|
||||||
|
"France": 50.00,
|
||||||
|
"Švicarska": 60.00,
|
||||||
|
"Switzerland": 60.00,
|
||||||
|
"SAD": 70.00,
|
||||||
|
"USA": 70.00,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
c = psycopg2.connect(**DB)
|
||||||
|
c.autocommit = True
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(v) -> Optional[datetime]:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return v
|
||||||
|
s = str(v).strip().replace("Z", "+00:00")
|
||||||
|
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S",
|
||||||
|
"%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(s)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict:
|
||||||
|
"""
|
||||||
|
Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]}
|
||||||
|
Pravila (HR pravilnik 2025, neoporeziv iznos):
|
||||||
|
- Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna.
|
||||||
|
- Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €.
|
||||||
|
- Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima.
|
||||||
|
Implementacija (jednostavna, transparentna):
|
||||||
|
1) ukupne sate računaj kao razliku.
|
||||||
|
2) full_segments = sati // 24
|
||||||
|
3) ostatak_sati = sati - full_segments*24
|
||||||
|
4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0.
|
||||||
|
5) puna dnevnica = pun_iznos po zemlji; pola = polovica.
|
||||||
|
"""
|
||||||
|
df = _parse_dt(date_from)
|
||||||
|
dt = _parse_dt(date_to)
|
||||||
|
if not df or not dt or dt < df:
|
||||||
|
return {"error": "neispravni datumi", "hours": 0,
|
||||||
|
"days_full": 0, "days_half": 0,
|
||||||
|
"dnevnica_amount_total": 0.0, "breakdown": []}
|
||||||
|
|
||||||
|
delta = dt - df
|
||||||
|
hours = round(delta.total_seconds() / 3600, 2)
|
||||||
|
|
||||||
|
full_segments = int(delta.total_seconds() // (24 * 3600))
|
||||||
|
remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0
|
||||||
|
|
||||||
|
days_full = full_segments
|
||||||
|
days_half = 0.0
|
||||||
|
if remainder_h >= 8:
|
||||||
|
days_full += 1
|
||||||
|
elif remainder_h >= 5:
|
||||||
|
days_half += 1
|
||||||
|
# else: 0
|
||||||
|
|
||||||
|
is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr")
|
||||||
|
if is_domestic:
|
||||||
|
full_amt = DNEVNICA_DOM_FULL
|
||||||
|
half_amt = DNEVNICA_DOM_HALF
|
||||||
|
else:
|
||||||
|
full_amt = DNEVNICE_INO.get(country.strip(), 50.00)
|
||||||
|
half_amt = full_amt / 2.0
|
||||||
|
|
||||||
|
total = round(days_full * full_amt + days_half * half_amt, 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hours": hours,
|
||||||
|
"days_full": days_full,
|
||||||
|
"days_half": days_half,
|
||||||
|
"country": country,
|
||||||
|
"rate_full": full_amt,
|
||||||
|
"rate_half": half_amt,
|
||||||
|
"dnevnica_amount_total": total,
|
||||||
|
"breakdown": [
|
||||||
|
f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €",
|
||||||
|
f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float:
|
||||||
|
try:
|
||||||
|
return round(float(km or 0) * float(km_rate or 0), 2)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# === Endpoints ===
|
||||||
|
|
||||||
|
@router.get("/putni-nalog/dnevnice/preview")
|
||||||
|
def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska",
|
||||||
|
km: float = 0.0, km_rate: float = KM_RATE_DEFAULT):
|
||||||
|
"""Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview."""
|
||||||
|
d = compute_dnevnice(date_from, date_to, country)
|
||||||
|
km_amt = compute_kilometrina(km, km_rate)
|
||||||
|
d["km_amount"] = km_amt
|
||||||
|
d["km_driven"] = km
|
||||||
|
d["km_rate"] = km_rate
|
||||||
|
d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2)
|
||||||
|
return {"ok": True, "preview": d}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/putni-nalog")
|
||||||
|
def list_putni_nalozi(klub_id: Optional[int] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
limit: int = Query(100, le=500),
|
||||||
|
offset: int = 0):
|
||||||
|
sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv,
|
||||||
|
er.user_id, er.clan_id, er.report_type, er.report_no,
|
||||||
|
er.destination, er.purpose,
|
||||||
|
er.date_from, er.date_to,
|
||||||
|
er.vehicle_type, er.vehicle_plate,
|
||||||
|
er.km_driven, er.km_rate,
|
||||||
|
er.cost_transport, er.cost_lodging, er.cost_meals,
|
||||||
|
er.cost_other, er.cost_total,
|
||||||
|
er.dnevnice_count, er.dnevnice_amount,
|
||||||
|
er.status, er.approved_at, er.paid_at,
|
||||||
|
er.created_at, er.tenant_id, er.notes
|
||||||
|
FROM pgz_sport.expense_reports er
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
|
||||||
|
WHERE er.report_type='putni_nalog'"""
|
||||||
|
args: list = []
|
||||||
|
if klub_id is not None:
|
||||||
|
sql += " AND er.klub_id=%s"; args.append(klub_id)
|
||||||
|
if status:
|
||||||
|
sql += " AND er.status=%s"; args.append(status)
|
||||||
|
sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s"
|
||||||
|
args += [limit, offset]
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(sql, args)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return {"ok": True, "rows": rows, "count": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/putni-nalog/{nalog_id}")
|
||||||
|
def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("""SELECT er.*, k.naziv AS klub_naziv, k.savez_id
|
||||||
|
FROM pgz_sport.expense_reports er
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
|
||||||
|
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_view_putni_nalog(user, row):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
|
||||||
|
|
||||||
|
# Vezani računi iz m2m tablice
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
|
||||||
|
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category,
|
||||||
|
pnr.kategorija AS attached_kategorija, pnr.attached_at
|
||||||
|
FROM pgz_sport.putni_nalog_racuni pnr
|
||||||
|
JOIN pgz_sport.invoices i ON i.id = pnr.invoice_id
|
||||||
|
WHERE pnr.putni_nalog_id=%s
|
||||||
|
ORDER BY i.invoice_date DESC""", (nalog_id,))
|
||||||
|
invoices = cur.fetchall()
|
||||||
|
|
||||||
|
# Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
|
||||||
|
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category
|
||||||
|
FROM pgz_sport.invoices i
|
||||||
|
LEFT JOIN pgz_sport.putni_nalog_racuni pnr
|
||||||
|
ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s
|
||||||
|
WHERE i.klub_id=%s
|
||||||
|
AND i.invoice_date BETWEEN %s AND %s
|
||||||
|
AND i.invoice_kind IN ('gorivo','cestarina','hotel','restoran','oprema','ostalo')
|
||||||
|
AND pnr.id IS NULL
|
||||||
|
ORDER BY i.invoice_date DESC LIMIT 50""",
|
||||||
|
(nalog_id, row.get("klub_id"), row.get("date_from"), row.get("date_to")),
|
||||||
|
)
|
||||||
|
suggested = cur.fetchall()
|
||||||
|
|
||||||
|
# Payments za ovaj putni nalog
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, payment_date, amount, currency, payment_method, iban_from,
|
||||||
|
iban_to, reference, bank_transaction_id, matched_status, created_at
|
||||||
|
FROM pgz_sport.payments WHERE expense_report_id=%s
|
||||||
|
ORDER BY payment_date DESC""", (nalog_id,))
|
||||||
|
payments = cur.fetchall()
|
||||||
|
|
||||||
|
audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50)
|
||||||
|
actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False}
|
||||||
|
return {"ok": True, "putni_nalog": row, "invoices": invoices,
|
||||||
|
"suggested_invoices": suggested,
|
||||||
|
"payments": payments, "audit": audit, "actions": actions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/putni-nalog/{nalog_id}/attach-invoice")
|
||||||
|
def attach_invoice(nalog_id: int, body: dict = Body(...),
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
"""Veži postojeći račun na putni nalog (m2m)."""
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
inv_id = body.get("invoice_id")
|
||||||
|
kategorija = body.get("kategorija") or body.get("category")
|
||||||
|
if not inv_id:
|
||||||
|
raise HTTPException(400, "invoice_id je obavezan")
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
||||||
|
pn = cur.fetchone()
|
||||||
|
if not pn:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti za vezivanje računa")
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO pgz_sport.putni_nalog_racuni
|
||||||
|
(putni_nalog_id, invoice_id, kategorija, attached_by)
|
||||||
|
VALUES (%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (putni_nalog_id, invoice_id) DO UPDATE SET kategorija=EXCLUDED.kategorija
|
||||||
|
RETURNING id, attached_at""",
|
||||||
|
(nalog_id, inv_id, kategorija, (user.get("id") if user else None)),
|
||||||
|
)
|
||||||
|
link = cur.fetchone()
|
||||||
|
audit_putni(user, nalog_id, "attach_invoice", field="invoice_id", new=inv_id)
|
||||||
|
return {"ok": True, "link_id": link["id"], "attached_at": link["attached_at"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/putni-nalog/{nalog_id}/invoice/{invoice_id}")
|
||||||
|
def detach_invoice(nalog_id: int, invoice_id: int,
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
||||||
|
pn = cur.fetchone()
|
||||||
|
if not pn:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti")
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s AND invoice_id=%s",
|
||||||
|
(nalog_id, invoice_id),
|
||||||
|
)
|
||||||
|
audit_putni(user, nalog_id, "detach_invoice", field="invoice_id", old=invoice_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/putni-nalog/{nalog_id}/posalji")
|
||||||
|
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
|
||||||
|
"""Voditelj/klub_admin šalje draft → poslan."""
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
||||||
|
pn = cur.fetchone()
|
||||||
|
if not pn:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_submit_putni_nalog(user, pn):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti slanja na odobrenje")
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE pgz_sport.expense_reports SET status='poslan', updated_at=NOW()
|
||||||
|
WHERE id=%s RETURNING id, status""", (nalog_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan")
|
||||||
|
return {"ok": True, "putni_nalog": row}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/putni-nalog/{nalog_id}/odbij")
|
||||||
|
def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
"""Klub_admin/pgz_admin odbija s razlogom."""
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
razlog = (body.get("razlog") or body.get("reason") or "").strip()
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
||||||
|
pn = cur.fetchone()
|
||||||
|
if not pn:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_approve_putni_nalog(user, pn):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti odbiti")
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE pgz_sport.expense_reports
|
||||||
|
SET status='odbijen', notes=COALESCE(notes,'') || E'\n[ODBIJEN] ' || %s, updated_at=NOW()
|
||||||
|
WHERE id=%s RETURNING id, status, notes""",
|
||||||
|
(razlog or "(bez razloga)", nalog_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
audit_putni(user, nalog_id, "reject", field="status",
|
||||||
|
old=pn.get("status"), new=f"odbijen: {razlog}")
|
||||||
|
return {"ok": True, "putni_nalog": row}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/putni-nalog/{nalog_id}/isplati")
|
||||||
|
def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
"""Isplata putnog naloga (odobren/zatvoren → isplaćen).
|
||||||
|
Body: {iban_to, iban_from, paid_date, amount, reference, bank_transaction_id}"""
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
||||||
|
pn = cur.fetchone()
|
||||||
|
if not pn:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_pay_putni_nalog(user, pn):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti za isplatu")
|
||||||
|
|
||||||
|
paid_date = body.get("paid_date") or date.today().isoformat()
|
||||||
|
iban_to = body.get("iban_to")
|
||||||
|
iban_from = body.get("iban_from")
|
||||||
|
amount = body.get("amount") or pn.get("cost_total")
|
||||||
|
reference = body.get("reference")
|
||||||
|
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
|
||||||
|
payment_method = body.get("payment_method") or "transfer"
|
||||||
|
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE pgz_sport.expense_reports
|
||||||
|
SET status='isplacen', paid_at=%s, updated_at=NOW()
|
||||||
|
WHERE id=%s RETURNING id, status, paid_at, cost_total""",
|
||||||
|
(paid_date, nalog_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO pgz_sport.payments
|
||||||
|
(klub_id, expense_report_id, payment_date, amount, currency,
|
||||||
|
payment_method, iban_from, iban_to, reference, bank_transaction_id,
|
||||||
|
matched_status)
|
||||||
|
VALUES (%s,%s,%s,%s,'EUR',%s,%s,%s,%s,%s,'matched')
|
||||||
|
RETURNING id""",
|
||||||
|
(pn.get("klub_id"), nalog_id, paid_date, amount, payment_method,
|
||||||
|
iban_from, iban_to, reference, tx_id),
|
||||||
|
)
|
||||||
|
pay = cur.fetchone()
|
||||||
|
audit_putni(user, nalog_id, "pay", field="status",
|
||||||
|
old=pn.get("status"), new="isplacen")
|
||||||
|
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
|
||||||
|
def putni_hub3(nalog_id: int, iban: Optional[str] = None,
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
"""HUB-3 uplatnica + EPC QR za isplatu putnog naloga voditelju."""
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT er.*, k.naziv AS klub_naziv, k.savez_id, k.adresa AS klub_adresa
|
||||||
|
FROM pgz_sport.expense_reports er
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id
|
||||||
|
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
|
||||||
|
pn = cur.fetchone()
|
||||||
|
if not pn:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_view_putni_nalog(user, pn):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from crm.payments import build_hub3_pdf
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}")
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
att = pn.get("attachments") or {}
|
||||||
|
if isinstance(att, str):
|
||||||
|
try: att = json.loads(att)
|
||||||
|
except Exception: att = {}
|
||||||
|
voditelj = att.get("voditelj") or "Voditelj putovanja"
|
||||||
|
iban_to = (iban or "").strip() or att.get("iban_voditelja") or "HR0000000000000000000"
|
||||||
|
iznos = float(pn.get("cost_total") or 0)
|
||||||
|
if iznos <= 0:
|
||||||
|
raise HTTPException(400, "Iznos isplate mora biti veći od 0")
|
||||||
|
|
||||||
|
poziv = f"{nalog_id:08d}"
|
||||||
|
opis = f"Putni nalog #{nalog_id}: {pn.get('destination') or ''} ({pn.get('date_from')}–{pn.get('date_to')})"[:140]
|
||||||
|
|
||||||
|
pdf = build_hub3_pdf(
|
||||||
|
platitelj_naziv=pn.get("klub_naziv") or "PGŽ Sport klub",
|
||||||
|
platitelj_adresa=pn.get("klub_adresa") or "—",
|
||||||
|
primatelj_naziv=voditelj,
|
||||||
|
primatelj_adresa="—",
|
||||||
|
iban=iban_to,
|
||||||
|
amount_eur=iznos,
|
||||||
|
model="HR99",
|
||||||
|
poziv_na_broj=poziv,
|
||||||
|
opis=opis,
|
||||||
|
sifra_namjene="SALA",
|
||||||
|
datum=date.today(),
|
||||||
|
)
|
||||||
|
return Response(content=pdf, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="putni-nalog-{nalog_id}-HUB3.pdf"'})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/putni-nalog/{nalog_id}/audit")
|
||||||
|
def putni_audit(nalog_id: int, limit: int = 100,
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,))
|
||||||
|
pn = cur.fetchone()
|
||||||
|
if not pn:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_view_putni_nalog(user, pn):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti")
|
||||||
|
return {"ok": True, "audit": fetch_audit("pgz_sport.expense_reports", nalog_id, limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/putni-nalog")
|
||||||
|
def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)):
|
||||||
|
"""Kreiraj putni nalog.
|
||||||
|
Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[],
|
||||||
|
svrha (purpose), od_grada, do_grada (destination),
|
||||||
|
datum_polaska (date_from), datum_povratka (date_to),
|
||||||
|
registracija_vozila (vehicle_plate), vehicle_type,
|
||||||
|
kilometara (km_driven), km_rate,
|
||||||
|
predviđeni_troškovi (cost_estimate), country, notes."""
|
||||||
|
df = body.get("date_from") or body.get("datum_polaska")
|
||||||
|
dt = body.get("date_to") or body.get("datum_povratka")
|
||||||
|
if not df or not dt:
|
||||||
|
raise HTTPException(400, "Datum polaska i povratka su obavezni")
|
||||||
|
klub_id = body.get("klub_id")
|
||||||
|
if not klub_id:
|
||||||
|
raise HTTPException(400, "klub_id je obavezan")
|
||||||
|
|
||||||
|
country = body.get("country", "Hrvatska")
|
||||||
|
km = body.get("km_driven", body.get("kilometara", 0)) or 0
|
||||||
|
km_rate = body.get("km_rate") or KM_RATE_DEFAULT
|
||||||
|
dnv = compute_dnevnice(df, dt, country)
|
||||||
|
dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0)
|
||||||
|
dnevnice_amount = dnv.get("dnevnica_amount_total") or 0
|
||||||
|
cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0)
|
||||||
|
|
||||||
|
od = body.get("od_grada") or body.get("from_city")
|
||||||
|
do = body.get("do_grada") or body.get("to_city") or body.get("destination")
|
||||||
|
destination = " → ".join([x for x in [od, do] if x]) or do
|
||||||
|
|
||||||
|
putnici = body.get("putnici") or []
|
||||||
|
voditelj = body.get("voditelj_ime") or body.get("voditelj")
|
||||||
|
purpose = body.get("svrha") or body.get("purpose") or ""
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"voditelj": voditelj,
|
||||||
|
"putnici": putnici,
|
||||||
|
"from_city": od, "to_city": do,
|
||||||
|
"country": country,
|
||||||
|
"dnevnice_calc": dnv,
|
||||||
|
"predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO pgz_sport.expense_reports
|
||||||
|
(klub_id, user_id, clan_id, report_type, report_no, destination, purpose,
|
||||||
|
date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate,
|
||||||
|
cost_transport, cost_lodging, cost_meals, cost_other,
|
||||||
|
dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id)
|
||||||
|
VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s, COALESCE(%s,'draft'), %s, %s, %s)
|
||||||
|
RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount,
|
||||||
|
cost_transport, date_from, date_to, destination""",
|
||||||
|
(
|
||||||
|
klub_id, body.get("user_id"), body.get("clan_id"),
|
||||||
|
body.get("report_no"), destination, purpose,
|
||||||
|
df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"),
|
||||||
|
float(km or 0), float(km_rate or 0),
|
||||||
|
cost_transport,
|
||||||
|
body.get("cost_lodging") or 0, body.get("cost_meals") or 0,
|
||||||
|
body.get("cost_other") or 0,
|
||||||
|
dnevnice_count, dnevnice_amount,
|
||||||
|
body.get("status"),
|
||||||
|
json.dumps(meta, ensure_ascii=False, default=str),
|
||||||
|
body.get("notes"),
|
||||||
|
body.get("tenant_id", 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
# cost_total via trigger maybe; recompute here
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE pgz_sport.expense_reports
|
||||||
|
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
|
||||||
|
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
|
||||||
|
+COALESCE(dnevnice_amount,0)
|
||||||
|
WHERE id=%s
|
||||||
|
RETURNING cost_total""", (row["id"],),
|
||||||
|
)
|
||||||
|
ct = cur.fetchone()
|
||||||
|
if ct:
|
||||||
|
row["cost_total"] = ct["cost_total"]
|
||||||
|
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/putni-nalog/{nalog_id}")
|
||||||
|
def update_putni_nalog(nalog_id: int, body: dict = Body(...)):
|
||||||
|
"""Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe)."""
|
||||||
|
cols = []
|
||||||
|
args: list = []
|
||||||
|
for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type",
|
||||||
|
"vehicle_plate", "km_driven", "km_rate", "cost_transport",
|
||||||
|
"cost_lodging", "cost_meals", "cost_other", "notes",
|
||||||
|
"dnevnice_count", "dnevnice_amount"):
|
||||||
|
if col in body:
|
||||||
|
cols.append(f"{col}=%s"); args.append(body[col])
|
||||||
|
# Recompute dnevnice if dates provided
|
||||||
|
if "date_from" in body or "date_to" in body or "country" in body:
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,))
|
||||||
|
cur_row = cur.fetchone()
|
||||||
|
if cur_row:
|
||||||
|
df = body.get("date_from") or cur_row["date_from"]
|
||||||
|
dt = body.get("date_to") or cur_row["date_to"]
|
||||||
|
country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska")
|
||||||
|
d = compute_dnevnice(df, dt, country)
|
||||||
|
cols += ["dnevnice_count=%s", "dnevnice_amount=%s"]
|
||||||
|
args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0),
|
||||||
|
d.get("dnevnica_amount_total") or 0]
|
||||||
|
if not cols:
|
||||||
|
raise HTTPException(400, "Nema polja za izmjenu")
|
||||||
|
cols.append("updated_at=NOW()")
|
||||||
|
args.append(nalog_id)
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE pgz_sport.expense_reports
|
||||||
|
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
|
||||||
|
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
|
||||||
|
+COALESCE(dnevnice_amount,0)
|
||||||
|
WHERE id=%s""", (nalog_id,),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
return {"ok": True, "putni_nalog": row}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/putni-nalog/{nalog_id}/odobriti")
|
||||||
|
def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
approved_by = body.get("approved_by") or (user.get("id") if user else None)
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
||||||
|
pn = cur.fetchone()
|
||||||
|
if not pn:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
if user and not can_approve_putni_nalog(user, pn):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti odobriti")
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE pgz_sport.expense_reports
|
||||||
|
SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW()
|
||||||
|
WHERE id=%s AND report_type='putni_nalog'
|
||||||
|
RETURNING id, status, approved_at""", (approved_by, nalog_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
audit_putni(user, nalog_id, "approve", field="status",
|
||||||
|
old=pn.get("status"), new="odobren")
|
||||||
|
return {"ok": True, "putni_nalog": row}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/putni-nalog/{nalog_id}/zatvori")
|
||||||
|
def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})):
|
||||||
|
"""Zatvori putni nalog: priloži račune i konačan obračun."""
|
||||||
|
invoice_ids = body.get("invoice_ids") or []
|
||||||
|
cost_lodging = body.get("cost_lodging")
|
||||||
|
cost_meals = body.get("cost_meals")
|
||||||
|
cost_other = body.get("cost_other")
|
||||||
|
notes = body.get("notes")
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,))
|
||||||
|
cur_row = cur.fetchone()
|
||||||
|
if not cur_row:
|
||||||
|
raise HTTPException(404, "Putni nalog ne postoji")
|
||||||
|
|
||||||
|
# Aggregiraj iznose iz računa (ako su poslani)
|
||||||
|
if invoice_ids:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)",
|
||||||
|
(invoice_ids,),
|
||||||
|
)
|
||||||
|
invs_total = float(cur.fetchone()["total"] or 0)
|
||||||
|
else:
|
||||||
|
invs_total = None
|
||||||
|
|
||||||
|
sets = ["status='zatvoren'", "updated_at=NOW()"]
|
||||||
|
args: list = []
|
||||||
|
if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging)
|
||||||
|
if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals)
|
||||||
|
if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other)
|
||||||
|
if notes: sets.append("notes=%s"); args.append(notes)
|
||||||
|
# Pohrani povezane račune u attachments
|
||||||
|
atts = cur_row["attachments"] or {}
|
||||||
|
if isinstance(atts, str):
|
||||||
|
try: atts = json.loads(atts)
|
||||||
|
except Exception: atts = {}
|
||||||
|
atts["invoice_ids"] = invoice_ids
|
||||||
|
if invs_total is not None:
|
||||||
|
atts["invoices_total"] = invs_total
|
||||||
|
sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str))
|
||||||
|
args.append(nalog_id)
|
||||||
|
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE pgz_sport.expense_reports
|
||||||
|
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
|
||||||
|
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
|
||||||
|
+COALESCE(dnevnice_amount,0)
|
||||||
|
WHERE id=%s RETURNING cost_total""", (nalog_id,),
|
||||||
|
)
|
||||||
|
ct = cur.fetchone()
|
||||||
|
if ct: row["cost_total"] = ct["cost_total"]
|
||||||
|
return {"ok": True, "putni_nalog": row}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
|||||||
|
/* PGŽ SPORT — Unified Sidebar v1.0
|
||||||
|
* dradulic@outlook.com / damir@rinet.one — 2026-05-05
|
||||||
|
* Used by: sport2.html, app.html, admin.html, crm.html, erp.html, audit.html, kpi.html, login.html
|
||||||
|
* Reference: app.rinet.one/klasik/control
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--pgz-blue:#003087; --pgz-blue2:#004CC4; --pgz-gold:#F4C430;
|
||||||
|
--bg0:#08090e; --bg1:#0d1021; --bg2:#111628; --bg3:#161d35; --bg4:#1c2542;
|
||||||
|
--rim:#1e2a50; --rim2:#283560;
|
||||||
|
--t0:#fff; --t1:#e2e6f0; --t2:#8a95b4; --t4:#4e5a7a;
|
||||||
|
--green:#00e88f; --red:#ff2d55; --amber:#f59e0b; --cyan:#00c8e8;
|
||||||
|
--sb-w-exp:230px; --sb-w-col:58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pgz-sb{
|
||||||
|
position:fixed; top:0; left:0; bottom:0; width:var(--sb-w-exp);
|
||||||
|
background:linear-gradient(180deg,var(--bg1) 0%,var(--bg0) 100%);
|
||||||
|
border-right:1px solid var(--rim);
|
||||||
|
display:flex; flex-direction:column; z-index:100;
|
||||||
|
font-family:'Inter',sans-serif; font-size:13px; color:var(--t1);
|
||||||
|
transition:width .22s ease, transform .22s ease;
|
||||||
|
}
|
||||||
|
#pgz-sb *{box-sizing:border-box}
|
||||||
|
#pgz-sb a{text-decoration:none;color:inherit}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.pgz-sb-h{padding:18px 18px 14px;border-bottom:1px solid var(--rim);position:relative;flex-shrink:0}
|
||||||
|
.pgz-sb-h .pgz-logo{font-weight:800;font-size:14px;color:var(--t0);letter-spacing:.5px;white-space:nowrap;overflow:hidden}
|
||||||
|
.pgz-sb-h .pgz-logo .g{color:var(--pgz-gold)}
|
||||||
|
.pgz-sb-h .pgz-sub{font-size:10px;color:var(--t2);margin-top:4px;text-transform:uppercase;letter-spacing:1px;white-space:nowrap;overflow:hidden}
|
||||||
|
.pgz-sb-toggle{
|
||||||
|
position:absolute;top:14px;right:8px;width:24px;height:24px;
|
||||||
|
display:flex;align-items:center;justify-content:center;cursor:pointer;
|
||||||
|
color:var(--t2);background:var(--bg2);border:1px solid var(--rim);
|
||||||
|
border-radius:5px;font-size:14px;font-weight:700;
|
||||||
|
transition:all .15s;user-select:none;
|
||||||
|
}
|
||||||
|
.pgz-sb-toggle:hover{background:var(--bg3);color:var(--pgz-gold);border-color:var(--pgz-gold)}
|
||||||
|
|
||||||
|
/* Section label / separator */
|
||||||
|
.pgz-sb-sep{padding:14px 14px 4px 14px;font-size:9.5px;color:var(--t4);
|
||||||
|
text-transform:uppercase;letter-spacing:1.2px;font-weight:700;
|
||||||
|
white-space:nowrap;overflow:hidden}
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
.pgz-sb-nav{flex:1;padding:6px 8px;overflow-y:auto;overflow-x:hidden}
|
||||||
|
.pgz-sb-nav::-webkit-scrollbar{width:6px}
|
||||||
|
.pgz-sb-nav::-webkit-scrollbar-thumb{background:var(--rim2);border-radius:3px}
|
||||||
|
.pgz-nav-i{
|
||||||
|
padding:9px 12px;border-radius:6px;color:var(--t2);
|
||||||
|
cursor:pointer;display:flex;align-items:center;gap:10px;
|
||||||
|
font-size:12.5px;margin-bottom:2px;white-space:nowrap;
|
||||||
|
transition:background .15s,color .15s;position:relative;
|
||||||
|
}
|
||||||
|
.pgz-nav-i:hover{background:var(--bg2);color:var(--t1)}
|
||||||
|
.pgz-nav-i.active{
|
||||||
|
background:linear-gradient(90deg,var(--pgz-blue) 0%,var(--pgz-blue2) 100%);
|
||||||
|
color:#fff;font-weight:600;
|
||||||
|
}
|
||||||
|
.pgz-nav-i .ic{width:20px;text-align:center;font-size:14px;flex-shrink:0}
|
||||||
|
.pgz-nav-i .lbl{overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||||
|
.pgz-nav-i .badge{margin-left:auto;background:var(--red);color:#fff;font-size:9px;font-weight:700;padding:1px 6px;border-radius:8px;flex-shrink:0}
|
||||||
|
.pgz-nav-ext{color:var(--cyan)}
|
||||||
|
.pgz-nav-ext::after{content:"↗";font-size:10px;opacity:.5;margin-left:auto;flex-shrink:0}
|
||||||
|
.pgz-nav-ext:hover{color:var(--pgz-gold);background:var(--bg2)}
|
||||||
|
.pgz-nav-ext.active{background:linear-gradient(90deg,var(--pgz-blue) 0%,var(--pgz-blue2) 100%);color:#fff}
|
||||||
|
.pgz-nav-ext.active::after{opacity:.85}
|
||||||
|
|
||||||
|
/* Footer (user) */
|
||||||
|
.pgz-sb-foot{padding:10px 12px;border-top:1px solid var(--rim);
|
||||||
|
display:flex;align-items:center;gap:8px;
|
||||||
|
white-space:nowrap;overflow:hidden;flex-shrink:0}
|
||||||
|
.pgz-sb-foot .av{
|
||||||
|
width:30px;height:30px;border-radius:50%;
|
||||||
|
background:linear-gradient(135deg,var(--pgz-blue),var(--pgz-gold));
|
||||||
|
color:#fff;font-weight:800;display:flex;align-items:center;justify-content:center;
|
||||||
|
font-size:11px;flex-shrink:0;overflow:hidden;
|
||||||
|
}
|
||||||
|
.pgz-sb-foot .av img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.pgz-sb-foot .ui{flex:1;min-width:0;overflow:hidden}
|
||||||
|
.pgz-sb-foot .un{font-size:11.5px;color:var(--t1);font-weight:600;line-height:1.2;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.pgz-sb-foot .ur{font-size:9.5px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;line-height:1.2;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.pgz-sb-foot .lo{cursor:pointer;color:var(--t4);font-size:14px;
|
||||||
|
padding:6px 8px;border-radius:5px;transition:all .15s;flex-shrink:0}
|
||||||
|
.pgz-sb-foot .lo:hover{background:rgba(255,45,85,.15);color:var(--red)}
|
||||||
|
|
||||||
|
/* Mobile burger (shown <768px when sidebar is offscreen) */
|
||||||
|
.pgz-sb-burger{
|
||||||
|
position:fixed;top:10px;left:10px;z-index:99;
|
||||||
|
width:36px;height:36px;display:none;align-items:center;justify-content:center;
|
||||||
|
background:var(--bg2);border:1px solid var(--rim);border-radius:6px;
|
||||||
|
color:var(--t1);font-size:18px;cursor:pointer;
|
||||||
|
}
|
||||||
|
.pgz-sb-burger:hover{background:var(--bg3);color:var(--pgz-gold)}
|
||||||
|
|
||||||
|
/* Mobile X (shown <768px when sidebar is open) */
|
||||||
|
.pgz-sb-mx{display:none;cursor:pointer;color:var(--t2);font-size:18px;
|
||||||
|
width:24px;height:24px;align-items:center;justify-content:center;
|
||||||
|
border-radius:5px;transition:all .15s}
|
||||||
|
.pgz-sb-mx:hover{background:var(--bg3);color:var(--red)}
|
||||||
|
|
||||||
|
/* ─── Collapsed state ─── */
|
||||||
|
#pgz-sb.pgz-collapsed{width:var(--sb-w-col)}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-h{padding:18px 6px 14px;text-align:center}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-h .pgz-logo{font-size:0}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-h .pgz-logo::before{content:"PG";font-size:13px;color:var(--pgz-gold);font-weight:800}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-h .pgz-sub{display:none}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-toggle{position:static;margin:6px auto 0;display:flex}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-sep{font-size:0;padding:6px 0;text-align:center;border-top:1px dashed var(--rim);margin:6px 8px 4px}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-nav-i{justify-content:center;padding:10px 6px}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-nav-i .lbl,
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-nav-i .badge,
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-nav-ext::after{display:none}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-foot{padding:10px 6px;justify-content:center}
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-foot .ui,
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-sb-foot .lo{display:none}
|
||||||
|
|
||||||
|
/* Tooltip when collapsed */
|
||||||
|
#pgz-sb.pgz-collapsed .pgz-nav-i:hover::after{
|
||||||
|
content:attr(data-label);
|
||||||
|
position:absolute;left:calc(var(--sb-w-col) - 4px);top:50%;transform:translateY(-50%);
|
||||||
|
background:var(--bg3);color:var(--t0);
|
||||||
|
padding:5px 10px;border-radius:4px;
|
||||||
|
font-size:11.5px;white-space:nowrap;
|
||||||
|
border:1px solid var(--rim);font-weight:600;
|
||||||
|
box-shadow:2px 2px 10px rgba(0,0,0,.45);
|
||||||
|
pointer-events:none;z-index:200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout helper — apply on body to push content right of sidebar */
|
||||||
|
body.pgz-has-sb{padding-left:var(--sb-w-exp);transition:padding-left .22s ease}
|
||||||
|
body.pgz-has-sb.pgz-sb-col{padding-left:var(--sb-w-col)}
|
||||||
|
|
||||||
|
/* Mobile: <768px */
|
||||||
|
@media (max-width:768px){
|
||||||
|
#pgz-sb{transform:translateX(-100%)}
|
||||||
|
#pgz-sb.pgz-mobile-open{transform:translateX(0)}
|
||||||
|
#pgz-sb.pgz-collapsed{width:var(--sb-w-exp)} /* full width on mobile when open */
|
||||||
|
body.pgz-has-sb,body.pgz-has-sb.pgz-sb-col{padding-left:0}
|
||||||
|
.pgz-sb-burger{display:flex}
|
||||||
|
.pgz-sb-mx{display:flex}
|
||||||
|
.pgz-sb-toggle{display:none}
|
||||||
|
/* overlay backdrop */
|
||||||
|
body.pgz-mobile-sb-open::before{
|
||||||
|
content:"";position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:99;backdrop-filter:blur(2px)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/* PGŽ SPORT — Unified Sidebar v1.0
|
||||||
|
* dradulic@outlook.com / damir@rinet.one — 2026-05-05
|
||||||
|
*
|
||||||
|
* Usage on each page:
|
||||||
|
* <link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||||
|
* <script src="/static/shared/sidebar.js" defer
|
||||||
|
* data-active="app" // page key for highlight: app|admin|crm|erp|kpi|audit|login|sport2
|
||||||
|
* data-inline="0"></script> // 0 (default) = render on load. 1 = call PGZSidebar.mount() yourself
|
||||||
|
*
|
||||||
|
* The script renders #pgz-sb at start of <body>, adds class "pgz-has-sb" to body
|
||||||
|
* (so existing layouts can be migrated). Pages that already have their own sidebar
|
||||||
|
* should pass data-skip="1" — only NAV_EXTERNAL portal links will be appended to
|
||||||
|
* an element with id="pgz-portal-mount" if present.
|
||||||
|
*/
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ────────── Configuration ──────────
|
||||||
|
// Per-portal "internal" sections (left as a hint; pages typically own their own internal nav)
|
||||||
|
// External portal links — same on every page
|
||||||
|
const NAV_EXTERNAL = [
|
||||||
|
{id:'login', href:'/sport/login', ic:'\u{1F511}', label:'Prijava'},
|
||||||
|
{id:'app', href:'/sport/app', ic:'\u{1F4F1}', label:'Aplikacija'},
|
||||||
|
{id:'admin', href:'/sport/admin', ic:'\u{1F6E1}', label:'Administracija'},
|
||||||
|
{id:'crm', href:'/sport/crm', ic:'\u{1F465}', label:'CRM'},
|
||||||
|
{id:'erp', href:'/sport/erp', ic:'\u{1F4B0}', label:'ERP'},
|
||||||
|
{id:'kpi', href:'/sport/kpi', ic:'\u{1F4C8}', label:'KPI'},
|
||||||
|
{id:'audit', href:'/sport/audit', ic:'\u{1F4CB}', label:'Audit'},
|
||||||
|
{id:'sport2', href:'/sport/static/sport2.html', ic:'\u{1F310}', label:'Public portal'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATE_KEY = 'sidebarCollapsed'; // shared across all pages
|
||||||
|
const $ = (s, root) => (root||document).querySelector(s);
|
||||||
|
|
||||||
|
function readToken(){
|
||||||
|
try { return localStorage.getItem('jwt') || localStorage.getItem('access_token') || ''; }
|
||||||
|
catch(e){ return ''; }
|
||||||
|
}
|
||||||
|
function logout(){
|
||||||
|
if(!confirm('Odjava iz aplikacije?')) return;
|
||||||
|
try { localStorage.removeItem('jwt'); localStorage.removeItem('access_token'); localStorage.removeItem('app-role'); } catch(e){}
|
||||||
|
location.href = '/sport/login';
|
||||||
|
}
|
||||||
|
function initials(n){
|
||||||
|
if(!n) return '?';
|
||||||
|
const p = String(n).trim().split(/\s+/);
|
||||||
|
return ((p[0]||'')[0]||'').toUpperCase() + ((p[1]||'')[0]||'').toUpperCase();
|
||||||
|
}
|
||||||
|
function esc(s){
|
||||||
|
return String(s==null?'':s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read /api/auth/me for footer display (best effort)
|
||||||
|
async function tryLoadMe(){
|
||||||
|
const tok = readToken(); if(!tok) return null;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/sport/api/auth/me', {headers:{'Authorization':'Bearer '+tok}});
|
||||||
|
if(!r.ok) return null;
|
||||||
|
return await r.json();
|
||||||
|
} catch(e){ return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShell(activeKey, internalNavHTML){
|
||||||
|
const sb = document.createElement('aside');
|
||||||
|
sb.id = 'pgz-sb';
|
||||||
|
sb.innerHTML = `
|
||||||
|
<div class="pgz-sb-h">
|
||||||
|
<div class="pgz-logo">PGŽ <span class="g">SPORT</span></div>
|
||||||
|
<div class="pgz-sub">Operativna platforma</div>
|
||||||
|
<div class="pgz-sb-toggle" onclick="PGZSidebar.toggle()" title="Skupi/raširi (≡)">≡</div>
|
||||||
|
<div class="pgz-sb-mx" onclick="PGZSidebar.closeMobile()" title="Zatvori">✕</div>
|
||||||
|
</div>
|
||||||
|
${internalNavHTML ? `<div class="pgz-sb-sep">Sekcije</div>` : ''}
|
||||||
|
<nav class="pgz-sb-nav" id="pgz-sb-nav">
|
||||||
|
${internalNavHTML || ''}
|
||||||
|
<div class="pgz-sb-sep" id="pgz-portal-sep">Portali</div>
|
||||||
|
<div id="pgz-portal-mount">${renderExternal(activeKey)}</div>
|
||||||
|
</nav>
|
||||||
|
<div class="pgz-sb-foot" id="pgz-sb-foot">
|
||||||
|
<div class="av" id="pgz-sb-av">PG</div>
|
||||||
|
<div class="ui">
|
||||||
|
<div class="un" id="pgz-sb-un">Gost</div>
|
||||||
|
<div class="ur" id="pgz-sb-ur">Demo</div>
|
||||||
|
</div>
|
||||||
|
<div class="lo" onclick="PGZSidebar.logout()" title="Odjava">⎋</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
function renderExternal(activeKey){
|
||||||
|
return NAV_EXTERNAL.map(n => `
|
||||||
|
<a class="pgz-nav-i pgz-nav-ext ${n.id===activeKey?'active':''}"
|
||||||
|
href="${n.href}" data-id="${n.id}" data-label="${esc(n.label)}">
|
||||||
|
<span class="ic">${n.ic}</span>
|
||||||
|
<span class="lbl">${esc(n.label)}</span>
|
||||||
|
</a>`).join('');
|
||||||
|
}
|
||||||
|
function renderBurger(){
|
||||||
|
if(document.getElementById('pgz-sb-burger')) return;
|
||||||
|
const b = document.createElement('div');
|
||||||
|
b.id = 'pgz-sb-burger';
|
||||||
|
b.className = 'pgz-sb-burger';
|
||||||
|
b.innerHTML = '≡';
|
||||||
|
b.onclick = () => PGZSidebar.openMobile();
|
||||||
|
document.body.appendChild(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserDisplay(me){
|
||||||
|
if(!me){
|
||||||
|
$('#pgz-sb-un') && ($('#pgz-sb-un').textContent = 'Gost');
|
||||||
|
$('#pgz-sb-ur') && ($('#pgz-sb-ur').textContent = 'Demo · click Prijava');
|
||||||
|
$('#pgz-sb-av') && ($('#pgz-sb-av').textContent = '?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = me.full_name || ((me.ime||'')+' '+(me.prezime||'')).trim() || me.email || '—';
|
||||||
|
const role = me.user_type || '';
|
||||||
|
const av = me.avatar_url || me.google_picture;
|
||||||
|
if($('#pgz-sb-un')) $('#pgz-sb-un').textContent = name;
|
||||||
|
if($('#pgz-sb-ur')) $('#pgz-sb-ur').textContent = role;
|
||||||
|
const avEl = $('#pgz-sb-av');
|
||||||
|
if(avEl){
|
||||||
|
if(av) avEl.innerHTML = `<img src="${esc(av)}" alt="">`;
|
||||||
|
else avEl.textContent = initials(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCollapsedFromStorage(){
|
||||||
|
let col = false;
|
||||||
|
try { col = localStorage.getItem(STATE_KEY) === '1'; } catch(e){}
|
||||||
|
const sb = document.getElementById('pgz-sb');
|
||||||
|
if(!sb) return;
|
||||||
|
sb.classList.toggle('pgz-collapsed', col);
|
||||||
|
document.body.classList.toggle('pgz-sb-col', col);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────── Public API ──────────
|
||||||
|
const PGZSidebar = {
|
||||||
|
NAV_EXTERNAL,
|
||||||
|
|
||||||
|
// Render: insert sidebar shell at document start; if a page provides internalNavHTML, use it
|
||||||
|
mount(opts){
|
||||||
|
opts = opts || {};
|
||||||
|
const activeKey = opts.activeKey || (document.currentScript && document.currentScript.dataset.active) || '';
|
||||||
|
const internalNavHTML = opts.internalNavHTML || '';
|
||||||
|
// Skip mount if the page already has its own sidebar AND a portal mount point is provided
|
||||||
|
if(opts.skipShell){
|
||||||
|
const mount = document.getElementById('pgz-portal-mount');
|
||||||
|
if(mount){ mount.innerHTML = renderExternal(activeKey); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = document.getElementById('pgz-sb');
|
||||||
|
if(existing) existing.remove();
|
||||||
|
const sb = renderShell(activeKey, internalNavHTML);
|
||||||
|
document.body.insertBefore(sb, document.body.firstChild);
|
||||||
|
document.body.classList.add('pgz-has-sb');
|
||||||
|
renderBurger();
|
||||||
|
applyCollapsedFromStorage();
|
||||||
|
tryLoadMe().then(setUserDisplay);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Append portal links to an existing custom sidebar (call this from a page's own buildNav)
|
||||||
|
appendPortalLinksTo(navEl, activeKey){
|
||||||
|
if(!navEl) return;
|
||||||
|
activeKey = activeKey || '';
|
||||||
|
navEl.insertAdjacentHTML('beforeend',
|
||||||
|
'<div class="pgz-sb-sep" style="padding:14px 14px 4px 14px;font-size:9.5px;color:var(--t4,#4e5a7a);text-transform:uppercase;letter-spacing:1.2px;font-weight:700">Portali</div>'
|
||||||
|
);
|
||||||
|
navEl.insertAdjacentHTML('beforeend', renderExternal(activeKey));
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(){
|
||||||
|
const sb = document.getElementById('pgz-sb');
|
||||||
|
if(!sb) return;
|
||||||
|
const col = sb.classList.toggle('pgz-collapsed');
|
||||||
|
document.body.classList.toggle('pgz-sb-col', col);
|
||||||
|
try { localStorage.setItem(STATE_KEY, col ? '1' : '0'); } catch(e){}
|
||||||
|
},
|
||||||
|
openMobile(){
|
||||||
|
const sb = document.getElementById('pgz-sb');
|
||||||
|
if(!sb) return;
|
||||||
|
sb.classList.add('pgz-mobile-open');
|
||||||
|
document.body.classList.add('pgz-mobile-sb-open');
|
||||||
|
// close on backdrop click
|
||||||
|
const closer = (ev) => {
|
||||||
|
if(!sb.contains(ev.target) && ev.target.id !== 'pgz-sb-burger'){
|
||||||
|
PGZSidebar.closeMobile();
|
||||||
|
document.removeEventListener('click', closer, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', closer, true), 50);
|
||||||
|
},
|
||||||
|
closeMobile(){
|
||||||
|
const sb = document.getElementById('pgz-sb');
|
||||||
|
if(!sb) return;
|
||||||
|
sb.classList.remove('pgz-mobile-open');
|
||||||
|
document.body.classList.remove('pgz-mobile-sb-open');
|
||||||
|
},
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
window.PGZSidebar = PGZSidebar;
|
||||||
|
|
||||||
|
// Auto-mount unless data-inline=1
|
||||||
|
function autoMount(){
|
||||||
|
const cs = document.currentScript || Array.from(document.scripts).find(s => /sidebar\.js/.test(s.src||''));
|
||||||
|
const inline = cs && cs.dataset && cs.dataset.inline === '1';
|
||||||
|
if(inline) return; // page will call PGZSidebar.mount() itself
|
||||||
|
if(document.readyState === 'loading'){
|
||||||
|
document.addEventListener('DOMContentLoaded', () => PGZSidebar.mount({}));
|
||||||
|
} else {
|
||||||
|
PGZSidebar.mount({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
autoMount();
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
+19
-4
@@ -264,17 +264,32 @@ def invite_user(uid: int, req: InviteReq, request: Request,
|
|||||||
meta={"email": target["email"], "note": req.note})
|
meta={"email": target["email"], "note": req.note})
|
||||||
invite_link = _build_link("/static/login.html?setup=1", raw_token)
|
invite_link = _build_link("/static/login.html?setup=1", raw_token)
|
||||||
api_link = _build_link("/api/auth/setup-password", raw_token)
|
api_link = _build_link("/api/auth/setup-password", raw_token)
|
||||||
|
# R6 #3: send invite email (mock in dev)
|
||||||
|
mail_result = None
|
||||||
|
if req.send_email:
|
||||||
|
try:
|
||||||
|
from .mailer import send_invite
|
||||||
|
mail_result = send_invite(
|
||||||
|
target["email"], invite_link,
|
||||||
|
int(INVITE_TTL.total_seconds()),
|
||||||
|
inviter=actor.get("email"),
|
||||||
|
role=target.get("user_type"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[invite mail WARN] {e}")
|
||||||
audit(actor["id"], "user.invite", "user", uid,
|
audit(actor["id"], "user.invite", "user", uid,
|
||||||
{"email": target["email"], "send_email": req.send_email,
|
{"email": target["email"], "send_email": req.send_email,
|
||||||
"ttl_days": INVITE_TTL.days}, ip, ua)
|
"ttl_days": INVITE_TTL.days,
|
||||||
# NOTE: real deployment must e-mail invite_link via a mailer (M11);
|
"mail_sent": bool(mail_result and mail_result.get("sent")),
|
||||||
# for now, the link is returned to the admin who triggered the invite.
|
"mail_mock": bool(mail_result and mail_result.get("mock"))}, ip, ua)
|
||||||
return {"status": "ok", "id": uid,
|
return {"status": "ok", "id": uid,
|
||||||
"email": target["email"],
|
"email": target["email"],
|
||||||
"invite_link": invite_link,
|
"invite_link": invite_link,
|
||||||
"api_link": api_link,
|
"api_link": api_link,
|
||||||
"expires_in_seconds": int(INVITE_TTL.total_seconds()),
|
"expires_in_seconds": int(INVITE_TTL.total_seconds()),
|
||||||
"email_sent": False}
|
"email_sent": bool(mail_result and mail_result.get("sent")),
|
||||||
|
"email_mock": bool(mail_result and mail_result.get("mock")),
|
||||||
|
"email_file": (mail_result or {}).get("file")}
|
||||||
|
|
||||||
# ─────────────────────────── Role change ───────────────────────────
|
# ─────────────────────────── Role change ───────────────────────────
|
||||||
class RoleReq(BaseModel):
|
class RoleReq(BaseModel):
|
||||||
|
|||||||
+76
-13
@@ -288,6 +288,36 @@ class ChangePwdReq(BaseModel):
|
|||||||
class ResetPwdReq(BaseModel):
|
class ResetPwdReq(BaseModel):
|
||||||
email: str
|
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 ───────────────────────────
|
# ─────────────────────────── Endpoints ───────────────────────────
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
def login(req: LoginReq, request: Request):
|
def login(req: LoginReq, request: Request):
|
||||||
@@ -296,11 +326,20 @@ def login(req: LoginReq, request: Request):
|
|||||||
if not email or not req.password:
|
if not email or not req.password:
|
||||||
raise HTTPException(400, "Email i lozinka obavezni")
|
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,
|
u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status,
|
||||||
user_type, klub_id, savez_id, aktivan, must_change_pwd,
|
user_type, klub_id, savez_id, aktivan, must_change_pwd,
|
||||||
failed_login_count, locked_until
|
failed_login_count, locked_until
|
||||||
FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,))
|
FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,))
|
||||||
if not u:
|
if not u:
|
||||||
|
_ip_record_fail(ip)
|
||||||
audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua)
|
audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua)
|
||||||
raise HTTPException(401, "Neispravni podaci")
|
raise HTTPException(401, "Neispravni podaci")
|
||||||
if u.get("locked_until"):
|
if u.get("locked_until"):
|
||||||
@@ -313,13 +352,25 @@ def login(req: LoginReq, request: Request):
|
|||||||
audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua)
|
audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua)
|
||||||
raise HTTPException(403, "Račun nije aktivan")
|
raise HTTPException(403, "Račun nije aktivan")
|
||||||
if not verify_password(req.password, u.get("password_hash")):
|
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
|
db_exec("""UPDATE pgz_sport.users
|
||||||
SET failed_login_count = COALESCE(failed_login_count,0)+1,
|
SET failed_login_count = %s,
|
||||||
locked_until = CASE WHEN COALESCE(failed_login_count,0)+1>=5
|
locked_until = CASE WHEN %s
|
||||||
THEN now()+interval '15 minutes' ELSE locked_until END
|
THEN now() + (interval '1 minute' * %s)
|
||||||
WHERE id=%s""", (u["id"],))
|
ELSE locked_until END
|
||||||
audit(u["id"], "login.fail", meta={"reason":"bad_password"}, ip=ip, ua=ua)
|
WHERE id=%s""",
|
||||||
raise HTTPException(401, "Neispravni podaci")
|
(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
|
# opportunistic rehash to bcrypt
|
||||||
if needs_rehash(u.get("password_hash")):
|
if needs_rehash(u.get("password_hash")):
|
||||||
@@ -357,6 +408,7 @@ def login(req: LoginReq, request: Request):
|
|||||||
db_exec("""UPDATE pgz_sport.users
|
db_exec("""UPDATE pgz_sport.users
|
||||||
SET failed_login_count=0, locked_until=NULL, last_login=now()
|
SET failed_login_count=0, locked_until=NULL, last_login=now()
|
||||||
WHERE id=%s""", (u["id"],))
|
WHERE id=%s""", (u["id"],))
|
||||||
|
_ip_clear(ip) # successful login clears IP throttle
|
||||||
|
|
||||||
jti = _new_jti()
|
jti = _new_jti()
|
||||||
rjti = _new_jti()
|
rjti = _new_jti()
|
||||||
@@ -620,17 +672,29 @@ class ForgotPwdReq(BaseModel):
|
|||||||
@router.post("/forgot-password")
|
@router.post("/forgot-password")
|
||||||
def forgot_password(req: ForgotPwdReq, request: Request):
|
def forgot_password(req: ForgotPwdReq, request: Request):
|
||||||
"""Always returns a generic message — never leaks which emails exist.
|
"""Always returns a generic message — never leaks which emails exist.
|
||||||
Issues a reset token only if the user exists and is active."""
|
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()
|
email = (req.email or "").lower().strip()
|
||||||
ip, ua = _client(request)
|
ip, ua = _client(request)
|
||||||
u = db_one("SELECT id, email, aktivan, status FROM pgz_sport.users WHERE LOWER(email)=%s",
|
u = db_one("SELECT id, email, aktivan, status FROM pgz_sport.users WHERE LOWER(email)=%s",
|
||||||
(email,))
|
(email,))
|
||||||
token = None
|
token = None
|
||||||
|
mail_result = None
|
||||||
if u and u.get("aktivan") and u.get("status") == "active":
|
if u and u.get("aktivan") and u.get("status") == "active":
|
||||||
token = issue_action_token(u["id"], "reset", RESET_TTL, ip=ip,
|
token = issue_action_token(u["id"], "reset", RESET_TTL, ip=ip,
|
||||||
meta={"email": email})
|
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",
|
audit(u["id"], "password.forgot.issue",
|
||||||
meta={"email": email, "ttl_hours": RESET_TTL.total_seconds()/3600},
|
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)
|
ip=ip, ua=ua)
|
||||||
else:
|
else:
|
||||||
audit(u["id"] if u else None, "password.forgot.miss",
|
audit(u["id"] if u else None, "password.forgot.miss",
|
||||||
@@ -638,14 +702,13 @@ def forgot_password(req: ForgotPwdReq, request: Request):
|
|||||||
# Generic response — do not leak account existence
|
# Generic response — do not leak account existence
|
||||||
resp = {"status": "ok",
|
resp = {"status": "ok",
|
||||||
"message": "Ako račun postoji, poslan je e-mail s linkom za promjenu lozinke."}
|
"message": "Ako račun postoji, poslan je e-mail s linkom za promjenu lozinke."}
|
||||||
# In production, e-mailer would deliver the link. For demo / dev,
|
# Reveal link only on localhost or with explicit env flag (debugging).
|
||||||
# return it only if header X-Demo-Reveal-Token is set OR caller is from
|
# Real users get it via e-mail.
|
||||||
# localhost (rare). Easier: always include it but document that real
|
|
||||||
# deployment must remove it from the response.
|
|
||||||
if token and (os.environ.get("PGZ_REVEAL_RESET_TOKEN") == "1" or
|
if token and (os.environ.get("PGZ_REVEAL_RESET_TOKEN") == "1" or
|
||||||
(request.client.host in ("127.0.0.1", "::1"))):
|
(request.client.host in ("127.0.0.1", "::1"))):
|
||||||
resp["reset_link"] = _build_link("/auth/reset-password", token)
|
resp["reset_link"] = _build_link("/static/login.html?reset=1", token)
|
||||||
resp["expires_in_seconds"] = int(RESET_TTL.total_seconds())
|
resp["expires_in_seconds"] = int(RESET_TTL.total_seconds())
|
||||||
|
resp["mail_mock"] = bool(mail_result and mail_result.get("mock"))
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
class ResetTokenReq(BaseModel):
|
class ResetTokenReq(BaseModel):
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# mailer.py — Mock e-mail sender for dev/demo (R6 #3)
|
||||||
|
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||||
|
"""
|
||||||
|
In dev/demo mode, e-mails are appended to:
|
||||||
|
- /tmp/pgz_mailbox/<unix-ts>-<recipient>.eml (raw .eml file)
|
||||||
|
- /tmp/pgz_mailbox/INDEX.jsonl (one JSON line per send)
|
||||||
|
plus printed to stdout (visible in journalctl).
|
||||||
|
|
||||||
|
Set PGZ_SMTP_HOST=... to switch to real SMTP (skipped here — out of scope
|
||||||
|
for R6 demo). The function ALWAYS returns a result; never raises.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, json, time, smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
MAILBOX_DIR = Path(os.environ.get("PGZ_MAILBOX_DIR", "/tmp/pgz_mailbox"))
|
||||||
|
MAILBOX_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
INDEX_FILE = MAILBOX_DIR / "INDEX.jsonl"
|
||||||
|
|
||||||
|
DEFAULT_FROM = os.environ.get("PGZ_MAIL_FROM", "no-reply@pgz.hr")
|
||||||
|
DEFAULT_SENDER = os.environ.get("PGZ_MAIL_SENDER", "PGŽ Sport platforma")
|
||||||
|
|
||||||
|
def send_email(to: str, subject: str, body: str,
|
||||||
|
html: Optional[str] = None,
|
||||||
|
from_addr: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict] = None) -> Dict:
|
||||||
|
"""Send (or mock-send) an email. Returns a dict with status + storage info."""
|
||||||
|
sender = from_addr or DEFAULT_FROM
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = f"{DEFAULT_SENDER} <{sender}>"
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||||
|
msg.set_content(body)
|
||||||
|
if html:
|
||||||
|
msg.add_alternative(html, subtype="html")
|
||||||
|
|
||||||
|
smtp_host = os.environ.get("PGZ_SMTP_HOST")
|
||||||
|
use_real = bool(smtp_host)
|
||||||
|
sent_at = int(time.time())
|
||||||
|
fname = f"{sent_at}-{to.replace('@','_at_')}.eml"
|
||||||
|
fpath = MAILBOX_DIR / fname
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(fpath, "wb") as f:
|
||||||
|
f.write(bytes(msg))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[MAILER WARN] cannot write {fpath}: {e}")
|
||||||
|
|
||||||
|
rec = {
|
||||||
|
"ts": sent_at,
|
||||||
|
"to": to, "from": sender, "subject": subject,
|
||||||
|
"file": str(fpath),
|
||||||
|
"real_send": use_real,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(INDEX_FILE, "a") as f:
|
||||||
|
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
if use_real:
|
||||||
|
try:
|
||||||
|
port = int(os.environ.get("PGZ_SMTP_PORT", "587"))
|
||||||
|
user = os.environ.get("PGZ_SMTP_USER")
|
||||||
|
pwd = os.environ.get("PGZ_SMTP_PASS")
|
||||||
|
with smtplib.SMTP(smtp_host, port, timeout=10) as s:
|
||||||
|
s.starttls()
|
||||||
|
if user: s.login(user, pwd or "")
|
||||||
|
s.send_message(msg)
|
||||||
|
rec["sent"] = True
|
||||||
|
except Exception as e:
|
||||||
|
rec["sent"] = False
|
||||||
|
rec["error"] = str(e)
|
||||||
|
print(f"[MAILER ERROR] {e}")
|
||||||
|
else:
|
||||||
|
# demo / dev mode — print preview to stdout
|
||||||
|
print(f"[MOCK-MAIL] → {to} | {subject}")
|
||||||
|
print(f"[MOCK-MAIL] stored at {fpath}")
|
||||||
|
rec["sent"] = True
|
||||||
|
rec["mock"] = True
|
||||||
|
|
||||||
|
return rec
|
||||||
|
|
||||||
|
# ─────────────────────────── Convenience helpers ───────────────────────────
|
||||||
|
def send_password_reset(email: str, reset_link: str, expires_in_seconds: int) -> Dict:
|
||||||
|
hours = expires_in_seconds / 3600
|
||||||
|
body = (
|
||||||
|
f"Pozdrav,\n\n"
|
||||||
|
f"Zatraženo je resetiranje lozinke za vaš račun na PGŽ Sport platformi.\n\n"
|
||||||
|
f"Otvorite ovaj link za postavljanje nove lozinke (vrijedi {hours:.0f} h):\n"
|
||||||
|
f"{reset_link}\n\n"
|
||||||
|
f"Ako niste vi tražili promjenu, ignorirajte ovu poruku.\n\n"
|
||||||
|
f"— PGŽ Sport platforma"
|
||||||
|
)
|
||||||
|
html = (
|
||||||
|
f'<p>Pozdrav,</p>'
|
||||||
|
f'<p>Zatraženo je resetiranje lozinke za vaš račun na PGŽ Sport platformi.</p>'
|
||||||
|
f'<p><a href="{reset_link}" style="background:#00f0ff;color:#000;padding:10px 20px;'
|
||||||
|
f'text-decoration:none;border-radius:6px;font-weight:600">Postavi novu lozinku</a></p>'
|
||||||
|
f'<p style="font-size:12px;color:#888">Link vrijedi {hours:.0f} h.</p>'
|
||||||
|
)
|
||||||
|
return send_email(email, "Resetiranje lozinke — PGŽ Sport", body, html=html,
|
||||||
|
metadata={"kind": "password_reset"})
|
||||||
|
|
||||||
|
def send_invite(email: str, invite_link: str, expires_in_seconds: int,
|
||||||
|
inviter: Optional[str] = None,
|
||||||
|
role: Optional[str] = None) -> Dict:
|
||||||
|
days = expires_in_seconds / 86400
|
||||||
|
by = f" od {inviter}" if inviter else ""
|
||||||
|
body = (
|
||||||
|
f"Pozdrav,\n\n"
|
||||||
|
f"Pozvani ste{by} u PGŽ Sport platformu" +
|
||||||
|
(f" kao {role}" if role else "") + ".\n\n"
|
||||||
|
f"Otvorite ovaj link i postavite svoju lozinku (vrijedi {days:.0f} dana):\n"
|
||||||
|
f"{invite_link}\n\n"
|
||||||
|
f"— PGŽ Sport platforma"
|
||||||
|
)
|
||||||
|
html = (
|
||||||
|
f'<p>Pozdrav,</p>'
|
||||||
|
f'<p>Pozvani ste{by} u PGŽ Sport platformu' +
|
||||||
|
(f' kao <strong>{role}</strong>' if role else '') + '.</p>'
|
||||||
|
f'<p><a href="{invite_link}" style="background:#00f0ff;color:#000;padding:10px 20px;'
|
||||||
|
f'text-decoration:none;border-radius:6px;font-weight:600">Postavi lozinku i prijavi se</a></p>'
|
||||||
|
f'<p style="font-size:12px;color:#888">Pozivnica vrijedi {days:.0f} dana.</p>'
|
||||||
|
)
|
||||||
|
return send_email(email, "Pozivnica — PGŽ Sport", body, html=html,
|
||||||
|
metadata={"kind": "invite", "role": role})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Data Quality Report — pgz_sport schema
|
||||||
|
**Generirano:** 2026-05-04T23:40:32.512034+00:00
|
||||||
|
**Skripta:** `/opt/pgz-sport/scripts/coverage_report.py`
|
||||||
|
**Ukupno entiteta:** 5952
|
||||||
|
|
||||||
|
## Sažetak po tipu
|
||||||
|
|
||||||
|
| Tip | n | Polja po entitetu | Srednje (%) | Median (%) | Praznih | Potpunih (≥99%) |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| savez | 246 | 10 | 59.8% | 60.0% | 0 | 24 |
|
||||||
|
| klub | 2244 | 12 | 57.1% | 66.7% | 0 | 8 |
|
||||||
|
| sportas | 3243 | 10 | 46.2% | 40.0% | 0 | 0 |
|
||||||
|
| objekt | 106 | 10 | 79.7% | 80.0% | 0 | 14 |
|
||||||
|
| manifestacija | 113 | 7 | 81.9% | 85.7% | 0 | 0 |
|
||||||
|
|
||||||
|
**Ponderirana srednja popunjenost svih 5952 zapisa:** **52.1%**
|
||||||
|
|
||||||
|
## Distribucija po tipu (postotak entiteta u svakom rasponu)
|
||||||
|
|
||||||
|
| Tip | 0-9% | 10-19% | 20-29% | 30-39% | 40-49% | 50-59% | 60-69% | 70-79% | 80-89% | 90-100% |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| savez | 0 (0.0%) | 0 (0.0%) | 21 (8.5%) | 9 (3.7%) | 19 (7.7%) | 55 (22.4%) | 42 (17.1%) | 55 (22.4%) | 16 (6.5%) | 29 (11.8%) |
|
||||||
|
| klub | 2 (0.1%) | 115 (5.1%) | 412 (18.4%) | 91 (4.1%) | 45 (2.0%) | 294 (13.1%) | 528 (23.5%) | 560 (25.0%) | 169 (7.5%) | 28 (1.2%) |
|
||||||
|
| sportas | 0 (0.0%) | 0 (0.0%) | 4 (0.1%) | 323 (10.0%) | 1462 (45.1%) | 951 (29.3%) | 297 (9.2%) | 50 (1.5%) | 144 (4.4%) | 12 (0.4%) |
|
||||||
|
| objekt | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 28 (26.4%) | 12 (11.3%) | 15 (14.2%) | 51 (48.1%) |
|
||||||
|
| manifestacija | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 0 (0.0%) | 4 (3.5%) | 0 (0.0%) | 22 (19.5%) | 87 (77.0%) | 0 (0.0%) |
|
||||||
|
|
||||||
|
## Definicija "popunjenog polja"
|
||||||
|
|
||||||
|
| Tip | Polja koja čine coverage |
|
||||||
|
|---|---|
|
||||||
|
| **savez** (10) | naziv, sport, predsjednik, tajnik, email, telefon, web, oib, adresa, godina_osnutka |
|
||||||
|
| **klub** (12) | naziv, sport, grad, oib, predsjednik, tajnik, email, telefon, web/web_stranica, sjediste/adresa, ciljevi, opis_djelatnosti |
|
||||||
|
| **sportas** (10) | ime, prezime, sport, klub_id, datum_rodenja, slika_url, oib, profile_url, biografija, hns_igrac_id |
|
||||||
|
| **objekt** (10) | naziv, tip, grad, adresa, lat, lng, upravitelj, kapacitet, sportovi, izgradeno |
|
||||||
|
| **manifestacija** (7) | naziv, mjesto, organizator, razina, broj_ucesnika, godina_od, source_url |
|
||||||
|
|
||||||
|
## TOP 50 entiteta za manualnu reviziju
|
||||||
|
Sortirano po najniže popunjenosti, zatim po veličini definicije (najviše-polja-prazno prvo).
|
||||||
|
Klikni link da otvori detaljni panel u portalu.
|
||||||
|
|
||||||
|
| # | Tip | ID | Naziv | Popunjeno | Postotak | Otvori |
|
||||||
|
|---:|---|---:|---|---:|---:|---|
|
||||||
|
| 1 | klub | 4250 | Streljački klub DVD Opatija | 1/12 | 8% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4250) |
|
||||||
|
| 2 | klub | 4249 | Streljački klub DVD svojevrstan vodič za roditelje | 1/12 | 8% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4249) |
|
||||||
|
| 3 | klub | 4530 | MOK RIJEKA II | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4530) |
|
||||||
|
| 4 | klub | 4532 | MOK RIJEKA III | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4532) |
|
||||||
|
| 5 | klub | 4531 | OK KASTAV 1998 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4531) |
|
||||||
|
| 6 | klub | 3758 | BK Podhum | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3758) |
|
||||||
|
| 7 | klub | 2290 | KK Metal - Jurdani | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2290) |
|
||||||
|
| 8 | klub | 2315 | RK PŠR SELCE 5. u III HRL Zapad od 8 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2315) |
|
||||||
|
| 9 | klub | 2356 | ŽRK MURVICA 6. u II HRL Zapad od 9 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2356) |
|
||||||
|
| 10 | klub | 2360 | ŽRK ZAMET II 3. u III HRL Zapad od 8 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2360) |
|
||||||
|
| 11 | klub | 3898 | VK Primorjem [MERGED→3896] | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3898) |
|
||||||
|
| 12 | klub | 2311 | RK LIBURNIJA 8. u II HRL Zapad od 12 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2311) |
|
||||||
|
| 13 | klub | 2312 | RK MORNAR 3. u II HRL Zapad od 10 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2312) |
|
||||||
|
| 14 | klub | 2324 | RK ČAVLE 2. u II HRL Zapad od 10 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2324) |
|
||||||
|
| 15 | klub | 2325 | RK ČAVLE 7. u III HRL Zapad od 8 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2325) |
|
||||||
|
| 16 | klub | 2331 | SK IJANJE | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2331) |
|
||||||
|
| 17 | klub | 2332 | SK IJAŠKO ROLKANJE | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2332) |
|
||||||
|
| 18 | klub | 2333 | SK RAD | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2333) |
|
||||||
|
| 19 | klub | 2355 | ŽRK MURVICA 6. u II HRL Zapad od 12 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2355) |
|
||||||
|
| 20 | klub | 3749 | AK Velenje | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3749) |
|
||||||
|
| 21 | klub | 2291 | KK OI KOSTRENA | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2291) |
|
||||||
|
| 22 | klub | 3797 | JK Špinut | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3797) |
|
||||||
|
| 23 | klub | 3890 | VK Lošinj | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3890) |
|
||||||
|
| 24 | klub | 4533 | HNK Goranin | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4533) |
|
||||||
|
| 25 | klub | 3899 | VK Šilo | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3899) |
|
||||||
|
| 26 | klub | 3741 | AK Elena Ban | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3741) |
|
||||||
|
| 27 | klub | 2321 | RK ZAMET 10. u Premijer ligi od 16 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2321) |
|
||||||
|
| 28 | klub | 2322 | RK ZAMET II 6. u II HRL Zapad od 10 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2322) |
|
||||||
|
| 29 | klub | 3744 | AK Koper | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3744) |
|
||||||
|
| 30 | klub | 3748 | AK Rijeka | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3748) |
|
||||||
|
| 31 | klub | 3761 | BK SVETA JELENA | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3761) |
|
||||||
|
| 32 | klub | 3753 | BK Vjekoslav Mance | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3753) |
|
||||||
|
| 33 | klub | 3754 | BK BROD MORAVICE | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3754) |
|
||||||
|
| 34 | klub | 2352 | ŠK Volosko - Volosko | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/2352) |
|
||||||
|
| 35 | klub | 3763 | BK Sivke Postojna | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3763) |
|
||||||
|
| 36 | klub | 3764 | BK Zameta | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3764) |
|
||||||
|
| 37 | klub | 3750 | AK Viškovo | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3750) |
|
||||||
|
| 38 | klub | 3747 | AK Kvarnera | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3747) |
|
||||||
|
| 39 | klub | 3765 | BK Čavle | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3765) |
|
||||||
|
| 40 | klub | 3917 | BK Boćari | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3917) |
|
||||||
|
| 41 | klub | 3918 | BK Sivke | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3918) |
|
||||||
|
| 42 | klub | 3759 | BK Predator | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3759) |
|
||||||
|
| 43 | klub | 3795 | JK Vega | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3795) |
|
||||||
|
| 44 | klub | 3780 | JK Neverin | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3780) |
|
||||||
|
| 45 | klub | 3792 | JK Trogir | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3792) |
|
||||||
|
| 46 | klub | 3919 | JK Labud | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3919) |
|
||||||
|
| 47 | klub | 3794 | JK Val | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3794) |
|
||||||
|
| 48 | klub | 3790 | JK Split | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3790) |
|
||||||
|
| 49 | klub | 3784 | JK Optimist | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/3784) |
|
||||||
|
| 50 | klub | 4426 | [UNRESOLVED] empty naziv & grad — id 4426 | 2/12 | 17% | [↗](https://sport.rinet.one/static/sport2.html#klubovi/4426) |
|
||||||
|
|
||||||
|
## Akcije za poboljšanje coverage-a
|
||||||
|
|
||||||
|
1. **Pokreni "Obogati podatke" na top 50 zapisa** — `POST /sport/api/v2/enrich/{kind}/{id}/apply` već puni nedostajuće web/email/telefon/opis polja iz CSE+Wikipedia+sport-pgz.hr.
|
||||||
|
2. **CC6 enrichment loop** trenutno targetira coverage<70 i confidence>=0.7 — proširiti na coverage<80 nakon QA prolaza.
|
||||||
|
3. **HNS/HOS scraper** za sportaše: hns_igrac_id i slika_url stvaraju 70% coverage skoka za nogometaše. CC2-tip rader na ovome.
|
||||||
|
4. **OIB validacija** preko sudreg API-ja — popraviti `naziv`/`adresa` paralelno (vidi `data_cleanup_report.md`).
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# erp/notifications.py — PGŽ Sport ERP mock e-mail notifikacije (R6)
|
||||||
|
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
|
||||||
|
# Date: 2026-05-04
|
||||||
|
# Description: Mock e-mail / channel='email' notifikacije pri promjeni statusa
|
||||||
|
# ERP entiteta. Upisuje u pgz_sport.notifications + log line.
|
||||||
|
# U produkciji se može zamijeniti pravim SMTP/Mailgun adapterom.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Iterable
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
|
||||||
|
password="R1net2026!SecureDB#v7")
|
||||||
|
|
||||||
|
LOG_PATH = os.environ.get("ERP_NOTIFY_LOG", "/var/log/pgz-sport-erp-notify.log")
|
||||||
|
logger = logging.getLogger("erp.notifications")
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
try:
|
||||||
|
fh = logging.FileHandler(LOG_PATH)
|
||||||
|
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||||
|
logger.addHandler(fh)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sh = logging.StreamHandler()
|
||||||
|
sh.setFormatter(logging.Formatter("[ERP-NOTIFY] %(message)s"))
|
||||||
|
logger.addHandler(sh)
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
c = psycopg2.connect(**DB); c.autocommit = True; return c
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_recipients(klub_id: Optional[int], user_id: Optional[int]) -> list[dict]:
|
||||||
|
"""Vrati listu primatelja: voditelj putovanja (user_id), klub_admin svog kluba,
|
||||||
|
+ pgz_admin kao info kopija."""
|
||||||
|
out: list[dict] = []
|
||||||
|
seen = set()
|
||||||
|
try:
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
if user_id:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name "
|
||||||
|
"FROM pgz_sport.users WHERE id=%s AND status='active'", (user_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if r and r["email"] and r["id"] not in seen:
|
||||||
|
out.append({**r, "rola": "voditelj"}); seen.add(r["id"])
|
||||||
|
if klub_id:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name
|
||||||
|
FROM pgz_sport.users
|
||||||
|
WHERE klub_id=%s AND user_type='klub_admin' AND status='active'""",
|
||||||
|
(klub_id,))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
if r["email"] and r["id"] not in seen:
|
||||||
|
out.append({**r, "rola": "klub_admin"}); seen.add(r["id"])
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name
|
||||||
|
FROM pgz_sport.users
|
||||||
|
WHERE user_type='pgz_admin' AND status='active' LIMIT 5""")
|
||||||
|
for r in cur.fetchall():
|
||||||
|
if r["email"] and r["id"] not in seen:
|
||||||
|
out.append({**r, "rola": "pgz_admin"}); seen.add(r["id"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("recipients fetch fail: %s", e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _store(user_id: Optional[int], subject: str, body: str, meta: dict,
|
||||||
|
channel: str = "email", status: str = "queued") -> Optional[int]:
|
||||||
|
try:
|
||||||
|
with _db() as c:
|
||||||
|
cur = c.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO pgz_sport.notifications
|
||||||
|
(user_id, channel, subject, body, status, scheduled_at, meta)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,NOW(),%s)
|
||||||
|
RETURNING id""",
|
||||||
|
(user_id, channel, subject[:200], body[:5000], status,
|
||||||
|
json.dumps(meta, ensure_ascii=False, default=str)),
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("notification insert fail: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(subject: str, body: str, *, klub_id: Optional[int] = None,
|
||||||
|
user_id: Optional[int] = None, meta: Optional[dict] = None) -> dict:
|
||||||
|
meta = dict(meta or {})
|
||||||
|
recipients = _resolve_recipients(klub_id, user_id)
|
||||||
|
delivered = []
|
||||||
|
if not recipients:
|
||||||
|
# Mock: nemamo korisnika, samo log + jedan info zapis bez user_id
|
||||||
|
nid = _store(None, subject, body,
|
||||||
|
{**meta, "to": "(no_recipient)", "klub_id": klub_id})
|
||||||
|
logger.info("MOCK email (no recipient) [%s] %s", nid, subject)
|
||||||
|
return {"sent": 0, "queued": 1 if nid else 0, "ids": [nid] if nid else [],
|
||||||
|
"recipients": []}
|
||||||
|
for r in recipients:
|
||||||
|
nid = _store(r["id"], subject, body,
|
||||||
|
{**meta, "to": r["email"], "rola": r.get("rola"),
|
||||||
|
"name": r.get("name")})
|
||||||
|
if nid:
|
||||||
|
delivered.append({"id": nid, "user_id": r["id"], "email": r["email"]})
|
||||||
|
logger.info(
|
||||||
|
"MOCK email queued [%s] to=%s rola=%s subj=%r",
|
||||||
|
nid, r["email"], r.get("rola"), subject,
|
||||||
|
)
|
||||||
|
return {"sent": 0, "queued": len(delivered), "ids": [d["id"] for d in delivered],
|
||||||
|
"recipients": [d["email"] for d in delivered]}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Public helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def notify_invoice_created(invoice: dict) -> dict:
|
||||||
|
"""Račun spremljen iz OCR-a — info klub_admin."""
|
||||||
|
subj = f"Novi račun #{invoice.get('id')}: {invoice.get('vendor_name','')} (€{invoice.get('amount_gross')})"
|
||||||
|
body = (
|
||||||
|
f"Račun {invoice.get('invoice_no')} od {invoice.get('vendor_name')} "
|
||||||
|
f"(OIB {invoice.get('vendor_oib')}) iznosa €{invoice.get('amount_gross')} "
|
||||||
|
f"na datum {invoice.get('invoice_date')} unesen je u sustav.\n\n"
|
||||||
|
f"Klub: {invoice.get('klub_id')} · Vrsta: {invoice.get('invoice_kind')} · Status: {invoice.get('payment_status')}"
|
||||||
|
)
|
||||||
|
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
|
||||||
|
meta={"event": "invoice_created", "invoice_id": invoice.get("id")})
|
||||||
|
|
||||||
|
|
||||||
|
def notify_invoice_paid(invoice: dict, payment: Optional[dict] = None) -> dict:
|
||||||
|
iban = (payment or {}).get("iban_to") or invoice.get("iban_to") or "—"
|
||||||
|
subj = f"Račun #{invoice.get('id')} označen kao plaćen — €{invoice.get('amount_gross')}"
|
||||||
|
body = (
|
||||||
|
f"Račun {invoice.get('invoice_no')} izdan od {invoice.get('vendor_name')} "
|
||||||
|
f"je označen kao plaćen.\n"
|
||||||
|
f"Iznos: €{invoice.get('amount_gross')}\n"
|
||||||
|
f"Datum uplate: {invoice.get('paid_date')}\n"
|
||||||
|
f"IBAN primatelja: {iban}\n"
|
||||||
|
f"Referenca: {(payment or {}).get('reference','—')}"
|
||||||
|
)
|
||||||
|
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
|
||||||
|
meta={"event": "invoice_paid", "invoice_id": invoice.get("id")})
|
||||||
|
|
||||||
|
|
||||||
|
def notify_invoice_cancelled(invoice: dict, razlog: str = "") -> dict:
|
||||||
|
subj = f"Račun #{invoice.get('id')} otkazan"
|
||||||
|
body = f"Račun {invoice.get('invoice_no')} ({invoice.get('vendor_name')}) je otkazan.\nRazlog: {razlog or '—'}"
|
||||||
|
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
|
||||||
|
meta={"event": "invoice_cancelled", "invoice_id": invoice.get("id"),
|
||||||
|
"razlog": razlog})
|
||||||
|
|
||||||
|
|
||||||
|
def notify_pn_submitted(pn: dict) -> dict:
|
||||||
|
subj = f"Putni nalog #{pn.get('id')} poslan na odobrenje (€{pn.get('cost_total')})"
|
||||||
|
body = (
|
||||||
|
f"Putni nalog za destinaciju '{pn.get('destination')}' "
|
||||||
|
f"({pn.get('date_from')} – {pn.get('date_to')}) "
|
||||||
|
f"poslan je na odobrenje.\nIznos: €{pn.get('cost_total')}"
|
||||||
|
)
|
||||||
|
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
|
||||||
|
meta={"event": "pn_submitted", "pn_id": pn.get("id")})
|
||||||
|
|
||||||
|
|
||||||
|
def notify_pn_approved(pn: dict) -> dict:
|
||||||
|
subj = f"Putni nalog #{pn.get('id')} ODOBREN — €{pn.get('cost_total')}"
|
||||||
|
body = (
|
||||||
|
f"Putni nalog za '{pn.get('destination')}' "
|
||||||
|
f"({pn.get('date_from')} – {pn.get('date_to')}) je odobren.\n"
|
||||||
|
f"Iznos: €{pn.get('cost_total')}"
|
||||||
|
)
|
||||||
|
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
|
||||||
|
meta={"event": "pn_approved", "pn_id": pn.get("id")})
|
||||||
|
|
||||||
|
|
||||||
|
def notify_pn_rejected(pn: dict, razlog: str = "") -> dict:
|
||||||
|
subj = f"Putni nalog #{pn.get('id')} ODBIJEN"
|
||||||
|
body = f"Putni nalog za '{pn.get('destination')}' je odbijen.\nRazlog: {razlog or '—'}"
|
||||||
|
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
|
||||||
|
meta={"event": "pn_rejected", "pn_id": pn.get("id"), "razlog": razlog})
|
||||||
|
|
||||||
|
|
||||||
|
def notify_pn_paid(pn: dict, payment: Optional[dict] = None) -> dict:
|
||||||
|
iban = (payment or {}).get("iban_to") or "—"
|
||||||
|
subj = f"Putni nalog #{pn.get('id')} ISPLAĆEN — €{pn.get('cost_total')}"
|
||||||
|
body = (
|
||||||
|
f"Putni nalog za '{pn.get('destination')}' isplaćen je voditelju.\n"
|
||||||
|
f"Iznos: €{(payment or {}).get('amount') or pn.get('cost_total')}\n"
|
||||||
|
f"IBAN primatelja: {iban}\n"
|
||||||
|
f"Datum isplate: {pn.get('paid_at') or (payment or {}).get('payment_date')}\n"
|
||||||
|
f"Referenca: {(payment or {}).get('reference','—')}"
|
||||||
|
)
|
||||||
|
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
|
||||||
|
meta={"event": "pn_paid", "pn_id": pn.get("id")})
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"notify_invoice_created", "notify_invoice_paid", "notify_invoice_cancelled",
|
||||||
|
"notify_pn_submitted", "notify_pn_approved", "notify_pn_rejected", "notify_pn_paid",
|
||||||
|
]
|
||||||
+53
-2
@@ -45,6 +45,15 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
_auth_user = None
|
_auth_user = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from erp.notifications import (
|
||||||
|
notify_invoice_created, notify_invoice_paid, notify_invoice_cancelled,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
def notify_invoice_created(*a, **k): return {}
|
||||||
|
def notify_invoice_paid(*a, **k): return {}
|
||||||
|
def notify_invoice_cancelled(*a, **k): return {}
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
|
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
|
||||||
|
|
||||||
# === Config ===
|
# === Config ===
|
||||||
@@ -324,6 +333,11 @@ async def ocr_upload(
|
|||||||
authorization: Optional[str] = Header(None),
|
authorization: Optional[str] = Header(None),
|
||||||
):
|
):
|
||||||
"""Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads."""
|
"""Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads."""
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
# Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub (ako je naveden)
|
||||||
|
if user and not is_pgz_admin(user):
|
||||||
|
if klub_id and user.get("klub_id") != klub_id:
|
||||||
|
raise HTTPException(403, "Nemate ovlasti uploadati za ovaj klub")
|
||||||
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
|
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
|
||||||
if suffix not in ALLOWED_EXT:
|
if suffix not in ALLOWED_EXT:
|
||||||
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}")
|
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}")
|
||||||
@@ -354,6 +368,18 @@ async def ocr_upload(
|
|||||||
sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})),
|
sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
# Audit log za OCR upload
|
||||||
|
try:
|
||||||
|
with _db() as c:
|
||||||
|
c.cursor().execute(
|
||||||
|
"""INSERT INTO pgz_sport.audit_log
|
||||||
|
(tablica, operacija, record_id, korisnik, promijenjeno_polje, nova_vrijednost)
|
||||||
|
VALUES ('pgz_sport.invoice_uploads','create',%s,%s,'file_name',%s)""",
|
||||||
|
(row["id"], (user.get("email") if user else "anon"),
|
||||||
|
f"{file.filename} ({len(raw)} B, sha={sha256[:12]})"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"],
|
return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"],
|
||||||
"size": len(raw), "sha256": sha256, "status": row["ocr_status"]}
|
"size": len(raw), "sha256": sha256, "status": row["ocr_status"]}
|
||||||
|
|
||||||
@@ -646,11 +672,17 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade
|
|||||||
if body.get(k) in (None, ""):
|
if body.get(k) in (None, ""):
|
||||||
raise HTTPException(400, f"Nedostaje polje: {k}")
|
raise HTTPException(400, f"Nedostaje polje: {k}")
|
||||||
|
|
||||||
|
user = _resolve_user(authorization)
|
||||||
klub_id = body.get("klub_id")
|
klub_id = body.get("klub_id")
|
||||||
tenant_id = body.get("tenant_id", 1)
|
tenant_id = body.get("tenant_id", 1)
|
||||||
upload_id = body.get("upload_id")
|
upload_id = body.get("upload_id")
|
||||||
lines = body.get("lines") or []
|
lines = body.get("lines") or []
|
||||||
|
|
||||||
|
# Permission: pgz_admin uvijek; klub_admin samo za vlastiti klub
|
||||||
|
if user and not is_pgz_admin(user):
|
||||||
|
if not (user.get("user_type") == "klub_admin" and klub_id == user.get("klub_id")):
|
||||||
|
raise HTTPException(403, "Nemate ovlasti kreirati račun za ovaj klub")
|
||||||
|
|
||||||
with _db() as c:
|
with _db() as c:
|
||||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -715,7 +747,10 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade
|
|||||||
(inv_id, upload_id),
|
(inv_id, upload_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"ok": True, "invoice": inv}
|
audit_invoice(user, inv_id, "create", field="invoice_no",
|
||||||
|
new=f"{body.get('invoice_no')} €{body.get('amount_gross')}")
|
||||||
|
notif = notify_invoice_created({**body, "id": inv_id, "klub_id": klub_id})
|
||||||
|
return {"ok": True, "invoice": inv, "notification": notif}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/invoices/{invoice_id}")
|
@router.put("/invoices/{invoice_id}")
|
||||||
@@ -813,7 +848,13 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}),
|
|||||||
pay = cur.fetchone()
|
pay = cur.fetchone()
|
||||||
audit_invoice(user, invoice_id, "pay", field="payment_status",
|
audit_invoice(user, invoice_id, "pay", field="payment_status",
|
||||||
old=inv.get("payment_status"), new="paid")
|
old=inv.get("payment_status"), new="paid")
|
||||||
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
|
notif = notify_invoice_paid(
|
||||||
|
{**inv, **(row or {}), "id": invoice_id},
|
||||||
|
{"iban_to": iban_to, "iban_from": iban_from, "reference": reference,
|
||||||
|
"payment_date": paid_date, "amount": amount},
|
||||||
|
)
|
||||||
|
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None,
|
||||||
|
"notification": notif}
|
||||||
|
|
||||||
|
|
||||||
# ── R5.3 BULK OPERATIONS ──────────────────────────────────────────────
|
# ── R5.3 BULK OPERATIONS ──────────────────────────────────────────────
|
||||||
@@ -868,6 +909,14 @@ def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Hea
|
|||||||
)
|
)
|
||||||
audit_invoice(user, inv["id"], "bulk_pay",
|
audit_invoice(user, inv["id"], "bulk_pay",
|
||||||
field="payment_status", old=inv.get("payment_status"), new="paid")
|
field="payment_status", old=inv.get("payment_status"), new="paid")
|
||||||
|
try:
|
||||||
|
notify_invoice_paid(
|
||||||
|
{**inv, "paid_date": paid_date},
|
||||||
|
{"iban_to": iban_to, "iban_from": iban_from, "reference": reference,
|
||||||
|
"payment_date": paid_date, "amount": inv.get("amount_gross")},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
results["paid"].append(inv["id"])
|
results["paid"].append(inv["id"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
|
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
|
||||||
@@ -907,6 +956,8 @@ def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] =
|
|||||||
audit_invoice(user, inv["id"], "bulk_cancel",
|
audit_invoice(user, inv["id"], "bulk_cancel",
|
||||||
field="payment_status", old=inv.get("payment_status"),
|
field="payment_status", old=inv.get("payment_status"),
|
||||||
new=f"cancelled: {razlog}")
|
new=f"cancelled: {razlog}")
|
||||||
|
try: notify_invoice_cancelled(inv, razlog)
|
||||||
|
except Exception: pass
|
||||||
results["cancelled"].append(inv["id"])
|
results["cancelled"].append(inv["id"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
|
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
|
||||||
|
|||||||
+52
-4
@@ -36,6 +36,16 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
_auth_user = None
|
_auth_user = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from erp.notifications import (
|
||||||
|
notify_pn_submitted, notify_pn_approved, notify_pn_rejected, notify_pn_paid,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
def notify_pn_submitted(*a, **k): return {}
|
||||||
|
def notify_pn_approved(*a, **k): return {}
|
||||||
|
def notify_pn_rejected(*a, **k): return {}
|
||||||
|
def notify_pn_paid(*a, **k): return {}
|
||||||
|
|
||||||
ADMIN_TOKEN = "admin-pgz-2026"
|
ADMIN_TOKEN = "admin-pgz-2026"
|
||||||
|
|
||||||
def _resolve_user(authorization):
|
def _resolve_user(authorization):
|
||||||
@@ -361,7 +371,8 @@ def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(Non
|
|||||||
WHERE id=%s RETURNING id, status""", (nalog_id,))
|
WHERE id=%s RETURNING id, status""", (nalog_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan")
|
audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan")
|
||||||
return {"ok": True, "putni_nalog": row}
|
notif = notify_pn_submitted({**pn, "status": "poslan"})
|
||||||
|
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/putni-nalog/{nalog_id}/odbij")
|
@router.post("/putni-nalog/{nalog_id}/odbij")
|
||||||
@@ -389,7 +400,8 @@ def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
audit_putni(user, nalog_id, "reject", field="status",
|
audit_putni(user, nalog_id, "reject", field="status",
|
||||||
old=pn.get("status"), new=f"odbijen: {razlog}")
|
old=pn.get("status"), new=f"odbijen: {razlog}")
|
||||||
return {"ok": True, "putni_nalog": row}
|
notif = notify_pn_rejected({**pn, "status": "odbijen"}, razlog=razlog)
|
||||||
|
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/putni-nalog/{nalog_id}/isplati")
|
@router.post("/putni-nalog/{nalog_id}/isplati")
|
||||||
@@ -437,7 +449,13 @@ def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
|||||||
pay = cur.fetchone()
|
pay = cur.fetchone()
|
||||||
audit_putni(user, nalog_id, "pay", field="status",
|
audit_putni(user, nalog_id, "pay", field="status",
|
||||||
old=pn.get("status"), new="isplacen")
|
old=pn.get("status"), new="isplacen")
|
||||||
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
|
notif = notify_pn_paid(
|
||||||
|
{**pn, **(row or {}), "id": nalog_id},
|
||||||
|
{"iban_to": iban_to, "iban_from": iban_from, "amount": amount,
|
||||||
|
"reference": reference, "payment_date": paid_date},
|
||||||
|
)
|
||||||
|
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None,
|
||||||
|
"notification": notif}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
|
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
|
||||||
@@ -526,6 +544,12 @@ def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = He
|
|||||||
if not klub_id:
|
if not klub_id:
|
||||||
raise HTTPException(400, "klub_id je obavezan")
|
raise HTTPException(400, "klub_id je obavezan")
|
||||||
|
|
||||||
|
user = _resolve_user(authorization)
|
||||||
|
# Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub
|
||||||
|
if user and not is_pgz_admin(user):
|
||||||
|
if user.get("user_type") not in ("klub_admin", "klub_user") or user.get("klub_id") != klub_id:
|
||||||
|
raise HTTPException(403, "Nemate ovlasti kreirati putni nalog za ovaj klub")
|
||||||
|
|
||||||
country = body.get("country", "Hrvatska")
|
country = body.get("country", "Hrvatska")
|
||||||
km = body.get("km_driven", body.get("kilometara", 0)) or 0
|
km = body.get("km_driven", body.get("kilometara", 0)) or 0
|
||||||
km_rate = body.get("km_rate") or KM_RATE_DEFAULT
|
km_rate = body.get("km_rate") or KM_RATE_DEFAULT
|
||||||
@@ -593,6 +617,8 @@ def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = He
|
|||||||
ct = cur.fetchone()
|
ct = cur.fetchone()
|
||||||
if ct:
|
if ct:
|
||||||
row["cost_total"] = ct["cost_total"]
|
row["cost_total"] = ct["cost_total"]
|
||||||
|
audit_putni(user, row["id"], "create", field="status",
|
||||||
|
new=f"draft (€{row.get('cost_total')})")
|
||||||
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
|
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
|
||||||
|
|
||||||
|
|
||||||
@@ -647,6 +673,8 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
|||||||
authorization: Optional[str] = Header(None)):
|
authorization: Optional[str] = Header(None)):
|
||||||
user = _resolve_user(authorization)
|
user = _resolve_user(authorization)
|
||||||
approved_by = body.get("approved_by") or (user.get("id") if user else None)
|
approved_by = body.get("approved_by") or (user.get("id") if user else None)
|
||||||
|
if approved_by == 0 or (user and user.get("_synthetic")):
|
||||||
|
approved_by = None # admin token nema realnog user_id u DB
|
||||||
with _db() as c:
|
with _db() as c:
|
||||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
||||||
@@ -666,7 +694,27 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
audit_putni(user, nalog_id, "approve", field="status",
|
audit_putni(user, nalog_id, "approve", field="status",
|
||||||
old=pn.get("status"), new="odobren")
|
old=pn.get("status"), new="odobren")
|
||||||
return {"ok": True, "putni_nalog": row}
|
notif = notify_pn_approved({**pn, "status": "odobren"})
|
||||||
|
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||||
|
|
||||||
|
|
||||||
|
# R6.2 — PUT alias za simetriju s briefom
|
||||||
|
@router.put("/putni-nalog/{nalog_id}/odobri")
|
||||||
|
def odobri_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
return odobriti_putni_nalog(nalog_id, body, authorization)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/putni-nalog/{nalog_id}/odbij")
|
||||||
|
def odbij_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
return odbij_putni_nalog(nalog_id, body, authorization)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/putni-nalog/{nalog_id}/isplati")
|
||||||
|
def isplati_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||||
|
authorization: Optional[str] = Header(None)):
|
||||||
|
return isplati_putni_nalog(nalog_id, body, authorization)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/putni-nalog/{nalog_id}/zatvori")
|
@router.post("/putni-nalog/{nalog_id}/zatvori")
|
||||||
|
|||||||
+50
-31
@@ -76,37 +76,55 @@ def apply_privacy(rows, admin):
|
|||||||
app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0")
|
app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
# ─── R5 #1: Defense-in-depth JWT enforcement on /api/admin/* ───
|
# ─── R5 #1 + R6 #1: Defense-in-depth JWT enforcement ───
|
||||||
# Even if a route accidentally lacks `Depends(require_user)`, this middleware
|
# Mutating requests (POST/PUT/PATCH/DELETE) under /api/* require a valid
|
||||||
# rejects requests with no/invalid Bearer token before they reach the handler.
|
# Bearer JWT, except for explicitly-public auth & consent endpoints.
|
||||||
|
# All /api/admin/* requests (any method) also require auth.
|
||||||
|
_PUBLIC_MUTATING_PATHS = {
|
||||||
|
"/api/auth/login", "/api/auth/refresh", "/api/auth/forgot-password",
|
||||||
|
"/api/auth/password/reset", "/api/auth/reset-password",
|
||||||
|
"/api/auth/setup-password", "/api/auth/google",
|
||||||
|
"/api/gdpr/consent",
|
||||||
|
}
|
||||||
|
_PUBLIC_MUTATING_SUFFIXES = (
|
||||||
|
"/avatar", # /api/crm/clanovi/{id}/avatar — demo mode handled in handler
|
||||||
|
)
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def require_jwt_on_admin(request, call_next):
|
async def require_jwt_middleware(request, call_next):
|
||||||
p = request.url.path
|
p = request.url.path
|
||||||
# Only gate admin endpoints — leave /api/auth/*, public /api/v2/* etc. alone
|
method = request.method.upper()
|
||||||
if p.startswith("/api/admin/") or p == "/api/admin":
|
if method == "OPTIONS":
|
||||||
# OPTIONS preflight passes through
|
return await call_next(request)
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return await call_next(request)
|
admin_gate = p.startswith("/api/admin/") or p == "/api/admin"
|
||||||
try:
|
mutating = method in ("POST", "PUT", "PATCH", "DELETE") and p.startswith("/api/")
|
||||||
from auth.auth_v2 import decode_token, _is_revoked
|
if mutating and (p in _PUBLIC_MUTATING_PATHS or
|
||||||
auth = request.headers.get("authorization", "")
|
any(p.endswith(s) for s in _PUBLIC_MUTATING_SUFFIXES)):
|
||||||
if not auth.lower().startswith("bearer "):
|
mutating = False
|
||||||
from starlette.responses import JSONResponse as _JR
|
|
||||||
return _JR({"detail": "Authentication required"}, status_code=401)
|
if not (admin_gate or mutating):
|
||||||
token = auth.split(" ", 1)[1].strip()
|
return await call_next(request)
|
||||||
try:
|
|
||||||
payload = decode_token(token)
|
try:
|
||||||
except Exception:
|
from auth.auth_v2 import decode_token, _is_revoked
|
||||||
from starlette.responses import JSONResponse as _JR
|
except Exception as e:
|
||||||
return _JR({"detail": "Invalid or expired token"}, status_code=401)
|
print(f"[JWT-MW import WARN] {e}")
|
||||||
if payload.get("typ") not in (None, "access"):
|
return await call_next(request)
|
||||||
from starlette.responses import JSONResponse as _JR
|
|
||||||
return _JR({"detail": "Wrong token type"}, status_code=401)
|
from starlette.responses import JSONResponse as _JR
|
||||||
if _is_revoked(payload.get("jti", "")):
|
auth_h = request.headers.get("authorization", "")
|
||||||
from starlette.responses import JSONResponse as _JR
|
if not auth_h.lower().startswith("bearer "):
|
||||||
return _JR({"detail": "Token revoked"}, status_code=401)
|
return _JR({"detail": "Authentication required"}, status_code=401)
|
||||||
except Exception as e:
|
token = auth_h.split(" ", 1)[1].strip()
|
||||||
print(f"[JWT-MW WARN] {e}")
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
except Exception:
|
||||||
|
return _JR({"detail": "Invalid or expired token"}, status_code=401)
|
||||||
|
if payload.get("typ") not in (None, "access"):
|
||||||
|
return _JR({"detail": "Wrong token type"}, status_code=401)
|
||||||
|
if _is_revoked(payload.get("jti", "")):
|
||||||
|
return _JR({"detail": "Token revoked"}, status_code=401)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -1395,9 +1413,10 @@ except Exception as e:
|
|||||||
print(f'[CRM/PANEL] clan_panel router fail: {e}')
|
print(f'[CRM/PANEL] clan_panel router fail: {e}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from crm_extras_router import router as crm_extras_router
|
from crm_extras_router import router as crm_extras_router, alias_router as crm_extras_alias_router
|
||||||
app.include_router(crm_extras_router)
|
app.include_router(crm_extras_router)
|
||||||
print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications)')
|
app.include_router(crm_extras_alias_router)
|
||||||
|
print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications + ZIP + email tpl + /me)')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[CRM/R5] extras router fail: {e}')
|
print(f'[CRM/R5] extras router fail: {e}')
|
||||||
|
|
||||||
|
|||||||
@@ -442,14 +442,10 @@ def update_clan(cid: int, patch: ClanPatch,
|
|||||||
async def upload_avatar(cid: int, file: UploadFile = File(...),
|
async def upload_avatar(cid: int, file: UploadFile = File(...),
|
||||||
authorization: Optional[str] = Header(None),
|
authorization: Optional[str] = Header(None),
|
||||||
x_role: Optional[str] = Header(None)):
|
x_role: Optional[str] = Header(None)):
|
||||||
role = (x_role or _resolve_role(authorization) or "viewer").lower()
|
"""Upload avatar. R6 #2 demo mode: if there is no/invalid token,
|
||||||
if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"):
|
accept upload but DO NOT persist (FS or DB) — return demo flag + mock URL.
|
||||||
# sportas/klub_admin/savez_admin/pgz_admin/super_admin svi smiju
|
Real save (FS + DB) requires a valid Bearer JWT for an authorized role."""
|
||||||
# (sportas ako je 'sebe' — UI to validira preko user_id, ovdje server
|
# validate file type early — applies to both demo and real
|
||||||
# primarno gata po roli; future M1 JWT propagacija će validirati clan_id == self)
|
|
||||||
raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara")
|
|
||||||
|
|
||||||
# validate file type
|
|
||||||
allowed_ct = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
allowed_ct = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||||
ext_map = {"image/jpeg": "jpg", "image/png": "png",
|
ext_map = {"image/jpeg": "jpg", "image/png": "png",
|
||||||
"image/webp": "webp", "image/gif": "gif"}
|
"image/webp": "webp", "image/gif": "gif"}
|
||||||
@@ -457,6 +453,47 @@ async def upload_avatar(cid: int, file: UploadFile = File(...),
|
|||||||
if ct not in allowed_ct:
|
if ct not in allowed_ct:
|
||||||
raise HTTPException(400, f"Nedozvoljeni tip slike: {ct}. Dozvoljeno: jpeg/png/webp/gif")
|
raise HTTPException(400, f"Nedozvoljeni tip slike: {ct}. Dozvoljeno: jpeg/png/webp/gif")
|
||||||
|
|
||||||
|
contents = await file.read()
|
||||||
|
if len(contents) > 5 * 1024 * 1024:
|
||||||
|
raise HTTPException(413, "Slika prevelika (max 5 MB)")
|
||||||
|
|
||||||
|
# Try to resolve role from JWT (via auth_v2 — proper secret + revocation check)
|
||||||
|
resolved_role = ""
|
||||||
|
has_valid_auth = False
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
tok = authorization.split(" ", 1)[1].strip()
|
||||||
|
try:
|
||||||
|
import sys as _s; _s.path.insert(0, '/opt/pgz-sport')
|
||||||
|
from auth.auth_v2 import decode_token as _dt, _is_revoked as _rev
|
||||||
|
payload = _dt(tok)
|
||||||
|
if payload.get("typ") in (None, "access") and not _rev(payload.get("jti","")):
|
||||||
|
resolved_role = (payload.get("role") or "").lower()
|
||||||
|
has_valid_auth = True
|
||||||
|
except Exception:
|
||||||
|
has_valid_auth = False
|
||||||
|
role = (x_role or resolved_role or "").lower()
|
||||||
|
|
||||||
|
# ───── DEMO MODE: no/invalid token → mock storage ─────
|
||||||
|
if not has_valid_auth:
|
||||||
|
import hashlib as _h
|
||||||
|
digest = _h.sha256(contents).hexdigest()[:12]
|
||||||
|
mock_fname = f"demo-{cid}-{digest}.{ext_map[ct]}"
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": cid,
|
||||||
|
"demo_mode": True,
|
||||||
|
"message": "Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.",
|
||||||
|
"slika_url": None,
|
||||||
|
"mock_filename": mock_fname,
|
||||||
|
"size_bytes": len(contents),
|
||||||
|
"content_type": ct,
|
||||||
|
"sha256": digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ───── REAL SAVE: valid auth + role check ─────
|
||||||
|
if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"):
|
||||||
|
raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara")
|
||||||
|
|
||||||
# provjeri da član postoji
|
# provjeri da član postoji
|
||||||
with _conn() as conn, conn.cursor() as cur:
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
cur.execute("SELECT id, slika_url FROM pgz_sport.clanovi WHERE id=%s", (cid,))
|
cur.execute("SELECT id, slika_url FROM pgz_sport.clanovi WHERE id=%s", (cid,))
|
||||||
@@ -464,20 +501,14 @@ async def upload_avatar(cid: int, file: UploadFile = File(...),
|
|||||||
if not r:
|
if not r:
|
||||||
raise HTTPException(404, "Član ne postoji")
|
raise HTTPException(404, "Član ne postoji")
|
||||||
|
|
||||||
# save file
|
|
||||||
fname = f"{cid}-{_uuid.uuid4().hex[:8]}.{ext_map[ct]}"
|
fname = f"{cid}-{_uuid.uuid4().hex[:8]}.{ext_map[ct]}"
|
||||||
fpath = UPLOADS_DIR / fname
|
fpath = UPLOADS_DIR / fname
|
||||||
contents = await file.read()
|
|
||||||
if len(contents) > 5 * 1024 * 1024:
|
|
||||||
raise HTTPException(413, "Slika prevelika (max 5 MB)")
|
|
||||||
with open(fpath, "wb") as fh:
|
with open(fpath, "wb") as fh:
|
||||||
fh.write(contents)
|
fh.write(contents)
|
||||||
|
|
||||||
public_url = f"{PUBLIC_AVATAR_PREFIX}/{fname}"
|
public_url = f"{PUBLIC_AVATAR_PREFIX}/{fname}"
|
||||||
|
|
||||||
# update DB
|
|
||||||
with _conn() as conn, conn.cursor() as cur:
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
# obriši staru sliku (best-effort, samo ako je u uploads/avatars/)
|
|
||||||
old = r["slika_url"]
|
old = r["slika_url"]
|
||||||
if old and PUBLIC_AVATAR_PREFIX in old:
|
if old and PUBLIC_AVATAR_PREFIX in old:
|
||||||
try:
|
try:
|
||||||
@@ -493,6 +524,7 @@ async def upload_avatar(cid: int, file: UploadFile = File(...),
|
|||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"id": cid,
|
"id": cid,
|
||||||
|
"demo_mode": False,
|
||||||
"slika_url": public_url,
|
"slika_url": public_url,
|
||||||
"size_bytes": len(contents),
|
"size_bytes": len(contents),
|
||||||
"content_type": ct,
|
"content_type": ct,
|
||||||
|
|||||||
@@ -23,14 +23,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import re as _re
|
||||||
import sys
|
import sys
|
||||||
|
import zipfile
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query, Header
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -42,6 +44,10 @@ from crm.payments import (
|
|||||||
build_hub3_pdf, make_poziv_na_broj, normalize_iban,
|
build_hub3_pdf, make_poziv_na_broj, normalize_iban,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DEFAULT_PRIMATELJ_IBAN = "HR0000000000000000000"
|
||||||
|
DEFAULT_PRIMATELJ_NAZIV = "PGŽ Odjel za sport"
|
||||||
|
DEFAULT_PRIMATELJ_ADRESA = "Adamićeva 10, 51000 Rijeka"
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/crm", tags=["crm-extras"])
|
router = APIRouter(prefix="/api/crm", tags=["crm-extras"])
|
||||||
|
|
||||||
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
||||||
@@ -587,3 +593,417 @@ def mark_all_read(body: MarkAllReadIn):
|
|||||||
ids = [r["id"] for r in cur.fetchall()]
|
ids = [r["id"] for r in cur.fetchall()]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True, "marked_read": len(ids), "ids": ids[:200]}
|
return {"ok": True, "marked_read": len(ids), "ids": ids[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════
|
||||||
|
# R6 #2 — BATCH HUB-3 PDFs ZIP
|
||||||
|
# ════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class BulkZipIn(BaseModel):
|
||||||
|
ids: Optional[list[int]] = None
|
||||||
|
klub_id: Optional[int] = None
|
||||||
|
godina: Optional[int] = None
|
||||||
|
only_unpaid: bool = True
|
||||||
|
limit: int = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(s: str) -> str:
|
||||||
|
s = (s or "x").strip()
|
||||||
|
s = _re.sub(r"[^\w\-\.]+", "_", s, flags=_re.UNICODE)
|
||||||
|
return s[:80] or "x"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clanarine/bulk/uplatnice.zip")
|
||||||
|
def bulk_uplatnice_zip(body: BulkZipIn):
|
||||||
|
"""
|
||||||
|
Generira ZIP archive sa svim HUB-3 PDF uplatnicama za odabrane članarine.
|
||||||
|
Filename pattern: <KlubSlug>/<Prezime_Ime>-<id>-<godina>.pdf
|
||||||
|
"""
|
||||||
|
where, params = [], []
|
||||||
|
if body.ids:
|
||||||
|
where.append("c.id = ANY(%s)"); params.append(body.ids)
|
||||||
|
if body.klub_id:
|
||||||
|
where.append("c.klub_id = %s"); params.append(body.klub_id)
|
||||||
|
if body.godina:
|
||||||
|
where.append("c.godina = %s"); params.append(body.godina)
|
||||||
|
if body.only_unpaid and not body.ids:
|
||||||
|
where.append("c.status IN ('nepodmireno','djelomicno')")
|
||||||
|
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
params.append(body.limit)
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT c.id, c.godina, c.razdoblje,
|
||||||
|
c.iznos_propisan, c.iznos_placen,
|
||||||
|
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
|
||||||
|
cl.ime, cl.prezime, cl.adresa AS clan_adresa, cl.grad AS clan_grad,
|
||||||
|
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
|
||||||
|
k.adresa AS klub_adresa, k.grad AS klub_grad
|
||||||
|
FROM pgz_sport.clanarine c
|
||||||
|
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
|
||||||
|
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY k.naziv NULLS LAST, cl.prezime, cl.ime
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
rows = [_row(r) for r in cur.fetchall()]
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(404, "Nema članarina za batch")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||||||
|
manifest = []
|
||||||
|
for r in rows:
|
||||||
|
dug = float(r["dug"] or 0)
|
||||||
|
if dug <= 0:
|
||||||
|
dug = float(r["iznos_propisan"] or 0)
|
||||||
|
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
|
||||||
|
primatelj_naziv = r.get("klub") or DEFAULT_PRIMATELJ_NAZIV
|
||||||
|
primatelj_adresa = ", ".join(
|
||||||
|
[x for x in [r.get("klub_adresa"), r.get("klub_grad")] if x]
|
||||||
|
) or DEFAULT_PRIMATELJ_ADRESA
|
||||||
|
platitelj_naziv = f"{r.get('ime') or ''} {r.get('prezime') or ''}".strip() or "Član"
|
||||||
|
platitelj_adresa = ", ".join(
|
||||||
|
[x for x in [r.get("clan_adresa"), r.get("clan_grad")] if x]
|
||||||
|
) or "—"
|
||||||
|
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
|
||||||
|
try:
|
||||||
|
pdf = build_hub3_pdf(
|
||||||
|
platitelj_naziv=platitelj_naziv,
|
||||||
|
platitelj_adresa=platitelj_adresa,
|
||||||
|
primatelj_naziv=primatelj_naziv,
|
||||||
|
primatelj_adresa=primatelj_adresa,
|
||||||
|
iban=iban,
|
||||||
|
amount_eur=dug,
|
||||||
|
model="HR00",
|
||||||
|
poziv_na_broj=poziv,
|
||||||
|
opis=f"Članarina {r['godina']} — {r.get('razdoblje') or 'godišnja'}",
|
||||||
|
sifra_namjene="OTHR",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
manifest.append(f"{r['id']}\tERROR\t{e}")
|
||||||
|
continue
|
||||||
|
klub_dir = _safe_filename(primatelj_naziv)
|
||||||
|
fname = (f"{klub_dir}/"
|
||||||
|
f"{_safe_filename(r.get('prezime') or 'X')}_"
|
||||||
|
f"{_safe_filename(r.get('ime') or 'X')}-"
|
||||||
|
f"{r['id']}-{r['godina']}.pdf")
|
||||||
|
z.writestr(fname, pdf)
|
||||||
|
manifest.append(f"{r['id']}\t{fname}\t{dug:.2f} EUR\t{poziv}")
|
||||||
|
# Manifest TXT
|
||||||
|
z.writestr("_manifest.txt",
|
||||||
|
"ID\tFILENAME\tIZNOS\tPOZIV_NA_BROJ\n" + "\n".join(manifest))
|
||||||
|
# Manifest JSON
|
||||||
|
z.writestr("_manifest.json", _json.dumps(
|
||||||
|
{"count": len(rows),
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"items": [{"id": r["id"], "klub": r.get("klub"),
|
||||||
|
"clan": f"{r.get('ime','')} {r.get('prezime','')}".strip(),
|
||||||
|
"godina": r["godina"], "iznos_eur": float(r["dug"] or r["iznos_propisan"] or 0)}
|
||||||
|
for r in rows]},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
fname = f"hub3-batch-{date.today().isoformat()}-{len(rows)}.zip"
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{fname}"',
|
||||||
|
"X-Batch-Count": str(len(rows))},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════
|
||||||
|
# R6 #3 — E-MAIL TEMPLATES (CRUD + render + send-mock)
|
||||||
|
# ════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _render(tpl: str, vars: dict) -> str:
|
||||||
|
"""Vrlo jednostavan {{key}} render."""
|
||||||
|
if not tpl:
|
||||||
|
return ""
|
||||||
|
out = tpl
|
||||||
|
for k, v in (vars or {}).items():
|
||||||
|
out = out.replace("{{" + str(k) + "}}", "" if v is None else str(v))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTemplateIn(BaseModel):
|
||||||
|
code: str
|
||||||
|
naziv: str
|
||||||
|
kategorija: Optional[str] = None
|
||||||
|
subject_tpl: str
|
||||||
|
body_tpl: str
|
||||||
|
variables: Optional[list[str]] = None
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTemplatePatch(BaseModel):
|
||||||
|
naziv: Optional[str] = None
|
||||||
|
kategorija: Optional[str] = None
|
||||||
|
subject_tpl: Optional[str] = None
|
||||||
|
body_tpl: Optional[str] = None
|
||||||
|
variables: Optional[list[str]] = None
|
||||||
|
active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email-templates")
|
||||||
|
def list_email_templates(kategorija: Optional[str] = Query(None),
|
||||||
|
active_only: bool = Query(True)):
|
||||||
|
where, params = [], []
|
||||||
|
if active_only:
|
||||||
|
where.append("active = TRUE")
|
||||||
|
if kategorija:
|
||||||
|
where.append("kategorija = %s"); params.append(kategorija)
|
||||||
|
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT id, code, naziv, kategorija, subject_tpl, body_tpl,
|
||||||
|
variables, active, created_at, updated_at
|
||||||
|
FROM pgz_sport.email_templates
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY kategorija NULLS LAST, naziv
|
||||||
|
""", params)
|
||||||
|
rows = [_row(r) for r in cur.fetchall()]
|
||||||
|
return {"count": len(rows), "templates": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email-templates/{code_or_id}")
|
||||||
|
def get_email_template(code_or_id: str):
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
if code_or_id.isdigit():
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
|
||||||
|
else:
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Email template ne postoji")
|
||||||
|
return _row(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email-templates")
|
||||||
|
def create_email_template(body: EmailTemplateIn):
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO pgz_sport.email_templates
|
||||||
|
(code, naziv, kategorija, subject_tpl, body_tpl, variables, active)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s)
|
||||||
|
RETURNING *
|
||||||
|
""", (body.code, body.naziv, body.kategorija, body.subject_tpl,
|
||||||
|
body.body_tpl, _json.dumps(body.variables or []), body.active))
|
||||||
|
r = cur.fetchone(); conn.commit()
|
||||||
|
return _row(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/email-templates/{code_or_id}")
|
||||||
|
def update_email_template(code_or_id: str, body: EmailTemplatePatch):
|
||||||
|
fields, params = [], []
|
||||||
|
for f in ("naziv", "kategorija", "subject_tpl", "body_tpl", "active"):
|
||||||
|
v = getattr(body, f)
|
||||||
|
if v is not None:
|
||||||
|
fields.append(f"{f} = %s"); params.append(v)
|
||||||
|
if body.variables is not None:
|
||||||
|
fields.append("variables = %s::jsonb"); params.append(_json.dumps(body.variables))
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(400, "Nema polja za izmjenu")
|
||||||
|
fields.append("updated_at = now()")
|
||||||
|
where_col = "id" if code_or_id.isdigit() else "code"
|
||||||
|
where_val = int(code_or_id) if code_or_id.isdigit() else code_or_id
|
||||||
|
params.append(where_val)
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(f"UPDATE pgz_sport.email_templates SET {', '.join(fields)} WHERE {where_col}=%s RETURNING *",
|
||||||
|
params)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Template ne postoji")
|
||||||
|
conn.commit()
|
||||||
|
return _row(r)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailRenderIn(BaseModel):
|
||||||
|
variables: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email-templates/{code_or_id}/render")
|
||||||
|
def render_email_template(code_or_id: str, body: EmailRenderIn):
|
||||||
|
"""Vrati subject/body s popunjenim {{vars}}."""
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
if code_or_id.isdigit():
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
|
||||||
|
else:
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
|
||||||
|
t = cur.fetchone()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "Template ne postoji")
|
||||||
|
return {
|
||||||
|
"code": t["code"],
|
||||||
|
"naziv": t["naziv"],
|
||||||
|
"subject": _render(t["subject_tpl"], body.variables),
|
||||||
|
"body": _render(t["body_tpl"], body.variables),
|
||||||
|
"variables_provided": list(body.variables.keys()),
|
||||||
|
"variables_required": t.get("variables") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSendIn(BaseModel):
|
||||||
|
to: Optional[str] = None
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
variables: dict = {}
|
||||||
|
schedule_inapp: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email-templates/{code_or_id}/send")
|
||||||
|
def send_email_template(code_or_id: str, body: EmailSendIn):
|
||||||
|
"""
|
||||||
|
Mock send: rendera template i upiše u notifications (channel=email + inapp).
|
||||||
|
Stvarni SMTP nije konfiguriran.
|
||||||
|
"""
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
if code_or_id.isdigit():
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (int(code_or_id),))
|
||||||
|
else:
|
||||||
|
cur.execute("SELECT * FROM pgz_sport.email_templates WHERE code=%s", (code_or_id,))
|
||||||
|
t = cur.fetchone()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "Template ne postoji")
|
||||||
|
|
||||||
|
subject = _render(t["subject_tpl"], body.variables)
|
||||||
|
body_txt = _render(t["body_tpl"], body.variables)
|
||||||
|
meta = _json.dumps({"template_code": t["code"],
|
||||||
|
"to": body.to,
|
||||||
|
"variables": body.variables})
|
||||||
|
ids = []
|
||||||
|
if body.to:
|
||||||
|
cur.execute("""INSERT INTO pgz_sport.notifications
|
||||||
|
(user_id, channel, subject, body, status, scheduled_at, meta)
|
||||||
|
VALUES (%s,'email',%s,%s,'pending',now(),%s::jsonb)
|
||||||
|
RETURNING id""",
|
||||||
|
(body.user_id, subject, body_txt, meta))
|
||||||
|
ids.append({"channel": "email", "id": cur.fetchone()["id"]})
|
||||||
|
if body.schedule_inapp:
|
||||||
|
cur.execute("""INSERT INTO pgz_sport.notifications
|
||||||
|
(user_id, channel, subject, body, status, scheduled_at, meta)
|
||||||
|
VALUES (%s,'inapp',%s,%s,'pending',now(),%s::jsonb)
|
||||||
|
RETURNING id""",
|
||||||
|
(body.user_id, subject, body_txt, meta))
|
||||||
|
ids.append({"channel": "inapp", "id": cur.fetchone()["id"]})
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "queued": ids, "subject": subject,
|
||||||
|
"body_preview": body_txt[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════
|
||||||
|
# R6 #4 — /api/notifications/me (alias na /api/crm/notifications/me)
|
||||||
|
# ════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _resolve_user_id(authorization: Optional[str], x_user_id: Optional[str]) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Priority:
|
||||||
|
1) X-User-Id header (UI / debug)
|
||||||
|
2) JWT 'sub' claim iz Bearer tokena (auth_v2)
|
||||||
|
"""
|
||||||
|
if x_user_id:
|
||||||
|
try:
|
||||||
|
return int(x_user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
tok = authorization.replace("Bearer ", "").strip()
|
||||||
|
try:
|
||||||
|
import jwt as _jwt # type: ignore
|
||||||
|
for secret in (
|
||||||
|
__import__("os").environ.get("JWT_SECRET"),
|
||||||
|
"rinet-jwt-secret-2026",
|
||||||
|
):
|
||||||
|
if not secret:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = _jwt.decode(tok, secret, algorithms=["HS256"])
|
||||||
|
sub = payload.get("sub") or payload.get("user_id")
|
||||||
|
if sub is not None:
|
||||||
|
return int(sub)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications/me")
|
||||||
|
def my_notifications(
|
||||||
|
only_unread: bool = Query(True),
|
||||||
|
channel: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(50, le=200),
|
||||||
|
authorization: Optional[str] = Header(None),
|
||||||
|
x_user_id: Optional[str] = Header(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Lista notifikacija za current usera (iz JWT sub ili X-User-Id headera).
|
||||||
|
Kao fallback (kad nije autentikiran) vraća notifikacije BEZ user_id
|
||||||
|
(broadcast / system).
|
||||||
|
"""
|
||||||
|
user_id = _resolve_user_id(authorization, x_user_id)
|
||||||
|
where = []
|
||||||
|
params: list = []
|
||||||
|
if user_id is None:
|
||||||
|
# broadcast: notifs bez user_id
|
||||||
|
where.append("user_id IS NULL")
|
||||||
|
else:
|
||||||
|
where.append("(user_id = %s OR user_id IS NULL)"); params.append(user_id)
|
||||||
|
if only_unread:
|
||||||
|
where.append("read_at IS NULL")
|
||||||
|
if channel:
|
||||||
|
where.append("channel = %s"); params.append(channel)
|
||||||
|
params.append(limit)
|
||||||
|
with _conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT id, user_id, channel, subject, body, status,
|
||||||
|
scheduled_at, sent_at, read_at, meta
|
||||||
|
FROM pgz_sport.notifications
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY scheduled_at DESC NULLS LAST
|
||||||
|
LIMIT %s
|
||||||
|
""", params)
|
||||||
|
rows = [_row(r) for r in cur.fetchall()]
|
||||||
|
# summary za badge
|
||||||
|
sum_where = ["read_at IS NULL"]
|
||||||
|
sum_params = []
|
||||||
|
if user_id is not None:
|
||||||
|
sum_where.append("(user_id = %s OR user_id IS NULL)")
|
||||||
|
sum_params.append(user_id)
|
||||||
|
else:
|
||||||
|
sum_where.append("user_id IS NULL")
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT COUNT(*) AS unread,
|
||||||
|
COUNT(*) FILTER (WHERE channel='inapp') AS unread_inapp,
|
||||||
|
COUNT(*) FILTER (WHERE channel='email') AS unread_email
|
||||||
|
FROM pgz_sport.notifications
|
||||||
|
WHERE {' AND '.join(sum_where)}
|
||||||
|
""", sum_params)
|
||||||
|
summary = _row(cur.fetchone())
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"count": len(rows),
|
||||||
|
"summary": summary,
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════
|
||||||
|
# Alias router: /api/notifications/me (bez /crm prefiksa)
|
||||||
|
# ════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
alias_router = APIRouter(prefix="/api/notifications", tags=["notifications-alias"])
|
||||||
|
|
||||||
|
|
||||||
|
@alias_router.get("/me")
|
||||||
|
def my_notifications_alias(
|
||||||
|
only_unread: bool = Query(True),
|
||||||
|
channel: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(50, le=200),
|
||||||
|
authorization: Optional[str] = Header(None),
|
||||||
|
x_user_id: Optional[str] = Header(None),
|
||||||
|
):
|
||||||
|
"""Alias za /api/crm/notifications/me — kompatibilnost s /api/notifications/me."""
|
||||||
|
return my_notifications(only_unread=only_unread, channel=channel, limit=limit,
|
||||||
|
authorization=authorization, x_user_id=x_user_id)
|
||||||
|
|||||||
+213
-3
@@ -1229,22 +1229,30 @@ def enrich_apply(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'),
|
|||||||
fields = res['proposed']
|
fields = res['proposed']
|
||||||
sources = res['sources']
|
sources = res['sources']
|
||||||
out = _apply_to_db(kind, eid, fields or {}, sources or [], x_user_email)
|
out = _apply_to_db(kind, eid, fields or {}, sources or [], x_user_email)
|
||||||
|
applied = out.get('applied') or {}
|
||||||
# R4-A3: write to pgz_sport.sys_audit so the audit page sees enrichment events
|
# R4-A3: write to pgz_sport.sys_audit so the audit page sees enrichment events
|
||||||
try:
|
try:
|
||||||
from audit_seal_router import audit_log as _audit_log
|
from audit_seal_router import audit_log as _audit_log
|
||||||
if out.get('applied'):
|
if applied:
|
||||||
_audit_log(
|
_audit_log(
|
||||||
action='enrich.apply',
|
action='enrich.apply',
|
||||||
target_type=kind,
|
target_type=kind,
|
||||||
target_id=eid,
|
target_id=eid,
|
||||||
payload={'applied': out.get('applied'),
|
payload={'applied': applied,
|
||||||
'sources': [s.get('url') for s in (sources or []) if isinstance(s, dict)]},
|
'sources': [s.get('url') for s in (sources or []) if isinstance(s, dict)]},
|
||||||
user_id=x_user_id,
|
user_id=x_user_id,
|
||||||
user_email=x_user_email,
|
user_email=x_user_email,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {'kind': kind, 'id': eid, **out}
|
return {
|
||||||
|
'status': 'success' if applied else 'no_changes',
|
||||||
|
'kind': kind,
|
||||||
|
'id': eid,
|
||||||
|
'applied_count': len(applied),
|
||||||
|
'applied_fields': list(applied.keys()),
|
||||||
|
**out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/enrich/log")
|
@router.get("/enrich/log")
|
||||||
@@ -1478,3 +1486,205 @@ def forensic_scan(req: dict = Body(...)):
|
|||||||
'total_findings': total_findings, 'critical_findings': crit_findings,
|
'total_findings': total_findings, 'critical_findings': crit_findings,
|
||||||
'persons': persons, 'scanned_at': int(time.time())}
|
'persons': persons, 'scanned_at': int(time.time())}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ─── SB-3 — Bulk enrichment ─────────────────────────────────────────────
|
||||||
|
_BULK_KEY_MAP = {
|
||||||
|
'klub': ('pgz_sport.klubovi',
|
||||||
|
('oib','sport','grad','predsjednik','tajnik','web','email','telefon',
|
||||||
|
'sjediste','godina_osnutka','ciljevi','opis_djelatnosti')),
|
||||||
|
'savez': ('pgz_sport.savezi',
|
||||||
|
('oib','sport','predsjednik','tajnik','email','telefon','web',
|
||||||
|
'adresa','godina_osnutka')),
|
||||||
|
'sportas': ('pgz_sport.clanovi',
|
||||||
|
('sport','profile_url','slika_url','hns_igrac_id','biografija',
|
||||||
|
'datum_rodenja','mjesto_rodenja','broj_dresa')),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage_sql(prefix: str, keys: tuple[str, ...]) -> str:
|
||||||
|
parts = [f"(CASE WHEN {prefix}{k} IS NOT NULL AND ({prefix}{k}::text) <> '' THEN 1 ELSE 0 END)"
|
||||||
|
for k in keys]
|
||||||
|
return f"((({' + '.join(parts)})::numeric * 100) / {len(keys)})"
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_pick(kind: str, limit: int, coverage_max: int) -> list[int]:
|
||||||
|
if kind not in _BULK_KEY_MAP:
|
||||||
|
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||||
|
table, keys = _BULK_KEY_MAP[kind]
|
||||||
|
cov = _coverage_sql('', keys)
|
||||||
|
extra_where = ''
|
||||||
|
if kind == 'klub':
|
||||||
|
extra_where = "AND aktivan = TRUE"
|
||||||
|
elif kind == 'sportas':
|
||||||
|
extra_where = "AND aktivan = TRUE"
|
||||||
|
sql = (f"SELECT id FROM {table} "
|
||||||
|
f"WHERE 1=1 {extra_where} "
|
||||||
|
f"AND {cov} < %s "
|
||||||
|
f"ORDER BY random() LIMIT %s")
|
||||||
|
with _db() as c, c.cursor() as cur:
|
||||||
|
cur.execute(sql, (coverage_max, limit))
|
||||||
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrich/bulk")
|
||||||
|
def enrich_bulk(body: dict = Body(default=None),
|
||||||
|
x_user_email: Optional[str] = Header(default=None),
|
||||||
|
x_user_id: Optional[int] = Header(default=None)):
|
||||||
|
"""Run preview+apply over N random under-enriched rows of one kind.
|
||||||
|
|
||||||
|
Body: {kind: 'klub'|'savez'|'sportas', limit: 50, coverage_max: 70}
|
||||||
|
Returns aggregate stats. Synchronous (use polling, not SSE).
|
||||||
|
"""
|
||||||
|
body = body or {}
|
||||||
|
kind = (body.get('kind') or '').strip().lower()
|
||||||
|
if kind not in _BULK_KEY_MAP:
|
||||||
|
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||||
|
limit = max(1, min(int(body.get('limit') or 50), 200))
|
||||||
|
coverage_max = max(0, min(int(body.get('coverage_max') or 70), 100))
|
||||||
|
|
||||||
|
ids = _bulk_pick(kind, limit, coverage_max)
|
||||||
|
items: list[dict] = []
|
||||||
|
fields_total = 0
|
||||||
|
started = time.time()
|
||||||
|
|
||||||
|
for eid in ids:
|
||||||
|
try:
|
||||||
|
row = _load_row(kind, eid)
|
||||||
|
if kind == 'klub': res = _propose_for_klub(row)
|
||||||
|
elif kind == 'savez': res = _propose_for_savez(row)
|
||||||
|
else: res = _propose_for_sportas(row)
|
||||||
|
proposed = res.get('proposed') or {}
|
||||||
|
srcs = res.get('sources') or []
|
||||||
|
if not proposed:
|
||||||
|
items.append({'id': eid, 'applied': 0, 'fields': []})
|
||||||
|
continue
|
||||||
|
out = _apply_to_db(kind, eid, proposed, srcs, x_user_email)
|
||||||
|
applied = out.get('applied') or {}
|
||||||
|
fields_total += len(applied)
|
||||||
|
items.append({'id': eid, 'applied': len(applied), 'fields': list(applied.keys())})
|
||||||
|
try:
|
||||||
|
from audit_seal_router import audit_log as _audit_log
|
||||||
|
if applied:
|
||||||
|
_audit_log(action='enrich.bulk.apply',
|
||||||
|
target_type=kind, target_id=eid,
|
||||||
|
payload={'applied': applied},
|
||||||
|
user_id=x_user_id, user_email=x_user_email)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except HTTPException as e:
|
||||||
|
items.append({'id': eid, 'error': e.detail})
|
||||||
|
except Exception as e:
|
||||||
|
items.append({'id': eid, 'error': f'{type(e).__name__}: {e}'})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'kind': kind,
|
||||||
|
'requested': limit,
|
||||||
|
'processed': len(items),
|
||||||
|
'fields_total': fields_total,
|
||||||
|
'elapsed_s': round(time.time() - started, 1),
|
||||||
|
'items': items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── SB-4 — Worker status / control ─────────────────────────────────────
|
||||||
|
_REDIS_KEYS = {
|
||||||
|
'heartbeat': 'cc:pgz-enricher:heartbeat',
|
||||||
|
'pause': 'cc:pgz-enricher:pause',
|
||||||
|
'run_now': 'cc:pgz-enricher:run_now',
|
||||||
|
'last_cycle': 'cc:pgz-enricher:last_cycle',
|
||||||
|
'confidence': 'cc:pgz-enricher:confidence',
|
||||||
|
'fields_24h': 'cc:pgz-enricher:fields_24h',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _redis_client():
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
host = os.environ.get('REDIS_HOST', 'localhost')
|
||||||
|
port = int(os.environ.get('REDIS_PORT', '6379'))
|
||||||
|
pwd = (os.environ.get('REDIS_PASS') or '').strip().strip("'").strip('"') or None
|
||||||
|
# Try with password first (prod); fall back to anonymous (dev box) on AUTH failure.
|
||||||
|
for p in (pwd, None):
|
||||||
|
try:
|
||||||
|
r = redis.Redis(host=host, port=port, password=p,
|
||||||
|
decode_responses=True, socket_connect_timeout=2)
|
||||||
|
r.ping()
|
||||||
|
return r
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enrich/worker/status")
|
||||||
|
def enrich_worker_status():
|
||||||
|
r = _redis_client()
|
||||||
|
out = {'available': bool(r)}
|
||||||
|
if not r:
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
hb = r.get(_REDIS_KEYS['heartbeat'])
|
||||||
|
out['heartbeat'] = int(hb) if hb else None
|
||||||
|
out['heartbeat_age_s'] = (int(time.time()) - int(hb)) if hb else None
|
||||||
|
out['paused'] = (r.get(_REDIS_KEYS['pause']) or '0') == '1'
|
||||||
|
out['run_now_pending'] = (r.get(_REDIS_KEYS['run_now']) or '0') == '1'
|
||||||
|
last = r.get(_REDIS_KEYS['last_cycle'])
|
||||||
|
if last:
|
||||||
|
try: out['last_cycle'] = json.loads(last)
|
||||||
|
except: out['last_cycle'] = last
|
||||||
|
conf = r.get(_REDIS_KEYS['confidence'])
|
||||||
|
out['confidence_threshold'] = float(conf) if conf else 0.7
|
||||||
|
f24 = r.get(_REDIS_KEYS['fields_24h'])
|
||||||
|
out['fields_24h'] = int(f24) if f24 and f24.isdigit() else 0
|
||||||
|
except Exception as e:
|
||||||
|
out['error'] = f'{type(e).__name__}: {e}'
|
||||||
|
# Recent enrichment_log rows for live activity
|
||||||
|
try:
|
||||||
|
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute("""SELECT id, kind, target_id, source, fields_set, user_email, created_at
|
||||||
|
FROM pgz_sport.enrichment_log
|
||||||
|
ORDER BY id DESC LIMIT 25""")
|
||||||
|
rows = []
|
||||||
|
for rr in cur.fetchall():
|
||||||
|
rr = dict(rr)
|
||||||
|
if rr.get('created_at'): rr['created_at'] = rr['created_at'].isoformat()
|
||||||
|
rows.append(rr)
|
||||||
|
out['recent'] = rows
|
||||||
|
except Exception:
|
||||||
|
out['recent'] = []
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrich/worker/pause")
|
||||||
|
def enrich_worker_pause(body: dict = Body(default=None)):
|
||||||
|
body = body or {}
|
||||||
|
pause = bool(body.get('paused', True))
|
||||||
|
r = _redis_client()
|
||||||
|
if not r: raise HTTPException(503, 'redis unavailable')
|
||||||
|
r.set(_REDIS_KEYS['pause'], '1' if pause else '0')
|
||||||
|
return {'status': 'success', 'paused': pause}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrich/worker/run-now")
|
||||||
|
def enrich_worker_run_now():
|
||||||
|
r = _redis_client()
|
||||||
|
if not r: raise HTTPException(503, 'redis unavailable')
|
||||||
|
r.set(_REDIS_KEYS['run_now'], '1')
|
||||||
|
return {'status': 'success', 'queued': True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrich/worker/confidence")
|
||||||
|
def enrich_worker_confidence(body: dict = Body(...)):
|
||||||
|
try:
|
||||||
|
v = float(body.get('value'))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(400, 'value must be number 0..1')
|
||||||
|
if not (0.0 <= v <= 1.0):
|
||||||
|
raise HTTPException(400, 'value out of range 0..1')
|
||||||
|
r = _redis_client()
|
||||||
|
if not r: raise HTTPException(503, 'redis unavailable')
|
||||||
|
r.set(_REDIS_KEYS['confidence'], str(v))
|
||||||
|
return {'status': 'success', 'confidence_threshold': v}
|
||||||
|
|||||||
Executable
+197
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
coverage_report.py — Per-entity coverage scoring across pgz_sport schema
|
||||||
|
|
||||||
|
Fills /opt/pgz-sport/data_quality_report.md with:
|
||||||
|
- per-type aggregate (n, mean coverage, median, # zero-coverage, # complete)
|
||||||
|
- distribution histogram
|
||||||
|
- top 50 entities most needing manual review (lowest coverage AND non-empty name)
|
||||||
|
- link to detail panel for each (so audit.html-style triage is one click away)
|
||||||
|
"""
|
||||||
|
import os, json
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import psycopg2, psycopg2.extras
|
||||||
|
|
||||||
|
PG = dict(host='10.10.0.2', port=6432, dbname='rinet_v3',
|
||||||
|
user='rinet', password='R1net2026!SecureDB#v7')
|
||||||
|
|
||||||
|
# Per-type coverage definition: list of fields that count toward coverage
|
||||||
|
DEFS = {
|
||||||
|
'savez': {
|
||||||
|
'table': 'pgz_sport.savezi',
|
||||||
|
'name_col': 'naziv',
|
||||||
|
'fields': ['naziv','sport','predsjednik','tajnik','email','telefon','web','oib','adresa','godina_osnutka'],
|
||||||
|
'panel_path': lambda i: f'/?nav=savezi&open={i}',
|
||||||
|
},
|
||||||
|
'klub': {
|
||||||
|
'table': 'pgz_sport.klubovi',
|
||||||
|
'name_col': 'naziv',
|
||||||
|
# Use COALESCE-ish: web OR web_stranica counts; sjediste OR adresa counts
|
||||||
|
'fields': ['naziv','sport','grad','oib','predsjednik','tajnik','email','telefon',
|
||||||
|
'web_or_stranica','sjediste_or_adresa','ciljevi','opis_djelatnosti'],
|
||||||
|
'panel_path': lambda i: f'/?nav=klubovi&open={i}',
|
||||||
|
},
|
||||||
|
'sportas': {
|
||||||
|
'table': 'pgz_sport.clanovi',
|
||||||
|
'name_col': "ime||' '||prezime",
|
||||||
|
'fields': ['ime','prezime','sport','klub_id','datum_rodenja','slika_url','oib','profile_url','biografija','hns_igrac_id'],
|
||||||
|
'panel_path': lambda i: f'/?nav=sportasi&open={i}',
|
||||||
|
},
|
||||||
|
'objekt': {
|
||||||
|
'table': 'pgz_sport.sportski_objekti',
|
||||||
|
'name_col': 'naziv',
|
||||||
|
'fields': ['naziv','tip','grad','adresa','lat','lng','upravitelj','kapacitet','sportovi','izgradeno'],
|
||||||
|
'panel_path': lambda i: f'/?nav=objekti&open={i}',
|
||||||
|
},
|
||||||
|
'manifestacija': {
|
||||||
|
'table': 'pgz_sport.manifestacije',
|
||||||
|
'name_col': 'naziv',
|
||||||
|
'fields': ['naziv','mjesto','organizator','razina','broj_ucesnika','godina_od','source_url'],
|
||||||
|
'panel_path': lambda i: f'/?nav=manifestacije&open={i}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def fetch_rows(cur, kind: str):
|
||||||
|
spec = DEFS[kind]
|
||||||
|
table = spec['table']
|
||||||
|
if kind == 'klub':
|
||||||
|
sql = f"""
|
||||||
|
SELECT id, naziv,
|
||||||
|
(CASE WHEN naziv IS NOT NULL AND naziv<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN sport IS NOT NULL AND sport<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN grad IS NOT NULL AND grad<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN oib IS NOT NULL AND oib<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN predsjednik IS NOT NULL AND predsjednik<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN tajnik IS NOT NULL AND tajnik<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN email IS NOT NULL AND email<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN telefon IS NOT NULL AND telefon<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN COALESCE(web, web_stranica) IS NOT NULL AND COALESCE(web, web_stranica)<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN COALESCE(sjediste, adresa) IS NOT NULL AND COALESCE(sjediste, adresa)<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN ciljevi IS NOT NULL AND ciljevi<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN opis_djelatnosti IS NOT NULL AND opis_djelatnosti<>'' THEN 1 ELSE 0 END
|
||||||
|
) AS filled
|
||||||
|
FROM {table}
|
||||||
|
"""
|
||||||
|
elif kind == 'sportas':
|
||||||
|
sql = f"""
|
||||||
|
SELECT id, (COALESCE(ime,'')||' '||COALESCE(prezime,'')) AS naziv,
|
||||||
|
(CASE WHEN ime IS NOT NULL AND ime<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN prezime IS NOT NULL AND prezime<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN sport IS NOT NULL AND sport<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN klub_id IS NOT NULL THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN datum_rodenja IS NOT NULL THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN slika_url IS NOT NULL AND slika_url<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN oib IS NOT NULL AND oib<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN profile_url IS NOT NULL AND profile_url<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN biografija IS NOT NULL AND biografija<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN hns_igrac_id IS NOT NULL AND hns_igrac_id<>'' THEN 1 ELSE 0 END
|
||||||
|
) AS filled
|
||||||
|
FROM {table}
|
||||||
|
"""
|
||||||
|
elif kind == 'objekt':
|
||||||
|
sql = f"""
|
||||||
|
SELECT id, naziv,
|
||||||
|
(CASE WHEN naziv IS NOT NULL AND naziv<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN tip IS NOT NULL AND tip<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN grad IS NOT NULL AND grad<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN adresa IS NOT NULL AND adresa<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN lng IS NOT NULL THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN upravitelj IS NOT NULL AND upravitelj<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN kapacitet IS NOT NULL THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN sportovi IS NOT NULL AND array_length(sportovi,1)>0 THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN izgradeno IS NOT NULL THEN 1 ELSE 0 END
|
||||||
|
) AS filled
|
||||||
|
FROM {table}
|
||||||
|
"""
|
||||||
|
elif kind == 'manifestacija':
|
||||||
|
sql = f"""
|
||||||
|
SELECT id, naziv,
|
||||||
|
(CASE WHEN naziv IS NOT NULL AND naziv<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN mjesto IS NOT NULL AND mjesto<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN organizator IS NOT NULL AND organizator<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN razina IS NOT NULL AND razina<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN broj_ucesnika IS NOT NULL AND broj_ucesnika::text<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN godina_od IS NOT NULL THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN source_url IS NOT NULL AND source_url<>'' THEN 1 ELSE 0 END
|
||||||
|
) AS filled
|
||||||
|
FROM {table}
|
||||||
|
"""
|
||||||
|
else: # savez
|
||||||
|
sql = f"""
|
||||||
|
SELECT id, naziv,
|
||||||
|
(CASE WHEN naziv IS NOT NULL AND naziv<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN sport IS NOT NULL AND sport<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN predsjednik IS NOT NULL AND predsjednik<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN tajnik IS NOT NULL AND tajnik<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN email IS NOT NULL AND email<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN telefon IS NOT NULL AND telefon<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN web IS NOT NULL AND web<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN oib IS NOT NULL AND oib<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN adresa IS NOT NULL AND adresa<>'' THEN 1 ELSE 0 END +
|
||||||
|
CASE WHEN godina_osnutka IS NOT NULL THEN 1 ELSE 0 END
|
||||||
|
) AS filled
|
||||||
|
FROM {table}
|
||||||
|
"""
|
||||||
|
cur.execute(sql)
|
||||||
|
rows = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
rows.append({'kind': kind, 'id': r['id'], 'naziv': r['naziv'] or '',
|
||||||
|
'filled': int(r['filled']),
|
||||||
|
'total': len(spec['fields'])})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def stats(rows):
|
||||||
|
if not rows: return {}
|
||||||
|
pcts = [r['filled']/r['total']*100 for r in rows]
|
||||||
|
pcts.sort()
|
||||||
|
n = len(pcts)
|
||||||
|
mean = sum(pcts)/n
|
||||||
|
median = pcts[n//2]
|
||||||
|
zero = sum(1 for p in pcts if p == 0)
|
||||||
|
complete = sum(1 for p in pcts if p >= 99.0)
|
||||||
|
bins = Counter()
|
||||||
|
for p in pcts:
|
||||||
|
b = int(p // 10) * 10
|
||||||
|
if b == 100: b = 90
|
||||||
|
bins[b] += 1
|
||||||
|
return {'n': n, 'mean': round(mean,1), 'median': round(median,1),
|
||||||
|
'zero': zero, 'complete': complete,
|
||||||
|
'distribution': dict(sorted(bins.items()))}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = psycopg2.connect(**PG)
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
all_rows = []
|
||||||
|
by_kind = {}
|
||||||
|
for kind in DEFS:
|
||||||
|
rows = fetch_rows(cur, kind)
|
||||||
|
by_kind[kind] = rows
|
||||||
|
all_rows.extend(rows)
|
||||||
|
print(f'{kind:14s} n={len(rows):5d} mean={stats(rows)["mean"]:.1f}% complete={stats(rows)["complete"]}')
|
||||||
|
|
||||||
|
# Top 50 worst — exclude rows with empty naziv (those are flagged separately)
|
||||||
|
valid = [r for r in all_rows if (r['naziv'] or '').strip()]
|
||||||
|
# Sort by coverage ASC, then by total DESC
|
||||||
|
worst = sorted(valid, key=lambda r: (r['filled']/r['total'], -r['total']))[:50]
|
||||||
|
out = {
|
||||||
|
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'totals': {k: len(v) for k,v in by_kind.items()},
|
||||||
|
'total_entities': len(all_rows),
|
||||||
|
'per_type_stats': {k: stats(v) for k,v in by_kind.items()},
|
||||||
|
'top50_review': worst,
|
||||||
|
}
|
||||||
|
print(f'\nTotal entities: {len(all_rows)}')
|
||||||
|
print(f'Top 50 worst — sample:')
|
||||||
|
for r in worst[:5]:
|
||||||
|
pct = r['filled']/r['total']*100
|
||||||
|
print(f" {r['kind']:14s} id={r['id']:7d} {r['naziv'][:50]:50s} {r['filled']}/{r['total']} ({pct:.0f}%)")
|
||||||
|
json.dump(out, open('/tmp/coverage_data.json','w'), ensure_ascii=False, default=str)
|
||||||
|
cur.close(); conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -164,7 +164,7 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
|
|||||||
<div class="nav-section sb-text">GDPR</div>
|
<div class="nav-section sb-text">GDPR</div>
|
||||||
<div class="nav-item" data-tab="gdpr"><span class="icon">🔒</span><span class="sb-text">GDPR</span></div>
|
<div class="nav-item" data-tab="gdpr"><span class="icon">🔒</span><span class="sb-text">GDPR</span></div>
|
||||||
<div class="nav-section sb-text">Drugi moduli</div>
|
<div class="nav-section sb-text">Drugi moduli</div>
|
||||||
<a class="nav-item" href="/sport/admin"><span class="icon">€</span><span class="sb-text">ERP / CRM / OCR</span></a>
|
<a class="nav-item" href="/admin"><span class="icon">€</span><span class="sb-text">ERP / CRM / OCR</span></a>
|
||||||
<a class="nav-item" href="/sport/static/sport2.html"><span class="icon">◊</span><span class="sb-text">Javni portal</span></a>
|
<a class="nav-item" href="/sport/static/sport2.html"><span class="icon">◊</span><span class="sb-text">Javni portal</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="user-box">
|
<div class="user-box">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+69
-12
@@ -408,10 +408,11 @@ async function enrichEntity(kind, id){
|
|||||||
<thead><tr style="background:var(--bg2)"><th style="text-align:left;padding:6px 8px;width:160px">Polje</th><th style="text-align:left;padding:6px 8px;width:240px">Trenutno</th><th style="text-align:left;padding:6px 8px">Predloženo</th></tr></thead>
|
<thead><tr style="background:var(--bg2)"><th style="text-align:left;padding:6px 8px;width:160px">Polje</th><th style="text-align:left;padding:6px 8px;width:240px">Trenutno</th><th style="text-align:left;padding:6px 8px">Predloženo</th></tr></thead>
|
||||||
<tbody id="enrich-diff-${kind}-${id}">${rows}</tbody>
|
<tbody id="enrich-diff-${kind}-${id}">${rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style="padding:8px 10px;background:var(--bg2);display:flex;gap:8px;justify-content:flex-end">
|
<div style="padding:8px 10px;background:var(--bg2);display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
|
||||||
<button class="btn" onclick="enrichSelectAll('${kind}',${id},true)">Označi sve</button>
|
<button class="btn" onclick="enrichSelectAll('${kind}',${id},true)">Označi sve</button>
|
||||||
<button class="btn" onclick="enrichSelectAll('${kind}',${id},false)">Poništi sve</button>
|
<button class="btn" onclick="enrichSelectAll('${kind}',${id},false)">Poništi sve</button>
|
||||||
<button class="btn primary" onclick="enrichApply('${kind}',${id})">💾 Spremi izmjene</button>
|
<button class="btn" onclick="document.getElementById('enrich-out-${kind}-${id}').innerHTML=''">❌ Odustani</button>
|
||||||
|
<button class="btn primary" onclick="enrichApply('${kind}',${id})">💾 SPREMI IZMJENE</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -454,11 +455,39 @@ function enrichSelectAll(kind, id, on){
|
|||||||
tbody.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = !!on; });
|
tbody.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = !!on; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reusable toast component (success / error / info / warn).
|
||||||
|
window.toast = function(msg, type, duration){
|
||||||
|
type = type || 'success';
|
||||||
|
duration = duration || 3000;
|
||||||
|
const palette = {
|
||||||
|
success: ['#1ec773', '#0b1a16'],
|
||||||
|
error: ['#ff6b6b', '#1a0b0b'],
|
||||||
|
info: ['#4a9eff', '#04132b'],
|
||||||
|
warn: ['#ffb84a', '#1a1004'],
|
||||||
|
}[type] || ['#4a9eff', '#04132b'];
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'pgz-toast pgz-toast-' + type;
|
||||||
|
t.style.cssText = 'position:fixed;right:20px;bottom:20px;'+
|
||||||
|
'background:'+palette[0]+';color:'+palette[1]+';'+
|
||||||
|
'padding:12px 18px;border-radius:8px;font-weight:700;font-size:14px;'+
|
||||||
|
'z-index:99999;box-shadow:0 6px 22px rgba(0,0,0,.45);'+
|
||||||
|
'transform:translateY(40px);opacity:0;transition:all .25s ease-out;'+
|
||||||
|
'max-width:380px;line-height:1.45;';
|
||||||
|
t.innerHTML = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
requestAnimationFrame(()=>{ t.style.transform='translateY(0)'; t.style.opacity='1'; });
|
||||||
|
setTimeout(()=>{
|
||||||
|
t.style.transform='translateY(40px)'; t.style.opacity='0';
|
||||||
|
setTimeout(()=>t.remove(), 280);
|
||||||
|
}, duration);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
async function enrichApply(kind, id){
|
async function enrichApply(kind, id){
|
||||||
const target = document.getElementById('enrich-out-'+kind+'-'+id);
|
const target = document.getElementById('enrich-out-'+kind+'-'+id);
|
||||||
const tbody = document.getElementById('enrich-diff-'+kind+'-'+id);
|
const tbody = document.getElementById('enrich-diff-'+kind+'-'+id);
|
||||||
const preview = (window._enrichPreviews||{})[kind+':'+id];
|
const preview = (window._enrichPreviews||{})[kind+':'+id];
|
||||||
if(!preview){ alert('Prvo pokreni "▶ Pokreni"'); return; }
|
if(!preview){ toast('⚠ Prvo pokreni "▶ Pokreni"', 'warn'); return; }
|
||||||
const proposed = preview.proposed || {};
|
const proposed = preview.proposed || {};
|
||||||
const fields = {};
|
const fields = {};
|
||||||
if(tbody){
|
if(tbody){
|
||||||
@@ -469,7 +498,7 @@ async function enrichApply(kind, id){
|
|||||||
} else {
|
} else {
|
||||||
Object.assign(fields, proposed);
|
Object.assign(fields, proposed);
|
||||||
}
|
}
|
||||||
if(!Object.keys(fields).length){ alert('Označi barem jedno polje za primjenu.'); return; }
|
if(!Object.keys(fields).length){ toast('Označi barem jedno polje za primjenu.', 'warn'); return; }
|
||||||
if(target) target.innerHTML = '<div class="loading">⏳ Spremam u bazu…</div>';
|
if(target) target.innerHTML = '<div class="loading">⏳ Spremam u bazu…</div>';
|
||||||
try{
|
try{
|
||||||
const r = await fetch(API+'/v2/enrich/'+kind+'/'+id+'/apply', {
|
const r = await fetch(API+'/v2/enrich/'+kind+'/'+id+'/apply', {
|
||||||
@@ -484,18 +513,46 @@ async function enrichApply(kind, id){
|
|||||||
else if(kind === 'savez' && typeof openSavez === 'function') await openSavez(id);
|
else if(kind === 'savez' && typeof openSavez === 'function') await openSavez(id);
|
||||||
else if(kind === 'sportas' && typeof openSportas === 'function') await openSportas(id);
|
else if(kind === 'sportas' && typeof openSportas === 'function') await openSportas(id);
|
||||||
setTimeout(() => enrichEntity(kind, id), 350);
|
setTimeout(() => enrichEntity(kind, id), 350);
|
||||||
const cnt = Object.keys(data.applied||{}).length;
|
const cnt = data.applied_count != null ? data.applied_count : Object.keys(data.applied||{}).length;
|
||||||
const t = document.createElement('div');
|
const fieldsList = (data.applied_fields || Object.keys(data.applied||{})).join(', ');
|
||||||
t.style.cssText = 'position:fixed;bottom:20px;right:20px;background:var(--ok,#1ec773);color:#0b1a16;padding:10px 16px;border-radius:6px;font-weight:700;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.4)';
|
if(cnt){
|
||||||
t.textContent = '✓ Spremljeno '+cnt+' polja u bazu';
|
toast('✅ Spremljeno <b>'+cnt+'</b> polja u bazu'
|
||||||
document.body.appendChild(t);
|
+ (fieldsList ? '<br><span style="opacity:.85;font-weight:500;font-size:12px">'+esc(fieldsList)+'</span>' : ''),
|
||||||
setTimeout(()=>t.remove(), 3500);
|
'success', 3500);
|
||||||
|
} else {
|
||||||
|
toast('Nema novih izmjena za spremiti.', 'info', 2500);
|
||||||
|
}
|
||||||
}catch(e){
|
}catch(e){
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
toast('❌ Greška pri spremanju: '+esc(e.message||String(e)), 'error', 4500);
|
||||||
if(target) target.innerHTML = '<div class="empty" style="color:var(--bad,#ff6b6b)">Greška pri spremanju: '+esc(e.message||String(e))+'</div>';
|
if(target) target.innerHTML = '<div class="empty" style="color:var(--bad,#ff6b6b)">Greška pri spremanju: '+esc(e.message||String(e))+'</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk enrichment — used by "Obogati sve" buttons in list views
|
||||||
|
async function enrichBulk(kind, limit, coverage_max){
|
||||||
|
limit = limit || 50; coverage_max = coverage_max || 70;
|
||||||
|
if(!confirm('Pokreni obogaćivanje za '+limit+' nasumično odabranih ('+kind+', coverage<'+coverage_max+'%)?')) return;
|
||||||
|
toast('⏳ Pokrećem bulk obogaćivanje za '+limit+' '+kind+'…', 'info', 2500);
|
||||||
|
try{
|
||||||
|
const r = await fetch(API+'/v2/enrich/bulk', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({kind, limit, coverage_max}),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if(!r.ok) throw new Error(data.detail || ('HTTP '+r.status));
|
||||||
|
toast('✅ Bulk gotov: <b>'+data.processed+'</b>/'+data.requested+' obrađeno, '+
|
||||||
|
'dodano <b>'+data.fields_total+'</b> polja u DB ('+data.elapsed_s+'s)',
|
||||||
|
'success', 5000);
|
||||||
|
// Reload the section so new values appear
|
||||||
|
if(typeof loadSection === 'function' && _state && _state.section) loadSection(_state.section);
|
||||||
|
}catch(e){
|
||||||
|
console.error(e);
|
||||||
|
toast('❌ Bulk greška: '+esc(e.message||String(e)), 'error', 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function enrichBlock(kind, id){
|
function enrichBlock(kind, id){
|
||||||
return `
|
return `
|
||||||
<div class="card" id="enrich-card-${kind}-${id}">
|
<div class="card" id="enrich-card-${kind}-${id}">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Binary file not shown.
|
Before Width: | Height: | Size: 176 B |
@@ -69,13 +69,78 @@ def _log(msg: str) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _heartbeat() -> None:
|
def _redis():
|
||||||
try:
|
try:
|
||||||
import redis
|
import redis
|
||||||
r = redis.Redis(host=os.environ.get('REDIS_HOST', 'localhost'),
|
except Exception:
|
||||||
port=int(os.environ.get('REDIS_PORT', '6379')),
|
return None
|
||||||
password=os.environ.get('REDIS_PASS', None))
|
host = os.environ.get('REDIS_HOST', 'localhost')
|
||||||
|
port = int(os.environ.get('REDIS_PORT', '6379'))
|
||||||
|
pwd = (os.environ.get('REDIS_PASS') or '').strip().strip("'").strip('"') or None
|
||||||
|
for p in (pwd, None):
|
||||||
|
try:
|
||||||
|
r = redis.Redis(host=host, port=port, password=p,
|
||||||
|
decode_responses=True, socket_connect_timeout=2)
|
||||||
|
r.ping()
|
||||||
|
return r
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _heartbeat(meta: dict | None = None) -> None:
|
||||||
|
r = _redis()
|
||||||
|
if not r: return
|
||||||
|
try:
|
||||||
r.set('cc:pgz-enricher:heartbeat', str(int(time.time())))
|
r.set('cc:pgz-enricher:heartbeat', str(int(time.time())))
|
||||||
|
if meta is not None:
|
||||||
|
r.set('cc:pgz-enricher:last_cycle', json.dumps(meta, default=str))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _is_paused() -> bool:
|
||||||
|
r = _redis()
|
||||||
|
if not r: return False
|
||||||
|
try:
|
||||||
|
return (r.get('cc:pgz-enricher:pause') or '0') == '1'
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_run_now() -> bool:
|
||||||
|
r = _redis()
|
||||||
|
if not r: return False
|
||||||
|
try:
|
||||||
|
v = r.get('cc:pgz-enricher:run_now')
|
||||||
|
if v == '1':
|
||||||
|
r.set('cc:pgz-enricher:run_now', '0')
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_confidence() -> None:
|
||||||
|
"""Read live confidence override from redis (set by /worker/confidence)."""
|
||||||
|
global CONFIDENCE_MIN
|
||||||
|
r = _redis()
|
||||||
|
if not r: return
|
||||||
|
try:
|
||||||
|
v = r.get('cc:pgz-enricher:confidence')
|
||||||
|
if v:
|
||||||
|
CONFIDENCE_MIN = float(v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_fields_24h(n: int) -> None:
|
||||||
|
if n <= 0: return
|
||||||
|
r = _redis()
|
||||||
|
if not r: return
|
||||||
|
try:
|
||||||
|
r.incrby('cc:pgz-enricher:fields_24h', n)
|
||||||
|
r.expire('cc:pgz-enricher:fields_24h', 86400)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -264,8 +329,10 @@ def _process(kind: str, eid: int) -> tuple[int, list[str]]:
|
|||||||
|
|
||||||
|
|
||||||
def _cycle() -> dict:
|
def _cycle() -> dict:
|
||||||
|
_refresh_confidence()
|
||||||
started = time.time()
|
started = time.time()
|
||||||
out = {'sportas': 0, 'klub': 0, 'savez': 0, 'fields_total': 0}
|
out = {'sportas': 0, 'klub': 0, 'savez': 0, 'fields_total': 0,
|
||||||
|
'started_at': datetime.now(timezone.utc).isoformat()}
|
||||||
fields_total = 0
|
fields_total = 0
|
||||||
for kind, picker, limit in (
|
for kind, picker, limit in (
|
||||||
('sportas', _pick_sportas, 50),
|
('sportas', _pick_sportas, 50),
|
||||||
@@ -278,26 +345,45 @@ def _cycle() -> dict:
|
|||||||
for eid in ids:
|
for eid in ids:
|
||||||
if DRY:
|
if DRY:
|
||||||
continue
|
continue
|
||||||
|
if _is_paused():
|
||||||
|
_log("paused → break out of cycle")
|
||||||
|
break
|
||||||
n, fields = _process(kind, eid)
|
n, fields = _process(kind, eid)
|
||||||
out[kind] += 1
|
out[kind] += 1
|
||||||
fields_total += n
|
fields_total += n
|
||||||
|
if n: _bump_fields_24h(n)
|
||||||
time.sleep(1.5) # gentle pacing
|
time.sleep(1.5) # gentle pacing
|
||||||
_heartbeat()
|
_heartbeat()
|
||||||
out['fields_total'] = fields_total
|
out['fields_total'] = fields_total
|
||||||
out['elapsed_s'] = round(time.time() - started, 1)
|
out['elapsed_s'] = round(time.time() - started, 1)
|
||||||
|
out['ended_at'] = datetime.now(timezone.utc).isoformat()
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
_log(f"enrichment_worker starting | API_BASE={API_BASE} | sleep={SLEEP_S}s | dry={DRY}")
|
_log(f"enrichment_worker starting | API_BASE={API_BASE} | sleep={SLEEP_S}s | dry={DRY}")
|
||||||
while True:
|
while True:
|
||||||
|
if _is_paused():
|
||||||
|
_log("paused (cc:pgz-enricher:pause=1) — sleeping 30s")
|
||||||
|
_heartbeat({'paused': True})
|
||||||
|
time.sleep(30)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
stats = _cycle()
|
stats = _cycle()
|
||||||
_log(f"cycle done: {json.dumps(stats)}")
|
_log(f"cycle done: {json.dumps(stats)}")
|
||||||
|
_heartbeat(stats)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"cycle FAILED: {type(e).__name__}: {e}")
|
_log(f"cycle FAILED: {type(e).__name__}: {e}")
|
||||||
_heartbeat()
|
_heartbeat({'error': str(e)[:200]})
|
||||||
time.sleep(SLEEP_S)
|
# Sleep in 5-second slices so /worker/run-now and /pause respond fast.
|
||||||
|
elapsed = 0
|
||||||
|
while elapsed < SLEEP_S:
|
||||||
|
if _consume_run_now():
|
||||||
|
_log("run-now signal received → starting next cycle early")
|
||||||
|
break
|
||||||
|
if _is_paused():
|
||||||
|
break
|
||||||
|
time.sleep(5); elapsed += 5
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Reference in New Issue
Block a user