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:
Damir Radulić
2026-05-05 01:42:53 +02:00
parent 3a79965899
commit f9ebcddf28
38 changed files with 24709 additions and 92 deletions
@@ -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>
+866
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
View File
@@ -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
View File
@@ -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
View File
@@ -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})
+100
View File
@@ -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`).
+207
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}')
+46 -14
View File
@@ -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,
+421 -1
View File
@@ -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
View File
@@ -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}
+197
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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

+93 -7
View File
@@ -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__':