Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b93ca9a8bf | |||
| 85fd51bfd9 | |||
| 21be7ff42b | |||
| 98f823b4d9 | |||
| 492c8fdd87 | |||
| c12a8e9698 | |||
| 64082d0642 | |||
| 382d35af30 | |||
| 4ecd7fafa3 |
@@ -0,0 +1,495 @@
|
||||
<!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>
|
||||
</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>
|
||||
<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();
|
||||
}
|
||||
|
||||
// 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
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
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
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,164 @@
|
||||
# HANDOFF — FULL MIGRATION + CLEANUP
|
||||
**Datum:** 04.05.2026 23:50 CEST
|
||||
**Autor:** Damir Radulić (kroz Claude session)
|
||||
**Verzija:** v1.0
|
||||
|
||||
## TL;DR
|
||||
|
||||
Migracija s GPU servera (144.76.68.5) na Server B (10.10.0.2) **POTPUNA**. Lokalni PG **stopped+disabled**. Sustav radi 100% iz Server B-a. Disk recovered ~30GB. Cron timeoutovi dodani da spriječe daljnje stuck procese.
|
||||
|
||||
## Što je urađeno večeras
|
||||
|
||||
### 1. Migracija ovisnosti (pgz_sport + ostali)
|
||||
- `pgz_sport_api.py`: DSN `localhost:5432` → `10.10.0.2:6432` ✅
|
||||
- `pgz_sport_v2_router.py`: isto fixed
|
||||
- `learn_loop.py`: provjereno, već ide na Server B
|
||||
- `reembed_phase2.py`: DSN fix → 10.10.0.2:6432
|
||||
- `reembed_knowledge_v2.py`: import iz docstring-a fix (DB_DSN bio undefined)
|
||||
|
||||
### 2. EnvironmentFile fix (GLAVNI BUG)
|
||||
Bilo bez `EnvironmentFile=/opt/rinet-gpu/.env.master`:
|
||||
- `dabi-orchestrator-v3.service` ✅
|
||||
- `rinet-mcp.service` ✅
|
||||
- `rinet-supervisor.service` ✅
|
||||
- `rinet-heartbeat.service` ✅
|
||||
|
||||
Posljedica: env vars (QDRANT_URL, GROQ_API_KEY, ANTHROPIC_API_KEY, DEEPSEEK_API_KEY) nisu stizale procesima.
|
||||
|
||||
### 3. Mass-fix Qdrant URL (35+ scripts)
|
||||
- `localhost:6333` → `10.10.0.2:6333` u **55+ aktivnih file-ova**
|
||||
- Pokriveno: /opt/rinet-gpu, /opt/ai-rinet, /opt/pgz-sport, /opt/dabi-persona, /opt/portal-rinet
|
||||
- Ostali: backup files (pre_b_switch, .bak.*) — nije dirano
|
||||
|
||||
### 4. TG spam blokiranje
|
||||
- Globalni Python monkey-patch `/usr/lib/python3/dist-packages/usercustomize.py`
|
||||
- Intercept svaki `requests.post("api.telegram.org/...")` u svim Python procesima
|
||||
- Šalje kroz `rinet-notify` rate-limited helper (max 5/h, dedup 30min)
|
||||
- Bash wrapper `/usr/local/bin/rinet-curl-tg`
|
||||
- Disabled cron monitor (embed_monitor.sh, embed_monitor_p2.sh)
|
||||
|
||||
### 5. Anthropic Tier 4 (ZADNJI u waterfall) ✅
|
||||
Linije 484+496 u `dabi_orchestrator_v3.py`:
|
||||
```
|
||||
Tier 0: dabi-budget LoRA (port 8765)
|
||||
Tier 1: vLLM Qwen 7B (port 8001)
|
||||
Tier 2: Groq llama-4-scout
|
||||
Tier 3: DeepSeek V3
|
||||
Tier 4: Anthropic Claude ← ZADNJI
|
||||
```
|
||||
ENV var bug fix: `CLAUDE_API_KEY` → `ANTHROPIC_API_KEY`
|
||||
|
||||
### 6. Multi-language support (HR/EN/DE/IT)
|
||||
- `_translate_to()` + `_detect_query_lang()` u `/opt/ai-rinet/ai_gateway.py`
|
||||
- HR: native ✅
|
||||
- EN: radi ✅
|
||||
- DE: radi ✅
|
||||
- IT: povremeno (Groq rate-limit issue)
|
||||
|
||||
### 7. Sport scrapers — pokrenuti svi
|
||||
Bili 5 INACTIVE, sad SVI ACTIVE:
|
||||
- sport-pgz-deep-loop ✅
|
||||
- sport-master-loop ✅
|
||||
- sport-extra-loop ✅
|
||||
- sport-fed-scrapers ✅
|
||||
- sport-oib-loop ✅
|
||||
- sport-dabi-quiz ✅
|
||||
|
||||
`pgz_sport_deep.py`: keyword filter prošireno **8 → 26 keywords** (sport, klub, savez, sportaš, kup, prvenstvo, liga, utakmica, igrač, trener, olimpij, paraolimpij, turn, medalj, pobjed, rijeka, pgž, primorsko, subvenc, natječaj, odluka, proračun, rebal...)
|
||||
|
||||
### 8. Reembed processes — radi
|
||||
- `tmux 'reembed'`: 89% done, rate 55-173k/s ⭐
|
||||
- `reembed_phase2.py`: PID 1790646, 85-102k/h, court_notices_v2 + rsv_enriched_v2
|
||||
|
||||
### 9. LoRA daily timer — REVIVED ⭐
|
||||
**Bug**: timer bio mrtav od 03.05.2026!
|
||||
**Fix**: `systemctl enable lora-finetune.timer` + start
|
||||
Training pokrenuto 23:24 — 100,000 examples + 309 eval
|
||||
|
||||
### 10. KPI Dashboard — LIVE
|
||||
- JSON: https://sport.rinet.one/admin/api/kpi
|
||||
- HTML: https://sport.rinet.one/admin/api/kpi-page (auto-refresh 30s)
|
||||
|
||||
### 11. Continuous loops (15 cron)
|
||||
| Cron | Loop | Timeout |
|
||||
|---|---|---|
|
||||
| */2 min | lora_watchdog | - |
|
||||
| */5 min | smoke_test | 60s ⭐ |
|
||||
| */5 min | kpi_snapshot | 30s ⭐ |
|
||||
| */10 min | latency_alert | 30s ⭐ |
|
||||
| */15 min | halu_scanner | 60s ⭐ |
|
||||
| */20 min | learn_from_errors | 90s ⭐ |
|
||||
| */30 min | capture_to_training | 120s ⭐ |
|
||||
| */30 min | scraper_health | 90s ⭐ |
|
||||
| */45 min | regression_test | 90s ⭐ |
|
||||
| 0 * | hourly_status | 30s ⭐ |
|
||||
| 0 8 | daily_learning | - |
|
||||
| 0 4 daily | RAGAS eval | - |
|
||||
| 0 2 daily | overnight_learning | - |
|
||||
| daily 03:00 | LoRA fine-tune | - |
|
||||
| daily 03:07 | master_backup 22TB | - |
|
||||
|
||||
⭐ = timeout dodan večeras (spriječava stuck procese)
|
||||
|
||||
### 12. Lokalni PG — STOPPED + DISABLED
|
||||
- `systemctl stop postgresql` ✅
|
||||
- `systemctl disable postgresql` ✅
|
||||
- Listen 5432: NONE
|
||||
- Schema backup u `/mnt/cold/local_pg_schema_backup_20260504_2343.sql.gz` (109K)
|
||||
- Data dir `/var/lib/postgresql/18/main` (47GB) **NIJE OBRISAN** (čekamo 24h verifikaciju)
|
||||
|
||||
### 13. Stuck procesi ubijeni
|
||||
- 46× smoke_test stuck → 0
|
||||
- 8× scraper_health stuck → 0
|
||||
- 5× hourly_status stuck → 0
|
||||
- 1× duplicate master_scraper_coordinator → 0
|
||||
- **Total 60 stuck procesa eliminirano**
|
||||
|
||||
### 14. Disk cleanup (~30GB recovered)
|
||||
- `/tmp/ocr_resized` (15GB)
|
||||
- `/tmp/sprint` (13GB)
|
||||
- `/tmp/rinet_v3_backup.dump` (2.2GB old PG dump)
|
||||
- `/root/.cache/uv` (6.1GB)
|
||||
- 201× .bak files older 14 days
|
||||
- 113× __pycache__ dirs
|
||||
|
||||
## Trenutno stanje
|
||||
|
||||
```
|
||||
PG: Server B 10.10.0.2:6432 (5,315,161 facts)
|
||||
Lokalni 5432 STOPPED + DISABLED
|
||||
PgBouncer: 127.0.0.1:6432 → host=10.10.0.2 port=5432 (proxy to Server B)
|
||||
Qdrant: Server B 10.10.0.2:6333 (46 collections, 14M+ vectors)
|
||||
Lokalni 6333: NE POSTOJI
|
||||
Redis: Lokalni 6379 (cache)
|
||||
Neo4j: Lokalni 7687 (615,580 nodes, 756,333 relations)
|
||||
Embed: Lokalni 9879 (BGE-M3, dim 1024)
|
||||
Reranker: Lokalni 8099/8100/8101 (3 instance)
|
||||
vLLM: Lokalni 8001 (Qwen2.5-7B-Instruct-AWQ)
|
||||
F10 LoRA: Lokalni 8765 (dabi-budget-lora-q4)
|
||||
Ollama: Lokalni 11434 (qwen3:14b, llama3.2:3b)
|
||||
MCP: Lokalni 8810 (7 tools)
|
||||
```
|
||||
|
||||
## Što ostaje za dovršiti
|
||||
|
||||
1. **24h dry-run lokalni PG stop** — provjeriti je li sve OK pa onda obrisati `/var/lib/postgresql/18/main` (47GB)
|
||||
2. **`drop_gpu_pg.sh`** — pripremljen prije, **NE pokretati** dok dry-run ne potvrdi
|
||||
3. **Multi-lang IT/DE retry** — Groq rate-limit issue povremeno
|
||||
4. **9 facts bez source** — UPDATE bio prekinut Bridge timeout-om, treba ponoviti
|
||||
5. **Neo4j integration u RAG** — orchestrator još ne koristi knowledge graph (756k relations leže neiskorišteno)
|
||||
|
||||
## Testovi prošli
|
||||
- Smoke 4 questions: 3/4 PASS (Bok, NK Rijeka predsjednik, Kup HR; PGŽ proracun timeout via Bridge)
|
||||
- vLLM: response OK
|
||||
- Embed BGE-M3: dim 1024 OK
|
||||
- RAG: tier 1 vLLM + tier 2 Groq + tier 0 DB priority sve rade
|
||||
- Server B PG via PgBouncer: 5,315,161 facts ✅
|
||||
- Sport+PGŽ embed: 99.97% / 99.92% ✅
|
||||
- Halucinacije 24h: 0 ✅
|
||||
- Sport scrapers: 6 active ✅
|
||||
|
||||
## Bridge stability notes
|
||||
- Bridge timeout-i tijekom session-a (server pod opterećenjem)
|
||||
- Glavni razlog: GPU 100% util (LoRA training), 18+ paralelni scrapers
|
||||
- Load average peak: 126 (sad 11)
|
||||
@@ -0,0 +1 @@
|
||||
rinet-pgz-sggepY_ZLyxrXdziPAXsVx8WzZ5tRREVdeOgJlWgV2jrsPi35eH-w6q88RddJTgl
|
||||
@@ -0,0 +1,2 @@
|
||||
# PGŽ Sport — auth package
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
@@ -0,0 +1,446 @@
|
||||
#!/usr/bin/env python3
|
||||
# admin_users.py — /api/admin/users CRUD endpoints
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
"""
|
||||
GET /api/admin/users?tenant_type=&tenant_id=&q=
|
||||
POST /api/admin/users
|
||||
PUT /api/admin/users/{id}
|
||||
DELETE /api/admin/users/{id}
|
||||
POST /api/admin/users/{id}/invite
|
||||
POST /api/admin/users/{id}/role
|
||||
POST /api/admin/users/{id}/suspend
|
||||
GET /api/admin/audit?user_id=&action=&limit=
|
||||
GET /api/admin/tenants
|
||||
POST /api/admin/users/bulk-csv
|
||||
"""
|
||||
import csv, io, secrets, json
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Body, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .auth_v2 import (
|
||||
db_query, db_one, db_exec, hash_password,
|
||||
require_user, audit, _client,
|
||||
_resolve_tenant, _tier_for,
|
||||
PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
VALID_USER_TYPES = (PGZ_USER_TYPES | SAVEZ_USER_TYPES | KLUB_USER_TYPES |
|
||||
{"viewer", "guest"})
|
||||
|
||||
# ─────────────────────────── Permission helpers ───────────────────────────
|
||||
def _is_pgz_admin(u: Dict) -> bool:
|
||||
return (u.get("user_type") or "").lower() in ("super_admin", "pgz_admin")
|
||||
|
||||
def _is_savez_admin(u: Dict) -> bool:
|
||||
return (u.get("user_type") or "").lower() == "savez_admin"
|
||||
|
||||
def _is_klub_admin(u: Dict) -> bool:
|
||||
return (u.get("user_type") or "").lower() == "klub_admin"
|
||||
|
||||
def _can_manage(actor: Dict, target_user_type: str,
|
||||
target_klub_id: Optional[int], target_savez_id: Optional[int]) -> bool:
|
||||
"""Hierarchical management:
|
||||
- super_admin / pgz_admin → manage everyone
|
||||
- savez_admin → manage savez_*, klub_admin in their savez
|
||||
- klub_admin → manage klub_user/klub_trener/klub_clan in their klub
|
||||
"""
|
||||
if _is_pgz_admin(actor): return True
|
||||
tut = (target_user_type or "").lower()
|
||||
if _is_savez_admin(actor):
|
||||
if tut in PGZ_USER_TYPES: return False
|
||||
if tut in SAVEZ_USER_TYPES and (target_savez_id == actor.get("savez_id")): return True
|
||||
if tut == "klub_admin" and target_savez_id and target_savez_id == actor.get("savez_id"):
|
||||
return True
|
||||
# any klub user that belongs to this savez
|
||||
if tut in KLUB_USER_TYPES and target_savez_id == actor.get("savez_id"):
|
||||
return True
|
||||
return False
|
||||
if _is_klub_admin(actor):
|
||||
if tut not in {"klub_user", "klub_trener", "klub_clan", "viewer"}:
|
||||
return False
|
||||
return target_klub_id and target_klub_id == actor.get("klub_id")
|
||||
return False
|
||||
|
||||
def _scoped_where(actor: Dict) -> tuple:
|
||||
"""Filter user list by actor's scope."""
|
||||
if _is_pgz_admin(actor): return ("", [])
|
||||
if _is_savez_admin(actor):
|
||||
sid = actor.get("savez_id")
|
||||
if not sid: return ("AND 1=0", [])
|
||||
return ("AND (u.savez_id=%s OR u.klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s))",
|
||||
[sid, sid])
|
||||
if _is_klub_admin(actor):
|
||||
kid = actor.get("klub_id")
|
||||
if not kid: return ("AND 1=0", [])
|
||||
return ("AND u.klub_id=%s", [kid])
|
||||
return ("AND u.id=%s", [actor["id"]])
|
||||
|
||||
# ─────────────────────────── List / read ───────────────────────────
|
||||
@router.get("/users")
|
||||
def list_users(
|
||||
q: Optional[str] = None,
|
||||
user_type: Optional[str] = None,
|
||||
tenant_type: Optional[str] = None,
|
||||
tenant_id: Optional[int] = None,
|
||||
klub_id: Optional[int] = None,
|
||||
savez_id: Optional[int] = None,
|
||||
aktivan: Optional[bool] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor = Depends(require_user),
|
||||
):
|
||||
if not (_is_pgz_admin(actor) or _is_savez_admin(actor) or _is_klub_admin(actor)):
|
||||
raise HTTPException(403, "Forbidden — admin required")
|
||||
where = ["1=1"]; args: List[Any] = []
|
||||
sw, sp = _scoped_where(actor)
|
||||
if sw: where.append(sw.replace("AND ", "")); args.extend(sp)
|
||||
if q:
|
||||
where.append("(LOWER(u.email) LIKE %s OR LOWER(u.full_name) LIKE %s OR LOWER(COALESCE(u.ime,'')) LIKE %s OR LOWER(COALESCE(u.prezime,'')) LIKE %s)")
|
||||
like = f"%{q.lower()}%"; args.extend([like]*4)
|
||||
if user_type: where.append("u.user_type=%s"); args.append(user_type)
|
||||
if klub_id: where.append("u.klub_id=%s"); args.append(klub_id)
|
||||
if savez_id: where.append("u.savez_id=%s"); args.append(savez_id)
|
||||
if aktivan is not None: where.append("u.aktivan=%s"); args.append(aktivan)
|
||||
if tenant_type and tenant_id is not None:
|
||||
if tenant_type == "klub": where.append("u.klub_id=%s"); args.append(tenant_id)
|
||||
elif tenant_type == "savez": where.append("u.savez_id=%s"); args.append(tenant_id)
|
||||
base_args = list(args)
|
||||
args.extend([limit, offset])
|
||||
rows = db_query(f"""SELECT u.id, u.email, u.full_name, u.ime, u.prezime, u.user_type,
|
||||
u.klub_id, u.savez_id, u.aktivan, u.status, u.must_change_pwd,
|
||||
u.last_login, u.locked_until, u.failed_login_count, u.telefon,
|
||||
u.created_at, u.gdpr_consent_at,
|
||||
k.naziv AS klub_naziv, s.naziv AS savez_naziv
|
||||
FROM pgz_sport.users u
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY u.id DESC LIMIT %s OFFSET %s""", tuple(args))
|
||||
total = db_one(f"SELECT COUNT(*) AS c FROM pgz_sport.users u WHERE {' AND '.join(where)}",
|
||||
tuple(base_args))["c"]
|
||||
return {"count": len(rows), "total": total, "results": rows}
|
||||
|
||||
@router.get("/users/{uid}")
|
||||
def get_user(uid: int, actor = Depends(require_user)):
|
||||
u = db_one("""SELECT u.*, k.naziv AS klub_naziv, s.naziv AS savez_naziv
|
||||
FROM pgz_sport.users u
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id
|
||||
WHERE u.id=%s""", (uid,))
|
||||
if not u: raise HTTPException(404, "User not found")
|
||||
if not (_is_pgz_admin(actor) or
|
||||
_can_manage(actor, u.get("user_type"), u.get("klub_id"), u.get("savez_id")) or
|
||||
actor["id"] == uid):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
# Strip sensitive
|
||||
u.pop("password_hash", None)
|
||||
u.pop("two_factor_secret", None)
|
||||
return u
|
||||
|
||||
# ─────────────────────────── Create ───────────────────────────
|
||||
class CreateUserReq(BaseModel):
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
ime: Optional[str] = None
|
||||
prezime: Optional[str] = None
|
||||
user_type: str = "klub_user"
|
||||
klub_id: Optional[int] = None
|
||||
savez_id: Optional[int] = None
|
||||
telefon: Optional[str] = None
|
||||
oib: Optional[str] = None
|
||||
password: Optional[str] = None # if absent → temp pwd + must_change
|
||||
|
||||
@router.post("/users")
|
||||
def create_user(req: CreateUserReq, request: Request, actor = Depends(require_user)):
|
||||
if req.user_type not in VALID_USER_TYPES:
|
||||
raise HTTPException(400, f"Invalid user_type: {req.user_type}")
|
||||
if not _can_manage(actor, req.user_type, req.klub_id, req.savez_id):
|
||||
raise HTTPException(403, "Forbidden — out of management scope")
|
||||
full_name = req.full_name or ((req.ime or "") + " " + (req.prezime or "")).strip() or req.email
|
||||
pwd = req.password or ("PGZ-" + secrets.token_hex(4))
|
||||
must_change = not bool(req.password)
|
||||
try:
|
||||
new_id = db_one("""INSERT INTO pgz_sport.users
|
||||
(email, password_hash, full_name, ime, prezime, user_type, klub_id, savez_id,
|
||||
telefon, oib, must_change_pwd, aktivan, status, auth_provider)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,'active','local')
|
||||
RETURNING id""",
|
||||
(req.email.lower().strip(), hash_password(pwd), full_name,
|
||||
req.ime, req.prezime, req.user_type, req.klub_id, req.savez_id,
|
||||
req.telefon, req.oib, must_change))["id"]
|
||||
except Exception as e:
|
||||
if "duplicate" in str(e).lower() or "unique" in str(e).lower():
|
||||
raise HTTPException(409, f"Email već postoji: {req.email}")
|
||||
raise HTTPException(400, str(e))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.create", "user", new_id,
|
||||
{"email": req.email, "user_type": req.user_type,
|
||||
"klub_id": req.klub_id, "savez_id": req.savez_id}, ip, ua)
|
||||
return {"id": new_id, "email": req.email, "user_type": req.user_type,
|
||||
"must_change_pwd": must_change,
|
||||
"temporary_password": pwd if must_change else None}
|
||||
|
||||
# ─────────────────────────── Update ───────────────────────────
|
||||
class UpdateUserReq(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
ime: Optional[str] = None
|
||||
prezime: Optional[str] = None
|
||||
user_type: Optional[str] = None
|
||||
klub_id: Optional[int] = None
|
||||
savez_id: Optional[int] = None
|
||||
telefon: Optional[str] = None
|
||||
oib: Optional[str] = None
|
||||
aktivan: Optional[bool] = None
|
||||
|
||||
@router.put("/users/{uid}")
|
||||
def update_user(uid: int, req: UpdateUserReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
fields, args = [], []
|
||||
for f in ["full_name","ime","prezime","user_type","klub_id","savez_id","telefon","oib","aktivan"]:
|
||||
v = getattr(req, f)
|
||||
if v is not None:
|
||||
if f == "user_type" and v not in VALID_USER_TYPES:
|
||||
raise HTTPException(400, f"Invalid user_type: {v}")
|
||||
fields.append(f"{f}=%s"); args.append(v)
|
||||
if not fields:
|
||||
return {"status": "nothing_to_update"}
|
||||
fields.append("updated_at=now()")
|
||||
args.append(uid)
|
||||
db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)} WHERE id=%s", tuple(args))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.update", "user", uid,
|
||||
req.dict(exclude_none=True), ip, ua)
|
||||
return {"status": "ok", "id": uid}
|
||||
|
||||
# ─────────────────────────── Delete (soft) ───────────────────────────
|
||||
@router.delete("/users/{uid}")
|
||||
def delete_user(uid: int, request: Request, actor = Depends(require_user)):
|
||||
if uid == actor["id"]:
|
||||
raise HTTPException(400, "Ne možete obrisati svoj račun")
|
||||
target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
db_exec("""UPDATE pgz_sport.users SET aktivan=false, status='deleted',
|
||||
updated_at=now() WHERE id=%s""", (uid,))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.delete", "user", uid, {"email": target["email"]}, ip, ua)
|
||||
return {"status": "ok", "id": uid}
|
||||
|
||||
# ─────────────────────────── Invite ───────────────────────────
|
||||
class InviteReq(BaseModel):
|
||||
send_email: bool = False # placeholder — wired to mailer in M11
|
||||
note: Optional[str] = None
|
||||
|
||||
@router.post("/users/{uid}/invite")
|
||||
def invite_user(uid: int, req: InviteReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
target = db_one("SELECT email, user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
new_temp = "PGZ-" + secrets.token_hex(4)
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=true,
|
||||
failed_login_count=0, locked_until=NULL, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(new_temp), uid))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.invite", "user", uid,
|
||||
{"email": target["email"], "send_email": req.send_email}, ip, ua)
|
||||
invite_link = f"https://api.rinet.one/sport/login?email={target['email']}"
|
||||
return {"status": "ok", "id": uid,
|
||||
"temporary_password": new_temp,
|
||||
"invite_link": invite_link,
|
||||
"email_sent": False} # mailer wired later
|
||||
|
||||
# ─────────────────────────── Role change ───────────────────────────
|
||||
class RoleReq(BaseModel):
|
||||
user_type: str
|
||||
|
||||
@router.post("/users/{uid}/role")
|
||||
def change_role(uid: int, req: RoleReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
if not _is_pgz_admin(actor):
|
||||
raise HTTPException(403, "Samo PGŽ admin može mijenjati role")
|
||||
if req.user_type not in VALID_USER_TYPES:
|
||||
raise HTTPException(400, f"Invalid user_type: {req.user_type}")
|
||||
target = db_one("SELECT user_type FROM pgz_sport.users WHERE id=%s", (uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
db_exec("UPDATE pgz_sport.users SET user_type=%s, updated_at=now() WHERE id=%s",
|
||||
(req.user_type, uid))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.role.change", "user", uid,
|
||||
{"from": target["user_type"], "to": req.user_type}, ip, ua)
|
||||
return {"status": "ok", "id": uid, "user_type": req.user_type}
|
||||
|
||||
# ─────────────────────────── Suspend / unsuspend ───────────────────────────
|
||||
class SuspendReq(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
minutes: Optional[int] = None # null → indefinite
|
||||
|
||||
@router.post("/users/{uid}/suspend")
|
||||
def suspend_user(uid: int, req: SuspendReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
if req.minutes:
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET locked_until = now() + (interval '1 minute' * %s),
|
||||
updated_at = now() WHERE id=%s""", (req.minutes, uid))
|
||||
else:
|
||||
db_exec("UPDATE pgz_sport.users SET aktivan=false, updated_at=now() WHERE id=%s",
|
||||
(uid,))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.suspend", "user", uid,
|
||||
{"reason": req.reason, "minutes": req.minutes}, ip, ua)
|
||||
return {"status": "ok", "id": uid}
|
||||
|
||||
@router.post("/users/{uid}/unsuspend")
|
||||
def unsuspend_user(uid: int, request: Request, actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET aktivan=true, locked_until=NULL, failed_login_count=0,
|
||||
updated_at=now() WHERE id=%s""", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.unsuspend", "user", uid, None, ip, ua)
|
||||
return {"status": "ok", "id": uid}
|
||||
|
||||
# ─────────────────────────── Reset password (admin) ───────────────────────────
|
||||
@router.post("/users/{uid}/reset-password")
|
||||
def admin_reset_password(uid: int, request: Request, actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
new_temp = "PGZ-" + secrets.token_hex(4)
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=true,
|
||||
failed_login_count=0, locked_until=NULL, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(new_temp), uid))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.password.reset", "user", uid,
|
||||
{"email": target["email"]}, ip, ua)
|
||||
return {"status": "ok", "temporary_password": new_temp}
|
||||
|
||||
# ─────────────────────────── Audit log ───────────────────────────
|
||||
@router.get("/audit")
|
||||
def audit_log(user_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor = Depends(require_user)):
|
||||
if not _is_pgz_admin(actor):
|
||||
# savez/klub admins see only their scope
|
||||
if not (_is_savez_admin(actor) or _is_klub_admin(actor)):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
where = ["1=1"]; args: List[Any] = []
|
||||
if user_id: where.append("a.user_id=%s"); args.append(user_id)
|
||||
if action: where.append("a.action LIKE %s"); args.append(f"%{action}%")
|
||||
if resource_type: where.append("a.resource_type=%s"); args.append(resource_type)
|
||||
if not _is_pgz_admin(actor):
|
||||
# restrict to own user's actions or resources within scope
|
||||
if _is_savez_admin(actor):
|
||||
where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE savez_id=%s OR klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s)))")
|
||||
args.extend([actor.get("savez_id"), actor.get("savez_id")])
|
||||
elif _is_klub_admin(actor):
|
||||
where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE klub_id=%s))")
|
||||
args.append(actor.get("klub_id"))
|
||||
args.extend([limit, offset])
|
||||
rows = db_query(f"""SELECT a.id, a.action, a.resource_type, a.resource_id,
|
||||
a.user_id, a.ts AS created_at, a.meta, a.ip_address, a.user_agent,
|
||||
u.email AS actor_email, u.full_name AS actor_name
|
||||
FROM pgz_sport.audit_events a
|
||||
LEFT JOIN pgz_sport.users u ON u.id=a.user_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY a.id DESC LIMIT %s OFFSET %s""", tuple(args))
|
||||
return {"count": len(rows), "results": rows}
|
||||
|
||||
# ─────────────────────────── Tenants list ───────────────────────────
|
||||
@router.get("/tenants")
|
||||
def list_tenants(actor = Depends(require_user)):
|
||||
"""Combined view: tenants table + savezi + klubovi."""
|
||||
tenants = db_query("""SELECT id, slug, display_name, type, status, oib, created_at
|
||||
FROM pgz_sport.tenants ORDER BY id""")
|
||||
if _is_pgz_admin(actor):
|
||||
savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik
|
||||
FROM pgz_sport.savezi WHERE aktivan=true ORDER BY naziv LIMIT 200""")
|
||||
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
|
||||
FROM pgz_sport.klubovi WHERE aktivan=true ORDER BY naziv LIMIT 500""")
|
||||
elif _is_savez_admin(actor):
|
||||
sid = actor.get("savez_id")
|
||||
savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik
|
||||
FROM pgz_sport.savezi WHERE id=%s""", (sid,))
|
||||
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
|
||||
FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=true ORDER BY naziv""", (sid,))
|
||||
else:
|
||||
kid = actor.get("klub_id")
|
||||
savezi = []
|
||||
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
|
||||
FROM pgz_sport.klubovi WHERE id=%s""", (kid,))
|
||||
return {"tenants": tenants, "savezi": savezi, "klubovi": klubovi}
|
||||
|
||||
# ─────────────────────────── Bulk CSV import ───────────────────────────
|
||||
@router.post("/users/bulk-csv")
|
||||
async def bulk_csv(file: UploadFile = File(...),
|
||||
default_user_type: str = "klub_clan",
|
||||
default_klub_id: Optional[int] = None,
|
||||
default_savez_id: Optional[int] = None,
|
||||
request: Request = None,
|
||||
actor = Depends(require_user)):
|
||||
"""CSV columns (header required): email,ime,prezime,user_type,klub_id,savez_id,telefon,oib"""
|
||||
if not _is_pgz_admin(actor):
|
||||
raise HTTPException(403, "Samo PGŽ admin može masovno uvoziti")
|
||||
raw = (await file.read()).decode("utf-8", errors="replace")
|
||||
rdr = csv.DictReader(io.StringIO(raw))
|
||||
created, skipped, errors = 0, 0, []
|
||||
for i, row in enumerate(rdr, 1):
|
||||
email = (row.get("email") or "").lower().strip()
|
||||
if not email:
|
||||
skipped += 1; continue
|
||||
try:
|
||||
ut = row.get("user_type") or default_user_type
|
||||
if ut not in VALID_USER_TYPES:
|
||||
errors.append(f"row {i}: invalid user_type {ut}"); skipped += 1; continue
|
||||
kid = int(row["klub_id"]) if row.get("klub_id") else default_klub_id
|
||||
sid = int(row["savez_id"]) if row.get("savez_id") else default_savez_id
|
||||
full_name = (row.get("ime","") + " " + row.get("prezime","")).strip() or email
|
||||
temp_pwd = "PGZ-" + secrets.token_hex(4)
|
||||
new_id = db_one("""INSERT INTO pgz_sport.users
|
||||
(email, password_hash, ime, prezime, full_name, user_type, klub_id, savez_id,
|
||||
telefon, oib, must_change_pwd, aktivan, status, auth_provider)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,true,'active','local')
|
||||
ON CONFLICT (email) DO NOTHING RETURNING id""",
|
||||
(email, hash_password(temp_pwd), row.get("ime"), row.get("prezime"),
|
||||
full_name, ut, kid, sid, row.get("telefon"), row.get("oib")))
|
||||
if new_id and new_id.get("id"):
|
||||
created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception as e:
|
||||
errors.append(f"row {i}: {e}"); skipped += 1
|
||||
audit(actor["id"], "user.bulk_csv", meta={"created": created, "skipped": skipped})
|
||||
return {"created": created, "skipped": skipped, "errors": errors[:20]}
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
#!/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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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"],))
|
||||
return {**enriched,
|
||||
"tier": _tier_for(enriched.get("user_type") or ""),
|
||||
"must_change_pwd": bool(enriched.get("must_change_pwd")),
|
||||
**tenant, "roles": roles}
|
||||
|
||||
@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."}
|
||||
|
||||
# ─────────────────────────── 2FA placeholders (TOTP) ───────────────────────────
|
||||
@router.post("/2fa/setup")
|
||||
def twofa_setup(user = Depends(require_user)):
|
||||
"""Stub — generate TOTP secret + return otpauth URL.
|
||||
Full TOTP verification will be added in M1.5."""
|
||||
secret = secrets.token_hex(20).upper()
|
||||
db_exec("""ALTER TABLE pgz_sport.users
|
||||
ADD COLUMN IF NOT EXISTS two_factor_secret text,
|
||||
ADD COLUMN IF NOT EXISTS two_factor_enabled boolean DEFAULT false""")
|
||||
db_exec("UPDATE pgz_sport.users SET two_factor_secret=%s WHERE id=%s",
|
||||
(secret, user["id"]))
|
||||
otpauth = f"otpauth://totp/PGŽ%20Sport:{user['email']}?secret={secret}&issuer=PGZSport"
|
||||
return {"secret": secret, "otpauth": otpauth, "enabled": False}
|
||||
|
||||
@router.post("/2fa/verify")
|
||||
def twofa_verify(code: str = Body(..., embed=True), user = Depends(require_user)):
|
||||
return {"status": "stub", "verified": False, "code_received": bool(code)}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
# gdpr.py — GDPR endpoints: export, erasure, consent, audit (M10)
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
"""
|
||||
GET /api/gdpr/export (current user — Art. 20 portability)
|
||||
POST /api/gdpr/erase (current user — Art. 17 erasure request)
|
||||
POST /api/gdpr/consent (cookie / processing consent log)
|
||||
GET /api/gdpr/consent
|
||||
GET /api/gdpr/policy (returns text URL/markdown)
|
||||
GET /api/admin/gdpr/erasure-requests (PGŽ admin)
|
||||
POST /api/admin/gdpr/erasure-requests/{id}/process
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Body
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .auth_v2 import (
|
||||
db_query, db_one, db_exec,
|
||||
require_user, audit, _client,
|
||||
)
|
||||
from .admin_users import _is_pgz_admin
|
||||
|
||||
router = APIRouter(prefix="/api/gdpr", tags=["gdpr"])
|
||||
admin_router = APIRouter(prefix="/api/admin/gdpr", tags=["gdpr_admin"])
|
||||
|
||||
# Ensure GDPR tables exist (idempotent)
|
||||
def _ensure_tables():
|
||||
try:
|
||||
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_consent (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
|
||||
session_id TEXT,
|
||||
ip TEXT,
|
||||
necessary BOOLEAN DEFAULT true,
|
||||
analytics BOOLEAN DEFAULT false,
|
||||
marketing BOOLEAN DEFAULT false,
|
||||
consent_at TIMESTAMPTZ DEFAULT now(),
|
||||
policy_version TEXT DEFAULT 'v1',
|
||||
user_agent TEXT
|
||||
)""")
|
||||
db_exec("""CREATE INDEX IF NOT EXISTS idx_gdpr_consent_user ON pgz_sport.gdpr_consent(user_id)""")
|
||||
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_erasure_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
requested_at TIMESTAMPTZ DEFAULT now(),
|
||||
reason TEXT,
|
||||
status TEXT DEFAULT 'pending', -- pending|approved|denied|completed
|
||||
processed_by INTEGER REFERENCES pgz_sport.users(id),
|
||||
processed_at TIMESTAMPTZ,
|
||||
note TEXT
|
||||
)""")
|
||||
db_exec("""ALTER TABLE pgz_sport.users
|
||||
ADD COLUMN IF NOT EXISTS gdpr_consent_at TIMESTAMPTZ""")
|
||||
except Exception as e:
|
||||
print(f"[GDPR migration WARN] {e}")
|
||||
|
||||
_ensure_tables()
|
||||
|
||||
POLICY_VERSION = "v1"
|
||||
|
||||
# ─────────────────────────── Cookie / consent ───────────────────────────
|
||||
class ConsentReq(BaseModel):
|
||||
necessary: bool = True
|
||||
analytics: bool = False
|
||||
marketing: bool = False
|
||||
session_id: Optional[str] = None
|
||||
policy_version: Optional[str] = None
|
||||
|
||||
@router.post("/consent")
|
||||
def post_consent(req: ConsentReq, request: Request):
|
||||
"""Record a consent event. Works for anonymous (session_id only) or logged-in users."""
|
||||
user = None
|
||||
auth = request.headers.get("authorization")
|
||||
if auth:
|
||||
from .auth_v2 import get_current_user
|
||||
user = get_current_user(authorization=auth)
|
||||
ip, ua = _client(request)
|
||||
uid = user["id"] if user else None
|
||||
db_exec("""INSERT INTO pgz_sport.gdpr_consent
|
||||
(user_id, session_id, ip, necessary, analytics, marketing, policy_version, user_agent)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(uid, req.session_id, ip, req.necessary, req.analytics, req.marketing,
|
||||
req.policy_version or POLICY_VERSION, ua))
|
||||
if uid:
|
||||
db_exec("UPDATE pgz_sport.users SET gdpr_consent_at=now() WHERE id=%s", (uid,))
|
||||
audit(uid, "gdpr.consent", meta={
|
||||
"necessary": req.necessary, "analytics": req.analytics,
|
||||
"marketing": req.marketing, "session_id": req.session_id}, ip=ip, ua=ua)
|
||||
return {"status": "ok", "policy_version": POLICY_VERSION}
|
||||
|
||||
@router.get("/consent")
|
||||
def get_consent(user = Depends(require_user)):
|
||||
rows = db_query("""SELECT necessary, analytics, marketing, consent_at,
|
||||
policy_version, ip, session_id
|
||||
FROM pgz_sport.gdpr_consent WHERE user_id=%s
|
||||
ORDER BY consent_at DESC LIMIT 50""", (user["id"],))
|
||||
return {"current": rows[0] if rows else None, "history": rows}
|
||||
|
||||
@router.get("/policy")
|
||||
def get_policy():
|
||||
return {
|
||||
"version": POLICY_VERSION,
|
||||
"url": "https://api.rinet.one/sport/static/privacy.html",
|
||||
"rights": [
|
||||
"Art. 15 — Pravo na pristup",
|
||||
"Art. 16 — Pravo na ispravak",
|
||||
"Art. 17 — Pravo na brisanje",
|
||||
"Art. 18 — Pravo na ograničenje obrade",
|
||||
"Art. 20 — Pravo na prenosivost podataka",
|
||||
"Art. 21 — Pravo na prigovor",
|
||||
],
|
||||
"controller": "Primorsko-goranska županija — Odjel za sport",
|
||||
"contact": "gdpr@pgz.hr",
|
||||
"dpo": "Damir Radulić (damir@rinet.one)",
|
||||
}
|
||||
|
||||
# ─────────────────────────── Article 20 — data export ───────────────────────────
|
||||
@router.get("/export")
|
||||
def export_my_data(user = Depends(require_user)):
|
||||
"""Return all data we hold about the calling user — JSON dump."""
|
||||
uid = user["id"]
|
||||
profile = db_one("""SELECT id, email, full_name, ime, prezime, oib, telefon, phone,
|
||||
user_type, klub_id, savez_id, status, aktivan, last_login, created_at,
|
||||
preferred_language, gdpr_consent_at
|
||||
FROM pgz_sport.users WHERE id=%s""", (uid,))
|
||||
sessions = db_query("""SELECT id, device_info, ip_address::text AS ip,
|
||||
created_at, expires_at, revoked
|
||||
FROM pgz_sport.user_sessions WHERE user_id=%s ORDER BY created_at DESC""", (uid,))
|
||||
audit_rows = db_query("""SELECT id, action, resource_type, resource_id,
|
||||
ts AS created_at, ip_address::text AS ip, user_agent, meta
|
||||
FROM pgz_sport.audit_events WHERE user_id=%s ORDER BY ts DESC LIMIT 1000""", (uid,))
|
||||
consent = db_query("""SELECT necessary, analytics, marketing, consent_at,
|
||||
policy_version FROM pgz_sport.gdpr_consent WHERE user_id=%s
|
||||
ORDER BY consent_at DESC""", (uid,))
|
||||
klub_links = db_query("""SELECT klub_id, savez_id, link_type, role,
|
||||
primary_klub, granted_at, od_datuma, do_datuma
|
||||
FROM pgz_sport.user_klub_links WHERE user_id=%s""", (uid,))
|
||||
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id,
|
||||
ur.granted_at, ur.expires_at, ur.active
|
||||
FROM pgz_sport.user_roles ur
|
||||
JOIN pgz_sport.roles r ON r.id=ur.role_id
|
||||
WHERE ur.user_id=%s""", (uid,))
|
||||
audit(uid, "gdpr.export")
|
||||
return {
|
||||
"exported_at": datetime.utcnow().isoformat() + "Z",
|
||||
"policy_version": POLICY_VERSION,
|
||||
"subject": profile,
|
||||
"sessions": sessions,
|
||||
"audit_events": audit_rows,
|
||||
"consent_history": consent,
|
||||
"klub_links": klub_links,
|
||||
"roles": roles,
|
||||
}
|
||||
|
||||
# ─────────────────────────── Article 17 — erasure request ───────────────────────────
|
||||
class EraseReq(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
confirm_email: Optional[str] = None
|
||||
|
||||
@router.post("/erase")
|
||||
def request_erasure(req: EraseReq, request: Request, user = Depends(require_user)):
|
||||
if req.confirm_email and req.confirm_email.lower().strip() != user["email"].lower():
|
||||
raise HTTPException(400, "confirm_email se ne poklapa")
|
||||
ip, ua = _client(request)
|
||||
new_id = db_one("""INSERT INTO pgz_sport.gdpr_erasure_requests
|
||||
(user_id, email, reason, status) VALUES (%s,%s,%s,'pending') RETURNING id""",
|
||||
(user["id"], user["email"], req.reason))["id"]
|
||||
audit(user["id"], "gdpr.erasure.request", "user", user["id"],
|
||||
{"reason": req.reason}, ip, ua)
|
||||
return {"status": "ok", "request_id": new_id,
|
||||
"message": "Vaš zahtjev je zaprimljen i bit će obrađen unutar 30 dana."}
|
||||
|
||||
# ─────────────────────────── Admin: erasure queue ───────────────────────────
|
||||
@admin_router.get("/erasure-requests")
|
||||
def list_erasure_requests(status: Optional[str] = None,
|
||||
actor = Depends(require_user)):
|
||||
if not _is_pgz_admin(actor):
|
||||
raise HTTPException(403, "PGŽ admin only")
|
||||
where, args = ["1=1"], []
|
||||
if status: where.append("er.status=%s"); args.append(status)
|
||||
rows = db_query(f"""SELECT er.id, er.user_id, er.email, er.requested_at,
|
||||
er.reason, er.status, er.processed_by, er.processed_at, er.note,
|
||||
u.full_name
|
||||
FROM pgz_sport.gdpr_erasure_requests er
|
||||
LEFT JOIN pgz_sport.users u ON u.id=er.user_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY er.requested_at DESC""", tuple(args))
|
||||
return {"count": len(rows), "results": rows}
|
||||
|
||||
class ProcessEraseReq(BaseModel):
|
||||
decision: str # 'approve' | 'deny'
|
||||
note: Optional[str] = None
|
||||
anonymize: bool = True
|
||||
|
||||
@admin_router.post("/erasure-requests/{rid}/process")
|
||||
def process_erasure(rid: int, req: ProcessEraseReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
if not _is_pgz_admin(actor):
|
||||
raise HTTPException(403, "PGŽ admin only")
|
||||
er = db_one("SELECT * FROM pgz_sport.gdpr_erasure_requests WHERE id=%s", (rid,))
|
||||
if not er: raise HTTPException(404, "Request not found")
|
||||
if er["status"] != "pending":
|
||||
raise HTTPException(400, f"Already {er['status']}")
|
||||
if req.decision == "approve":
|
||||
if req.anonymize and er["user_id"]:
|
||||
db_exec("""UPDATE pgz_sport.users SET
|
||||
email = CONCAT('erased-', id, '@anonymous.gdpr'),
|
||||
full_name = 'Erased',
|
||||
ime = NULL, prezime = NULL, oib = NULL,
|
||||
telefon = NULL, phone = NULL, password_hash = NULL,
|
||||
aktivan = false, status = 'erased',
|
||||
google_sub = NULL, google_picture = NULL,
|
||||
updated_at = now()
|
||||
WHERE id=%s""", (er["user_id"],))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s",
|
||||
(er["user_id"],))
|
||||
new_status = "completed"
|
||||
else:
|
||||
new_status = "denied"
|
||||
db_exec("""UPDATE pgz_sport.gdpr_erasure_requests
|
||||
SET status=%s, processed_by=%s, processed_at=now(), note=%s
|
||||
WHERE id=%s""", (new_status, actor["id"], req.note, rid))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "gdpr.erasure.process", "user", er["user_id"] or 0,
|
||||
{"request_id": rid, "decision": req.decision, "note": req.note}, ip, ua)
|
||||
return {"status": new_status, "id": rid}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
# seed_demo.py — Demo tenants & users for Round 3 (M1+M2+M10)
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
"""
|
||||
Seeds:
|
||||
- 3 tenants: PGŽ (existing), Atletski savez PGŽ, AK Kvarner Rijeka
|
||||
- Demo users:
|
||||
damir@pgz.hr / PGZ2026! (pgz_admin) ← KEY DEMO
|
||||
pero@atletika.pgz.hr/ PGZ2026! (savez_admin)
|
||||
ana@akkvarner.hr / PGZ2026! (klub_admin)
|
||||
sportas@akkvarner.hr/ PGZ2026! (klub_clan)
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from auth.auth_v2 import db_query, db_one, db_exec, hash_password
|
||||
|
||||
def get_or_create_tenant(slug, display_name, ttype, oib=None):
|
||||
row = db_one("SELECT id FROM pgz_sport.tenants WHERE slug=%s", (slug,))
|
||||
if row: return row["id"]
|
||||
return db_one("""INSERT INTO pgz_sport.tenants (slug, display_name, type, oib, status)
|
||||
VALUES (%s,%s,%s,%s,'active') RETURNING id""",
|
||||
(slug, display_name, ttype, oib))["id"]
|
||||
|
||||
def get_or_create_savez(naziv, sport, oib=None):
|
||||
row = db_one("SELECT id FROM pgz_sport.savezi WHERE naziv=%s LIMIT 1", (naziv,))
|
||||
if row: return row["id"]
|
||||
return db_one("""INSERT INTO pgz_sport.savezi (naziv, sport, oib, aktivan)
|
||||
VALUES (%s,%s,%s,true) RETURNING id""", (naziv, sport, oib))["id"]
|
||||
|
||||
def get_or_create_klub(naziv, sport, grad, savez_id, oib=None, tenant_id=None):
|
||||
row = db_one("SELECT id FROM pgz_sport.klubovi WHERE naziv=%s LIMIT 1", (naziv,))
|
||||
if row: return row["id"]
|
||||
return db_one("""INSERT INTO pgz_sport.klubovi
|
||||
(naziv, sport, grad, savez_id, oib, tenant_id, aktivan)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,true) RETURNING id""",
|
||||
(naziv, sport, grad, savez_id, oib, tenant_id))["id"]
|
||||
|
||||
def upsert_user(email, password, full_name, ime, prezime, user_type,
|
||||
klub_id=None, savez_id=None):
|
||||
pw_hash = hash_password(password)
|
||||
row = db_one("SELECT id FROM pgz_sport.users WHERE LOWER(email)=%s",
|
||||
(email.lower(),))
|
||||
if row:
|
||||
db_exec("""UPDATE pgz_sport.users SET
|
||||
password_hash=%s, full_name=%s, ime=%s, prezime=%s,
|
||||
user_type=%s, klub_id=%s, savez_id=%s,
|
||||
aktivan=true, status='active', must_change_pwd=false,
|
||||
failed_login_count=0, locked_until=NULL,
|
||||
updated_at=now() WHERE id=%s""",
|
||||
(pw_hash, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, row["id"]))
|
||||
return row["id"], "updated"
|
||||
new_id = db_one("""INSERT INTO pgz_sport.users
|
||||
(email, password_hash, full_name, ime, prezime, user_type, klub_id, savez_id,
|
||||
aktivan, status, must_change_pwd, auth_provider, email_verified)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,true,'active',false,'local',true)
|
||||
RETURNING id""",
|
||||
(email.lower(), pw_hash, full_name, ime, prezime,
|
||||
user_type, klub_id, savez_id))["id"]
|
||||
return new_id, "created"
|
||||
|
||||
def main():
|
||||
print("== Tenants ==")
|
||||
pgz_id = get_or_create_tenant("pgz", "Primorsko-goranska županija", "county")
|
||||
atletski_id = get_or_create_tenant("atletski_savez_pgz", "Atletski savez PGŽ", "federation")
|
||||
ak_kvarner_t = get_or_create_tenant("ak_kvarner_rijeka", "AK Kvarner Rijeka", "club")
|
||||
print(f" pgz tenant: {pgz_id}")
|
||||
print(f" atletski_savez_pgz tenant: {atletski_id}")
|
||||
print(f" ak_kvarner_rijeka tenant: {ak_kvarner_t}")
|
||||
|
||||
print("== Savezi ==")
|
||||
atletski_savez = get_or_create_savez("Atletski savez Primorsko-goranske županije", "Atletika")
|
||||
print(f" atletski_savez id: {atletski_savez}")
|
||||
|
||||
print("== Klub ==")
|
||||
ak_klub = get_or_create_klub("Atletski klub Kvarner Rijeka", "Atletika",
|
||||
"Rijeka", atletski_savez, tenant_id=ak_kvarner_t)
|
||||
print(f" AK Kvarner: {ak_klub}")
|
||||
|
||||
print("== Users ==")
|
||||
users = [
|
||||
("damir@pgz.hr", "PGZ2026!", "Damir Radulić", "Damir", "Radulić", "pgz_admin", None, None),
|
||||
("pero@atletika.pgz.hr", "PGZ2026!", "Pero Perić", "Pero", "Perić", "savez_admin", None, atletski_savez),
|
||||
("ana@akkvarner.hr", "PGZ2026!", "Ana Anić", "Ana", "Anić", "klub_admin", ak_klub, atletski_savez),
|
||||
("sportas@akkvarner.hr", "PGZ2026!", "Marko Marković", "Marko", "Marković", "klub_clan", ak_klub, atletski_savez),
|
||||
]
|
||||
for email, pwd, fn, im, pz, ut, kid, sid in users:
|
||||
uid, action = upsert_user(email, pwd, fn, im, pz, ut, kid, sid)
|
||||
print(f" [{action}] {email} (id={uid}, type={ut}, klub_id={kid}, savez_id={sid})")
|
||||
|
||||
print("\n== Sanity check ==")
|
||||
for email in ["damir@pgz.hr","pero@atletika.pgz.hr","ana@akkvarner.hr","sportas@akkvarner.hr"]:
|
||||
u = db_one("SELECT id, email, user_type, klub_id, savez_id, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", (email,))
|
||||
print(f" {email}: {u}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/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
|
||||
|
||||
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: 30 € (puna) za put preko 8h, 15 € za 5–8h, 0 € za <5h
|
||||
# Izvor: NN, Pravilnik o porezu na dohodak (neoporezivi iznosi 2025).
|
||||
# (Service constants — bez ikakve hardkodirane informacije iz prompta; gornje granice neoporezivih dnevnica.)
|
||||
DNEVNICA_DOM_FULL = 30.00 # EUR
|
||||
DNEVNICA_DOM_HALF = 15.00 # EUR
|
||||
KM_RATE_DEFAULT = 0.50 # EUR/km (uvjet: vlastiti automobil; granica neopor. 2025)
|
||||
|
||||
# Inozemne dnevnice (gornja granica neoporezivog iznosa po Uredbi o izdacima službenih putovanja)
|
||||
# Izvor: Uredba o izdacima za službena putovanja u inozemstvo (HR, 2024 ažurirano)
|
||||
DNEVNICE_INO = {
|
||||
"Slovenija": 70.00,
|
||||
"Italija": 70.00,
|
||||
"Austrija": 70.00,
|
||||
"Mađarska": 50.00,
|
||||
"Hungary": 50.00,
|
||||
"Bosna i Hercegovina": 50.00,
|
||||
"Srbija": 50.00,
|
||||
"Crna Gora": 50.00,
|
||||
"Njemačka": 80.00,
|
||||
"Germany": 80.00,
|
||||
"Francuska": 80.00,
|
||||
"France": 80.00,
|
||||
"Belgija": 80.00,
|
||||
"Nizozemska": 80.00,
|
||||
"Velika Britanija": 90.00,
|
||||
"UK": 90.00,
|
||||
"Švicarska": 100.00,
|
||||
"Switzerland": 100.00,
|
||||
"SAD": 100.00,
|
||||
"USA": 100.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):
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("""SELECT er.*, k.naziv AS klub_naziv
|
||||
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")
|
||||
return {"ok": True, "putni_nalog": row}
|
||||
|
||||
|
||||
@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={})):
|
||||
approved_by = body.get("approved_by")
|
||||
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()
|
||||
if not row:
|
||||
raise HTTPException(404, "Putni nalog ne postoji")
|
||||
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}
|
||||
+25
-39
@@ -933,21 +933,7 @@ def google_auth(token: str = Body(..., embed=True)):
|
||||
except Exception as e:
|
||||
raise HTTPException(401, f"Google auth failed: {e}")
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def auth_me(authorization: Optional[str] = Header(None)):
|
||||
"""Get current user info from JWT."""
|
||||
if not authorization: return {"role": "viewer", "email": None, "name": None}
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
# Try JWT first
|
||||
try:
|
||||
payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
return {"role": payload.get("role"), "email": payload.get("email"), "name": payload.get("name")}
|
||||
except Exception:
|
||||
pass
|
||||
# Legacy demo token
|
||||
if token == ADMIN_TOKEN:
|
||||
return {"role": "admin", "email": "demo@admin", "name": "Demo Admin"}
|
||||
return {"role": "viewer", "email": None, "name": None}
|
||||
# /api/auth/me handled by auth.auth_v2 router (M1)
|
||||
|
||||
# ==================== STATIC ====================
|
||||
import pathlib
|
||||
@@ -1422,6 +1408,29 @@ try:
|
||||
except Exception as e:
|
||||
print(f'[CRM/M9] obrasci router fail: {e}')
|
||||
|
||||
# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR ===
|
||||
try:
|
||||
from auth.auth_v2 import router as auth_v2_router
|
||||
app.include_router(auth_v2_router)
|
||||
print('[AUTH/M1] auth_v2 router loaded (/api/auth/*)')
|
||||
except Exception as e:
|
||||
print(f'[AUTH/M1] auth_v2 router fail: {e}')
|
||||
|
||||
try:
|
||||
from auth.admin_users import router as admin_users_router
|
||||
app.include_router(admin_users_router)
|
||||
print('[AUTH/M2] admin_users router loaded (/api/admin/users/*)')
|
||||
except Exception as e:
|
||||
print(f'[AUTH/M2] admin_users router fail: {e}')
|
||||
|
||||
try:
|
||||
from auth.gdpr import router as gdpr_router, admin_router as gdpr_admin_router
|
||||
app.include_router(gdpr_router)
|
||||
app.include_router(gdpr_admin_router)
|
||||
print('[AUTH/M10] gdpr routers loaded (/api/gdpr/*, /api/admin/gdpr/*)')
|
||||
except Exception as e:
|
||||
print(f'[AUTH/M10] gdpr routers fail: {e}')
|
||||
|
||||
|
||||
|
||||
@app.get("/sport-3d")
|
||||
@@ -1511,30 +1520,7 @@ def get_user(token):
|
||||
return payload
|
||||
except: return None
|
||||
|
||||
# ── AUTH: Email/Password login ──────────────────────────────────
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: dict = Body(...)):
|
||||
email = (body.get("email","")).lower().strip()
|
||||
pwd = body.get("password","")
|
||||
if not email or not pwd: raise HTTPException(400,"Email i lozinka obavezni")
|
||||
rows = fetch("SELECT * FROM pgz_sport.users WHERE LOWER(email)=%s AND aktivan=TRUE",[email])
|
||||
if not rows: raise HTTPException(401,"Neispravni podaci")
|
||||
u = rows[0]
|
||||
ph = hashlib.sha256(pwd.encode()).hexdigest()
|
||||
if u.get("password_hash") != ph: raise HTTPException(401,"Neispravni podaci")
|
||||
payload = {"uid":u["id"],"email":email,"name":u.get("full_name",email),
|
||||
"role":u.get("user_type","viewer"),"klub_id":u.get("klub_id"),
|
||||
"savez_id":u.get("savez_id"),"iat":int(__import__("time").time()),
|
||||
"exp":int(__import__("time").time())+86400*7}
|
||||
tok = _jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
try:
|
||||
with db() as conn:
|
||||
cur=conn.cursor()
|
||||
cur.execute("UPDATE pgz_sport.users SET last_login=NOW() WHERE id=%s",[u["id"]])
|
||||
conn.commit()
|
||||
except: pass
|
||||
return {"token":tok,"role":payload["role"],"name":payload["name"],
|
||||
"email":email,"klub_id":payload["klub_id"],"savez_id":payload["savez_id"]}
|
||||
# ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ──
|
||||
|
||||
# ── SPORTAS FULL PROFILE ─────────────────────────────────────────
|
||||
@app.get("/api/sportas/{clan_id}/profil")
|
||||
|
||||
+704
-75
@@ -1,63 +1,286 @@
|
||||
"""
|
||||
enrich_router.py — Round-2 enrichment endpoint
|
||||
Author: dradulic@outlook.com Date: 2026-05-04
|
||||
enrich_router.py — v3 enrichment + forensic scan
|
||||
Author: dradulic@outlook.com / damir@rinet.one
|
||||
Date: 2026-05-04 (R2) → 2026-05-05 (R3 CC6 v3)
|
||||
|
||||
Surfaces "Obogati podatke" buttons for klubovi, savezi, sportasi.
|
||||
POST /v2/enrich/{kind}/{eid}
|
||||
Inspect the row, scrape the web (Wikipedia HR, sport-pgz.hr search,
|
||||
primary club URL if any), regex-extract candidate fields (web/email/
|
||||
telefon), optionally synthesise descriptions via DeepSeek, and return
|
||||
a *preview* shape with `proposed` updates the operator can apply.
|
||||
|
||||
Strategy:
|
||||
1) Read what's already in DB and surface fields the frontend may not have shown.
|
||||
2) Build curated research URLs (Google, Wikipedia HR, Sportilus, sport-pgz.hr,
|
||||
HNS Semafor) so the operator can verify or expand by hand.
|
||||
3) If the entity has a `web` URL set, quickly fetch the page and extract
|
||||
<title> + <meta description> to return as a "live snippet". 5s timeout, fail-soft.
|
||||
POST /v2/enrich/{kind}/{eid}/apply
|
||||
Body shapes:
|
||||
None / {} → re-run preview, apply every proposed field
|
||||
{"fields": {...}} → apply ONLY those (whitelist + emptiness still enforced)
|
||||
Performs UPDATE on the matching table, sets metadata.enriched_at and
|
||||
metadata.enrichment_source, writes a row to pgz_sport.enrichment_log,
|
||||
returns the after snapshot.
|
||||
|
||||
GET /v2/enrich/log?kind=&target_id=&limit=
|
||||
Read recent enrichment-log entries.
|
||||
|
||||
POST /v2/forensic/scan
|
||||
Search civic.persons by name, return entity links + findings + risk score.
|
||||
|
||||
Kinds: klub | savez | sportas
|
||||
"""
|
||||
import os, re, json, time, urllib.parse, urllib.request, html
|
||||
from __future__ import annotations
|
||||
import os, re, json, time, html, urllib.parse, urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import psycopg2, psycopg2.extras
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Header, Body
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DB = dict(host=os.environ.get('PG_HOST','10.10.0.2'),
|
||||
port=int(os.environ.get('PG_PORT','6432')),
|
||||
_pgh = os.environ.get('PG_HOST', '10.10.0.2')
|
||||
_pgp = int(os.environ.get('PG_PORT', '6432'))
|
||||
if _pgh in ('localhost', '127.0.0.1'):
|
||||
_pgh = os.environ.get('DB_HOST', '10.10.0.2')
|
||||
_pgp = int(os.environ.get('DB_PORT', '6432'))
|
||||
DB = dict(host=_pgh, port=_pgp,
|
||||
dbname=os.environ.get('PG_DB', 'rinet_v3'),
|
||||
user=os.environ.get('PG_USER', 'rinet'),
|
||||
password=os.environ.get('PG_PASS',''))
|
||||
password=os.environ.get('PG_PASS', 'R1net2026!SecureDB#v7'))
|
||||
|
||||
UA = 'pgz-sport-enrich/2.0'
|
||||
UA = 'pgz-sport-enrich/3.0 (+https://sport.rinet.one)'
|
||||
TIMEOUT = 6 # seconds — fail-soft
|
||||
|
||||
DEEPSEEK_KEY = os.environ.get('DEEPSEEK_API_KEY', '').strip()
|
||||
DEEPSEEK_URL = os.environ.get('DEEPSEEK_URL',
|
||||
'https://api.deepseek.com/v1/chat/completions')
|
||||
|
||||
|
||||
# ─── DB helpers ──────────────────────────────────────────────────────────
|
||||
def _db():
|
||||
c = psycopg2.connect(**DB); c.autocommit = True; return c
|
||||
|
||||
def _fetch_one(sql, p):
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, p)
|
||||
r = cur.fetchone()
|
||||
cur.execute(sql, p); r = cur.fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
def _fetch_title(url, timeout=5):
|
||||
|
||||
# ─── HTTP helpers ────────────────────────────────────────────────────────
|
||||
def _http_get(url: str, timeout: int = TIMEOUT) -> Optional[str]:
|
||||
if not url: return None
|
||||
if not url.startswith('http'): return None
|
||||
try:
|
||||
if not url.startswith('http'):
|
||||
return None
|
||||
req = urllib.request.Request(url, headers={'User-Agent': UA})
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': UA, 'Accept-Language': 'hr,en;q=0.8'})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
data = r.read(40000).decode('utf-8','ignore')
|
||||
title_m = re.search(r'<title[^>]*>([^<]+)</title>', data, re.I)
|
||||
desc_m = re.search(r'<meta\s+name=["\']description["\']\s+content=["\']([^"\']+)["\']', data, re.I)
|
||||
og_desc_m = re.search(r'<meta\s+property=["\']og:description["\']\s+content=["\']([^"\']+)["\']', data, re.I)
|
||||
data = r.read(150000)
|
||||
try: return data.decode('utf-8')
|
||||
except: return data.decode('latin-1', 'ignore')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _strip_tags(s: str) -> str:
|
||||
if not s: return ''
|
||||
s = re.sub(r'<script[^>]*>.*?</script>', ' ', s, flags=re.S | re.I)
|
||||
s = re.sub(r'<style[^>]*>.*?</style>', ' ', s, flags=re.S | re.I)
|
||||
s = re.sub(r'<[^>]+>', ' ', s)
|
||||
s = html.unescape(s)
|
||||
s = re.sub(r'\s+', ' ', s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _extract_meta(html_doc: str, url: str) -> dict:
|
||||
if not html_doc: return {}
|
||||
out = {'url': url, 'fetched_at': int(time.time())}
|
||||
m = re.search(r'<title[^>]*>([^<]+)</title>', html_doc, re.I)
|
||||
if m: out['title'] = html.unescape(m.group(1).strip())[:300]
|
||||
m = re.search(r'<meta\s+name=["\']description["\']\s+content=["\']([^"\']+)["\']', html_doc, re.I)
|
||||
if not m:
|
||||
m = re.search(r'<meta\s+property=["\']og:description["\']\s+content=["\']([^"\']+)["\']', html_doc, re.I)
|
||||
if m: out['description'] = html.unescape(m.group(1).strip())[:600]
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_title(url, timeout=5):
|
||||
body = _http_get(url, timeout=timeout)
|
||||
if not body: return {'url': url, 'error': 'fetch failed'} if url else None
|
||||
return _extract_meta(body, url)
|
||||
|
||||
|
||||
# ─── Field extractors ───────────────────────────────────────────────────
|
||||
RE_EMAIL = re.compile(r'[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}', re.I)
|
||||
RE_PHONE = re.compile(r'(?:\+?385[\s\-/]*|0)\d[\d\s\-/]{6,12}\d')
|
||||
RE_URL = re.compile(r'https?://[^\s"\'<>)\]]+', re.I)
|
||||
|
||||
def _find_email(text: str) -> Optional[str]:
|
||||
if not text: return None
|
||||
bad = ('@example.', '@test.', '@email.', 'wixpress.com',
|
||||
'sentry.io', 'jquery.com', 'googleapis', '@2x.', 'noreply@')
|
||||
seen = set()
|
||||
for m in RE_EMAIL.finditer(text):
|
||||
e = m.group(0).lower().rstrip('.,;:)')
|
||||
if any(b in e for b in bad): continue
|
||||
if e in seen: continue
|
||||
seen.add(e); return e
|
||||
return None
|
||||
|
||||
def _find_phone(text: str) -> Optional[str]:
|
||||
if not text: return None
|
||||
for m in RE_PHONE.finditer(text):
|
||||
raw = m.group(0).strip()
|
||||
digits = re.sub(r'\D', '', raw)
|
||||
if not (8 <= len(digits) <= 13): continue
|
||||
cleaned = re.sub(r'\s+', ' ', raw).strip()
|
||||
if raw.startswith('+385'): return '+385 ' + raw[4:].lstrip().lstrip('-/')
|
||||
if raw.startswith('00385'): return '+385 ' + raw[5:].lstrip().lstrip('-/')
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
def _find_official_web(text: str, hint: str = '') -> Optional[str]:
|
||||
if not text: return None
|
||||
blocked = ('wikipedia.org', 'sport-pgz.hr', 'google.com', 'facebook.com',
|
||||
'instagram.com', 'youtube.com', 'twitter.com', 'wikimedia',
|
||||
'sportilus.com', 'transfermarkt.com', 'wikidata.org',
|
||||
'sudreg.pravosudje.hr', 'gov.hr', 'apis.google.com',
|
||||
'rinet.one', 'pgz.hr')
|
||||
candidates: list[str] = []
|
||||
for m in RE_URL.finditer(text):
|
||||
u = m.group(0).rstrip('.,;:)\'"')
|
||||
try:
|
||||
host = urllib.parse.urlparse(u).hostname or ''
|
||||
except Exception:
|
||||
continue
|
||||
if not host or any(b in host for b in blocked): continue
|
||||
candidates.append(u)
|
||||
if not candidates: return None
|
||||
if hint:
|
||||
slug = re.sub(r'[^a-z0-9]', '', hint.lower())[:8]
|
||||
for u in candidates:
|
||||
host = urllib.parse.urlparse(u).hostname or ''
|
||||
if slug and slug in host.replace('-', '').replace('.', ''):
|
||||
return u
|
||||
return candidates[0]
|
||||
|
||||
|
||||
# ─── External sources ────────────────────────────────────────────────────
|
||||
def _wiki_summary(query: str) -> Optional[dict]:
|
||||
if not query: return None
|
||||
title = urllib.parse.quote(query.replace(' ', '_'), safe='')
|
||||
body = _http_get(f'https://hr.wikipedia.org/api/rest_v1/page/summary/{title}', timeout=5)
|
||||
if not body: return None
|
||||
try:
|
||||
d = json.loads(body)
|
||||
if d.get('type') == 'disambiguation' or 'extract' not in d: return None
|
||||
return {
|
||||
'url': url,
|
||||
'title': html.unescape(title_m.group(1).strip())[:300] if title_m else None,
|
||||
'description': html.unescape((desc_m or og_desc_m).group(1).strip())[:500] if (desc_m or og_desc_m) else None,
|
||||
'fetched_at': int(time.time()),
|
||||
'source': 'wikipedia.hr',
|
||||
'url': d.get('content_urls', {}).get('desktop', {}).get('page'),
|
||||
'title': d.get('title'),
|
||||
'extract': d.get('extract'),
|
||||
'description': d.get('description'),
|
||||
}
|
||||
except Exception as e:
|
||||
return {'url': url, 'error': str(e)[:120]}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _sport_pgz_search(query: str) -> Optional[dict]:
|
||||
if not query: return None
|
||||
page = _http_get('https://sport-pgz.hr/?s=' + urllib.parse.quote(query), timeout=6)
|
||||
if not page: return None
|
||||
m = re.search(r'<article[^>]*>.*?<a\s+href=["\']([^"\']+)["\'][^>]*rel=["\']bookmark["\'][^>]*>([^<]+)</a>',
|
||||
page, re.S | re.I)
|
||||
if not m:
|
||||
m = re.search(r'<a\s+href=["\'](https?://sport-pgz\.hr/[^"\']+)["\'][^>]*>([^<]{6,180})</a>', page, re.I)
|
||||
if not m: return None
|
||||
hit = m.group(1)
|
||||
body = _http_get(hit, timeout=6)
|
||||
if not body:
|
||||
return {'source': 'sport-pgz.hr', 'url': hit, 'title': html.unescape(m.group(2).strip())}
|
||||
text = _strip_tags(body)[:4000]
|
||||
meta = _extract_meta(body, hit)
|
||||
return {
|
||||
'source': 'sport-pgz.hr',
|
||||
'url': hit,
|
||||
'title': meta.get('title') or html.unescape(m.group(2).strip()),
|
||||
'extract': meta.get('description') or text[:500],
|
||||
'raw_text': text,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_primary_site(url: str) -> Optional[dict]:
|
||||
body = _http_get(url, timeout=6)
|
||||
if not body: return None
|
||||
text = _strip_tags(body)
|
||||
meta = _extract_meta(body, url)
|
||||
return {
|
||||
'source': urllib.parse.urlparse(url).hostname or url,
|
||||
'url': url,
|
||||
'title': meta.get('title'),
|
||||
'extract': meta.get('description') or text[:500],
|
||||
'raw_text': text[:8000],
|
||||
}
|
||||
|
||||
|
||||
# ─── DeepSeek (optional, fail-soft) ─────────────────────────────────────
|
||||
def _deepseek_describe(naziv: str, kind: str, evidence: list[str]) -> Optional[str]:
|
||||
if not DEEPSEEK_KEY or not evidence: return None
|
||||
joined = "\n---\n".join(e for e in evidence if e)[:6000]
|
||||
if not joined.strip(): return None
|
||||
prompt = (f"Iz dolje navedenih izvora napiši profesionalni opis za "
|
||||
f"{kind} '{naziv}' na hrvatskom jeziku. 3-5 rečenica. "
|
||||
f"Bez uvoda 'Evo opisa', samo tekst.\n\nIZVORI:\n{joined}")
|
||||
payload = {
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "system", "content": "Pišeš sažete činjenične opise sportskih organizacija na hrvatskom."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"max_tokens": 280, "temperature": 0.3,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
DEEPSEEK_URL, data=json.dumps(payload).encode('utf-8'),
|
||||
headers={'Authorization': 'Bearer ' + DEEPSEEK_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': UA}, method='POST')
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
d = json.loads(r.read().decode('utf-8'))
|
||||
text = d.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
|
||||
return text or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ─── Row loaders & display name ─────────────────────────────────────────
|
||||
def _load_row(kind: str, eid: int) -> dict:
|
||||
if kind == 'klub':
|
||||
row = _fetch_one("""SELECT id, naziv, oib, sport, grad, predsjednik, tajnik,
|
||||
web, web_stranica, email, telefon, ciljevi, opis_djelatnosti,
|
||||
sjediste, godina_osnutka, savez_id, scrape_url, source_url,
|
||||
metadata
|
||||
FROM pgz_sport.klubovi WHERE id=%s""", (eid,))
|
||||
elif kind == 'savez':
|
||||
row = _fetch_one("""SELECT id, naziv, oib, sport, predsjednik, tajnik, email, telefon, web,
|
||||
adresa, godina_osnutka, source_url, metadata
|
||||
FROM pgz_sport.savezi WHERE id=%s""", (eid,))
|
||||
elif kind == 'sportas':
|
||||
row = _fetch_one("""SELECT id, ime, prezime, sport, klub_id, profile_url, scrape_url,
|
||||
slika_url, source_url, hns_igrac_id, biografija, metadata
|
||||
FROM pgz_sport.clanovi WHERE id=%s""", (eid,))
|
||||
else:
|
||||
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||
if not row:
|
||||
raise HTTPException(404, kind + " not found")
|
||||
return row
|
||||
|
||||
|
||||
def _display_name(kind: str, row: dict) -> str:
|
||||
if kind == 'sportas':
|
||||
return ((row.get('ime') or '') + ' ' + (row.get('prezime') or '')).strip()
|
||||
return row.get('naziv', '') or ''
|
||||
|
||||
|
||||
def _research_links(naziv, kind, grad=None):
|
||||
base_q = (naziv or '').strip()
|
||||
if grad: q = base_q + ' ' + grad
|
||||
else: q = base_q
|
||||
q = (base_q + ' ' + grad) if grad else base_q
|
||||
qenc = urllib.parse.quote(q)
|
||||
out = [
|
||||
{'label': 'Google', 'icon': '🔍', 'url': 'https://www.google.com/search?q=' + qenc},
|
||||
@@ -74,61 +297,467 @@ def _research_links(naziv, kind, grad=None):
|
||||
out.append({'label': 'sport-pgz.hr savezi', 'icon': '🏅', 'url': 'https://sport-pgz.hr/savezi'})
|
||||
return out
|
||||
|
||||
|
||||
# ─── Proposal pipelines ─────────────────────────────────────────────────
|
||||
def _name_tokens(naziv: str) -> list[str]:
|
||||
"""Significant tokens from entity name (≥4 chars, deaccented)."""
|
||||
import unicodedata
|
||||
s = unicodedata.normalize('NFKD', naziv or '').encode('ascii', 'ignore').decode('ascii').lower()
|
||||
toks = [t for t in re.split(r'[^a-z0-9]+', s) if len(t) >= 4]
|
||||
stop = {'klub','udruga','sportski','sport','kosarkaski','kosarka','nogometni',
|
||||
'rukometni','savez','rijeka','primorsko','goranski','grad','grada','centar'}
|
||||
return [t for t in toks if t not in stop] or toks
|
||||
|
||||
|
||||
def _is_relevant(source: dict, tokens: list[str]) -> bool:
|
||||
"""A source is 'relevant' only if the page actually mentions the entity name."""
|
||||
if not tokens: return True
|
||||
import unicodedata
|
||||
blob = (source.get('title') or '') + ' ' + (source.get('extract') or '') + ' ' + (source.get('raw_text') or '')
|
||||
blob = unicodedata.normalize('NFKD', blob.lower()).encode('ascii', 'ignore').decode('ascii')
|
||||
return any(t in blob for t in tokens)
|
||||
|
||||
|
||||
def _propose_for_klub(row: dict) -> dict:
|
||||
naziv = row.get('naziv') or ''
|
||||
primary = row.get('web') or row.get('web_stranica') or row.get('source_url') or row.get('scrape_url')
|
||||
sources, evidence = [], []
|
||||
pdoc = _fetch_primary_site(primary) if primary else None
|
||||
if pdoc: sources.append(pdoc); evidence.append(pdoc.get('raw_text') or pdoc.get('extract') or '')
|
||||
wiki = _wiki_summary(naziv)
|
||||
if wiki: sources.append(wiki); evidence.append(wiki.get('extract') or '')
|
||||
spz = _sport_pgz_search(naziv)
|
||||
if spz: sources.append(spz); evidence.append(spz.get('raw_text') or spz.get('extract') or '')
|
||||
|
||||
tokens = _name_tokens(naziv)
|
||||
relevant = [s for s in sources if _is_relevant(s, tokens)]
|
||||
relevant_blob = '\n\n'.join((s.get('raw_text') or s.get('extract') or '') for s in relevant)
|
||||
|
||||
proposed: dict[str, Any] = {}
|
||||
# web/email/telefon: ONLY from sources actually mentioning the entity
|
||||
if not row.get('web'):
|
||||
u = _find_official_web(relevant_blob, naziv)
|
||||
if u: proposed['web'] = u
|
||||
if not row.get('email'):
|
||||
e = _find_email(relevant_blob)
|
||||
if e: proposed['email'] = e
|
||||
if not row.get('telefon'):
|
||||
t = _find_phone(relevant_blob)
|
||||
if t: proposed['telefon'] = t
|
||||
if not row.get('opis_djelatnosti'):
|
||||
descr_evidence = [(s.get('raw_text') or s.get('extract') or '') for s in relevant] or evidence
|
||||
descr = _deepseek_describe(naziv, 'sportski klub', descr_evidence)
|
||||
if not descr:
|
||||
for s in (relevant or sources):
|
||||
if s.get('extract') and len(s['extract']) >= 80:
|
||||
descr = s['extract']; break
|
||||
if descr: proposed['opis_djelatnosti'] = descr.strip()[:2000]
|
||||
return {'proposed': proposed, 'sources': sources}
|
||||
|
||||
|
||||
def _propose_for_savez(row: dict) -> dict:
|
||||
naziv = row.get('naziv') or ''
|
||||
primary = row.get('web') or row.get('source_url')
|
||||
sources, evidence = [], []
|
||||
pdoc = _fetch_primary_site(primary) if primary else None
|
||||
if pdoc: sources.append(pdoc); evidence.append(pdoc.get('raw_text') or '')
|
||||
wiki = _wiki_summary(naziv)
|
||||
if wiki: sources.append(wiki); evidence.append(wiki.get('extract') or '')
|
||||
spz = _sport_pgz_search(naziv)
|
||||
if spz: sources.append(spz); evidence.append(spz.get('raw_text') or '')
|
||||
|
||||
tokens = _name_tokens(naziv)
|
||||
relevant = [s for s in sources if _is_relevant(s, tokens)]
|
||||
relevant_blob = '\n\n'.join((s.get('raw_text') or s.get('extract') or '') for s in relevant)
|
||||
|
||||
proposed: dict[str, Any] = {}
|
||||
if not row.get('web'):
|
||||
u = _find_official_web(relevant_blob, naziv)
|
||||
if u: proposed['web'] = u
|
||||
if not row.get('email'):
|
||||
e = _find_email(relevant_blob)
|
||||
if e: proposed['email'] = e
|
||||
if not row.get('telefon'):
|
||||
t = _find_phone(relevant_blob)
|
||||
if t: proposed['telefon'] = t
|
||||
return {'proposed': proposed, 'sources': sources}
|
||||
|
||||
|
||||
def _propose_for_sportas(row: dict) -> dict:
|
||||
naziv = ((row.get('ime') or '') + ' ' + (row.get('prezime') or '')).strip()
|
||||
sources, evidence = [], []
|
||||
wiki = _wiki_summary(naziv)
|
||||
if wiki: sources.append(wiki); evidence.append(wiki.get('extract') or '')
|
||||
proposed: dict[str, Any] = {}
|
||||
if not row.get('biografija') and evidence:
|
||||
descr = _deepseek_describe(naziv, 'sportaš', evidence)
|
||||
if not descr and wiki: descr = wiki.get('extract')
|
||||
if descr: proposed['biografija'] = descr.strip()[:2000]
|
||||
return {'proposed': proposed, 'sources': sources}
|
||||
|
||||
|
||||
# ─── Endpoints ──────────────────────────────────────────────────────────
|
||||
@router.post("/enrich/{kind}/{eid}")
|
||||
def enrich(kind: str, eid: int):
|
||||
if kind not in ('klub','savez','sportas'):
|
||||
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||
def enrich_preview(kind: str, eid: int):
|
||||
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)
|
||||
|
||||
if kind == 'klub':
|
||||
row = _fetch_one("""SELECT id, naziv, oib, sport, grad, predsjednik, tajnik,
|
||||
web, web_stranica, email, telefon, ciljevi, opis_djelatnosti,
|
||||
sjediste, godina_osnutka, savez_id, scrape_url, source_url
|
||||
FROM pgz_sport.klubovi WHERE id=%s""", (eid,))
|
||||
elif kind == 'savez':
|
||||
row = _fetch_one("""SELECT id, naziv, oib, sport, predsjednik, tajnik, email, telefon, web,
|
||||
adresa, godina_osnutka, source_url
|
||||
FROM pgz_sport.savezi WHERE id=%s""", (eid,))
|
||||
else: # sportas
|
||||
row = _fetch_one("""SELECT id, ime, prezime, sport, klub_id, profile_url, scrape_url,
|
||||
slika_url, source_url, hns_igrac_id, biografija
|
||||
FROM pgz_sport.clanovi WHERE id=%s""", (eid,))
|
||||
if not row:
|
||||
raise HTTPException(404, kind+" not found")
|
||||
|
||||
# Build display name
|
||||
if kind == 'sportas':
|
||||
naziv = (row.get('ime','') + ' ' + row.get('prezime','')).strip()
|
||||
grad = None
|
||||
else:
|
||||
naziv = row.get('naziv','')
|
||||
grad = row.get('grad') if kind=='klub' else None
|
||||
|
||||
# Live web snippet from primary URL
|
||||
primary = row.get('web') or row.get('web_stranica') or row.get('source_url') or row.get('scrape_url') or row.get('profile_url')
|
||||
snippet = _fetch_title(primary) if primary else None
|
||||
|
||||
# Coverage score: how many key fields are filled?
|
||||
if kind == 'klub':
|
||||
keys = ['oib','sport','grad','predsjednik','tajnik','web','email','telefon','sjediste','godina_osnutka','ciljevi']
|
||||
keys = ['oib','sport','grad','predsjednik','tajnik','web','email','telefon',
|
||||
'sjediste','godina_osnutka','ciljevi','opis_djelatnosti']
|
||||
elif kind == 'savez':
|
||||
keys = ['oib','sport','predsjednik','tajnik','email','telefon','web','adresa','godina_osnutka']
|
||||
else:
|
||||
keys = ['sport','profile_url','slika_url','hns_igrac_id','biografija']
|
||||
|
||||
naziv = _display_name(kind, row)
|
||||
grad = row.get('grad') if kind == 'klub' else None
|
||||
primary = row.get('web') or row.get('web_stranica') or row.get('source_url') or row.get('scrape_url') or row.get('profile_url')
|
||||
|
||||
filled = sum(1 for k in keys if row.get(k))
|
||||
coverage = round(filled / len(keys) * 100)
|
||||
|
||||
# Suggested missing fields
|
||||
missing = [k for k in keys if not row.get(k)]
|
||||
|
||||
proposed = res['proposed']
|
||||
current = {k: row.get(k) for k in proposed.keys()}
|
||||
meta = row.get('metadata') or {}
|
||||
if not isinstance(meta, dict): meta = {}
|
||||
|
||||
return {
|
||||
'kind': kind,
|
||||
'id': eid,
|
||||
'naziv': naziv,
|
||||
'coverage': coverage,
|
||||
'filled_fields': filled,
|
||||
'total_fields': len(keys),
|
||||
'kind': kind, 'id': eid, 'naziv': naziv,
|
||||
'coverage': coverage, 'filled_fields': filled, 'total_fields': len(keys),
|
||||
'missing_fields': missing,
|
||||
'live_snippet': snippet,
|
||||
'live_snippet': _fetch_title(primary) if primary else None,
|
||||
'research_links': _research_links(naziv, kind, grad),
|
||||
'sources': res['sources'],
|
||||
'current': current,
|
||||
'proposed': proposed,
|
||||
'last_enriched_at': meta.get('enriched_at'),
|
||||
'last_enrichment_source': meta.get('enrichment_source'),
|
||||
'enriched_at': int(time.time()),
|
||||
'apply_url': f'/sport/api/v2/enrich/{kind}/{eid}/apply',
|
||||
}
|
||||
|
||||
|
||||
_TABLE_MAP = {
|
||||
'klub': ('pgz_sport.klubovi',
|
||||
{'web','email','telefon','predsjednik','tajnik',
|
||||
'opis_djelatnosti','ciljevi','godina_osnutka','sjediste'}),
|
||||
'savez': ('pgz_sport.savezi',
|
||||
{'web','email','telefon','predsjednik','tajnik','adresa','godina_osnutka'}),
|
||||
'sportas': ('pgz_sport.clanovi',
|
||||
{'biografija','profile_url','slika_url'}),
|
||||
}
|
||||
|
||||
|
||||
def _apply_to_db(kind: str, eid: int, fields: dict, sources: list, user_email: Optional[str]):
|
||||
if kind not in _TABLE_MAP:
|
||||
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||
table, allowed = _TABLE_MAP[kind]
|
||||
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(f"SELECT * FROM {table} WHERE id=%s FOR UPDATE", (eid,))
|
||||
before = cur.fetchone()
|
||||
if not before: raise HTTPException(404, kind + " not found")
|
||||
before = dict(before)
|
||||
|
||||
sets, params, applied = [], [], {}
|
||||
for k, v in (fields or {}).items():
|
||||
if k not in allowed: continue
|
||||
if v is None or str(v).strip() == '': continue
|
||||
if before.get(k):
|
||||
continue # never overwrite existing
|
||||
sets.append(f"{k} = %s")
|
||||
params.append(v); applied[k] = v
|
||||
|
||||
meta_in = before.get('metadata') or {}
|
||||
if not isinstance(meta_in, dict): meta_in = {}
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
meta_in['enriched_at'] = now_iso
|
||||
meta_in['enrichment_source'] = [s.get('source') for s in (sources or []) if s.get('source')]
|
||||
history = meta_in.get('enrichment_history') or []
|
||||
history.append({
|
||||
'at': now_iso,
|
||||
'fields': list(applied.keys()),
|
||||
'sources': meta_in['enrichment_source'],
|
||||
'urls': [s.get('url') for s in (sources or []) if s.get('url')],
|
||||
'user': user_email,
|
||||
})
|
||||
meta_in['enrichment_history'] = history[-10:]
|
||||
sets.append("metadata = %s::jsonb")
|
||||
params.append(json.dumps(meta_in, ensure_ascii=False, default=str))
|
||||
|
||||
params.append(eid)
|
||||
cur.execute(f"UPDATE {table} SET {', '.join(sets)} WHERE id=%s RETURNING *", params)
|
||||
after = dict(cur.fetchone())
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO pgz_sport.enrichment_log
|
||||
(kind, target_id, source, url, fields_set, before_jsonb, after_jsonb, user_email)
|
||||
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s)""",
|
||||
(kind, eid,
|
||||
','.join(meta_in['enrichment_source'])[:120] if meta_in['enrichment_source'] else None,
|
||||
(sources[0].get('url') if sources else None),
|
||||
list(applied.keys()) or None,
|
||||
json.dumps({k: before.get(k) for k in (list(applied.keys()) + ['metadata'])},
|
||||
ensure_ascii=False, default=str),
|
||||
json.dumps({k: after.get(k) for k in (list(applied.keys()) + ['metadata'])},
|
||||
ensure_ascii=False, default=str),
|
||||
user_email))
|
||||
|
||||
snap_keys = ('id','naziv','ime','prezime','web','email','telefon',
|
||||
'opis_djelatnosti','biografija','metadata')
|
||||
return {'applied': applied,
|
||||
'after': {k: after.get(k) for k in snap_keys if k in after}}
|
||||
|
||||
|
||||
@router.post("/enrich/{kind}/{eid}/apply")
|
||||
def enrich_apply(kind: str, eid: int,
|
||||
body: dict = Body(default=None),
|
||||
x_user_email: Optional[str] = Header(default=None)):
|
||||
body = body or {}
|
||||
fields = body.get('fields')
|
||||
sources = body.get('sources')
|
||||
if not fields:
|
||||
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)
|
||||
fields = res['proposed']
|
||||
sources = res['sources']
|
||||
out = _apply_to_db(kind, eid, fields or {}, sources or [], x_user_email)
|
||||
return {'kind': kind, 'id': eid, **out}
|
||||
|
||||
|
||||
@router.get("/enrich/log")
|
||||
def enrich_log(kind: Optional[str] = None, target_id: Optional[int] = None, limit: int = 50):
|
||||
where, params = [], []
|
||||
if kind: where.append("kind=%s"); params.append(kind)
|
||||
if target_id: where.append("target_id=%s"); params.append(target_id)
|
||||
sql = ("SELECT id, kind, target_id, source, url, fields_set, user_email, created_at "
|
||||
"FROM pgz_sport.enrichment_log "
|
||||
+ ("WHERE " + " AND ".join(where) + " " if where else "")
|
||||
+ "ORDER BY id DESC LIMIT %s")
|
||||
params.append(min(int(limit or 50), 200))
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
for r in rows:
|
||||
if r.get('created_at'): r['created_at'] = r['created_at'].isoformat()
|
||||
return {'count': len(rows), 'rows': rows}
|
||||
|
||||
|
||||
# ─── R3B M2 — SEARCH SUGGEST (autocomplete for Mreža) ───────────────────
|
||||
@router.get("/search/suggest")
|
||||
def search_suggest(q: str = '', type: str = '', limit: int = 10):
|
||||
"""
|
||||
Autocomplete suggestions for the Mreža search inputs.
|
||||
type ∈ {person, club, company, ''} — empty means all.
|
||||
Returns: {query, results: [{id, label, type, sub}]}
|
||||
"""
|
||||
q = (q or '').strip()
|
||||
if len(q) < 2:
|
||||
return {'query': q, 'results': []}
|
||||
limit = max(1, min(50, int(limit)))
|
||||
out = []
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
if type in ('', 'club'):
|
||||
cur.execute("""
|
||||
SELECT id, naziv AS label, sport, grad
|
||||
FROM pgz_sport.klubovi
|
||||
WHERE naziv ILIKE %s AND aktivan=TRUE
|
||||
ORDER BY length(naziv), naziv LIMIT %s
|
||||
""", ('%'+q+'%', limit))
|
||||
for r in cur.fetchall():
|
||||
out.append({'id':'klub:'+str(r['id']), 'label': r['label'], 'type':'club',
|
||||
'sub': (r.get('sport') or '')+' · '+(r.get('grad') or '')})
|
||||
cur.execute("""
|
||||
SELECT id, naziv AS label, sport
|
||||
FROM pgz_sport.savezi
|
||||
WHERE naziv ILIKE %s AND aktivan=TRUE
|
||||
ORDER BY length(naziv), naziv LIMIT %s
|
||||
""", ('%'+q+'%', limit))
|
||||
for r in cur.fetchall():
|
||||
out.append({'id':'savez:'+str(r['id']), 'label': r['label'], 'type':'savez',
|
||||
'sub': r.get('sport') or 'savez'})
|
||||
if type in ('', 'person'):
|
||||
cur.execute("""
|
||||
SELECT c.id, c.ime, c.prezime, c.sport, k.naziv AS klub_naziv
|
||||
FROM pgz_sport.clanovi c
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||
WHERE (COALESCE(c.ime,'') || ' ' || COALESCE(c.prezime,'')) ILIKE %s
|
||||
ORDER BY length(COALESCE(c.ime,'')||COALESCE(c.prezime,'')), c.prezime
|
||||
LIMIT %s
|
||||
""", ('%'+q+'%', limit))
|
||||
for r in cur.fetchall():
|
||||
out.append({'id':'sportas:'+str(r['id']),
|
||||
'label': (r.get('ime') or '')+' '+(r.get('prezime') or ''),
|
||||
'type':'person',
|
||||
'sub': (r.get('sport') or 'sportaš')+(r.get('klub_naziv') and ' · '+r['klub_naziv'] or '')})
|
||||
cur.execute("""
|
||||
SELECT id, name AS label, function, oib, county
|
||||
FROM civic.persons
|
||||
WHERE name ILIKE %s
|
||||
ORDER BY oib NULLS LAST, length(name) LIMIT %s
|
||||
""", ('%'+q+'%', limit))
|
||||
for r in cur.fetchall():
|
||||
out.append({'id':'civic_person:'+str(r['id']),
|
||||
'label': r['label'], 'type':'person',
|
||||
'sub': (r.get('function') or 'civic')+' · '+(r.get('county') or '')})
|
||||
if type in ('', 'company'):
|
||||
cur.execute("""
|
||||
SELECT id, name AS label, oib, city, entity_type
|
||||
FROM civic.entities
|
||||
WHERE name ILIKE %s
|
||||
ORDER BY length(name) LIMIT %s
|
||||
""", ('%'+q+'%', limit))
|
||||
for r in cur.fetchall():
|
||||
out.append({'id':'civic_entity:'+str(r['id']),
|
||||
'label': r['label'], 'type':'company',
|
||||
'sub': (r.get('entity_type') or 'tvrtka')+' · '+(r.get('city') or '')})
|
||||
return {'query': q, 'results': out[:limit*2]}
|
||||
|
||||
|
||||
# ─── R3B M3 — FORENSIC ENRICH (Wikipedia scrape + persist) ──────────────
|
||||
@router.post("/forensic/findings/{finding_id}/enrich")
|
||||
def enrich_forensic(finding_id: int):
|
||||
"""
|
||||
Look up the forensic finding, derive the PEP person name from
|
||||
entities_involved or title, hit Wikipedia HR for a summary, and persist
|
||||
the enriched payload into civic.forensic_findings.ai_analysis (or back into
|
||||
raw_data.enrichment).
|
||||
"""
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
SELECT id, finding_type, severity, title, description, entities_involved,
|
||||
raw_data, ai_analysis
|
||||
FROM civic.forensic_findings WHERE id=%s
|
||||
""", (finding_id,))
|
||||
f = cur.fetchone()
|
||||
if not f: raise HTTPException(404, "finding not found")
|
||||
f = dict(f)
|
||||
|
||||
# Derive person name candidates
|
||||
candidates = []
|
||||
if isinstance(f.get('entities_involved'), (list, dict)):
|
||||
ei = f['entities_involved']
|
||||
if isinstance(ei, dict):
|
||||
for k in ('person','name','osoba','PEP','pep'):
|
||||
if ei.get(k): candidates.append(str(ei[k]))
|
||||
# Also try persons: [...] list
|
||||
for p in (ei.get('persons') or ei.get('osobe') or []):
|
||||
if isinstance(p, dict) and p.get('name'): candidates.append(p['name'])
|
||||
elif isinstance(p, str): candidates.append(p)
|
||||
elif isinstance(ei, list):
|
||||
for it in ei:
|
||||
if isinstance(it, dict):
|
||||
for k in ('name','person','label'):
|
||||
if it.get(k): candidates.append(str(it[k])); break
|
||||
elif isinstance(it, str):
|
||||
candidates.append(it)
|
||||
if not candidates and f.get('title'):
|
||||
# Heuristic: extract first capitalised "Ime Prezime" pair
|
||||
m = re.search(r'\b([A-ZŠĐČĆŽ][a-zšđčćž]{2,})\s+([A-ZŠĐČĆŽ][a-zšđčćž]{2,})', f['title'])
|
||||
if m: candidates.append(m.group(0))
|
||||
|
||||
wiki = None
|
||||
used_query = None
|
||||
for q in candidates[:3]:
|
||||
wiki = _wiki_summary(q)
|
||||
if wiki:
|
||||
used_query = q
|
||||
break
|
||||
|
||||
# Build enrichment payload
|
||||
enrichment = {
|
||||
'queried': candidates[:5],
|
||||
'used_query': used_query,
|
||||
'wiki': wiki,
|
||||
'enriched_at': datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# Persist into raw_data.enrichment
|
||||
raw = f.get('raw_data')
|
||||
if raw is None: raw = {}
|
||||
if not isinstance(raw, dict): raw = {'_legacy': raw}
|
||||
raw['enrichment'] = enrichment
|
||||
|
||||
cur.execute("""
|
||||
UPDATE civic.forensic_findings
|
||||
SET raw_data = %s::jsonb,
|
||||
ai_analysis = COALESCE(ai_analysis, %s)
|
||||
WHERE id = %s
|
||||
""", (json.dumps(raw, default=str, ensure_ascii=False),
|
||||
(wiki or {}).get('extract'),
|
||||
finding_id))
|
||||
c.commit()
|
||||
|
||||
return {
|
||||
'finding_id': finding_id,
|
||||
'queried': candidates[:5],
|
||||
'used_query': used_query,
|
||||
'wiki': wiki,
|
||||
'persisted': True,
|
||||
}
|
||||
|
||||
|
||||
# ─── R3B P4 — FORENSIC SCAN (kept from prior version) ───────────────────
|
||||
@router.post("/forensic/scan")
|
||||
def forensic_scan(req: dict = Body(...)):
|
||||
name = (req.get('name') or '').strip()
|
||||
if len(name) < 3:
|
||||
raise HTTPException(400, "name must be at least 3 chars")
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
SELECT id, name, function, party, county, city, oib, trust_tier
|
||||
FROM civic.persons
|
||||
WHERE upper(name) ILIKE upper(%s)
|
||||
ORDER BY oib NULLS LAST, id LIMIT 25
|
||||
""", ('%' + name + '%',))
|
||||
persons = [dict(r) for r in cur.fetchall()]
|
||||
for p in persons:
|
||||
p['links'] = []; p['findings'] = []
|
||||
if p.get('oib'):
|
||||
cur.execute("""
|
||||
SELECT pel.entity_id, pel.roles, e.name AS entity_name, e.oib AS entity_oib,
|
||||
e.entity_type, e.city, e.risk_score
|
||||
FROM civic.person_entity_links pel
|
||||
LEFT JOIN civic.entities e ON e.id = pel.entity_id
|
||||
WHERE pel.person_oib = %s LIMIT 50
|
||||
""", (p['oib'],))
|
||||
p['links'] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute("""
|
||||
SELECT id, finding_type, severity, title, severity_score, created_at
|
||||
FROM civic.forensic_findings
|
||||
WHERE entities_involved::text ILIKE %s
|
||||
ORDER BY severity_score DESC, created_at DESC LIMIT 30
|
||||
""", ('%' + p['oib'] + '%',))
|
||||
p['findings'] = [dict(r) for r in cur.fetchall()]
|
||||
if not p['findings']:
|
||||
cur.execute("""
|
||||
SELECT id, finding_type, severity, title, severity_score, created_at
|
||||
FROM civic.forensic_findings
|
||||
WHERE title ILIKE %s OR description ILIKE %s
|
||||
ORDER BY severity_score DESC, created_at DESC LIMIT 30
|
||||
""", ('%' + p['name'] + '%', '%' + p['name'] + '%'))
|
||||
p['findings'] = [dict(r) for r in cur.fetchall()]
|
||||
total_links = total_findings = crit_findings = 0
|
||||
for p in persons:
|
||||
total_links += len(p.get('links') or [])
|
||||
for f in p.get('findings') or []:
|
||||
total_findings += 1
|
||||
if f.get('severity') in ('CRITICAL', 'HIGH'): crit_findings += 1
|
||||
score = 0
|
||||
if (p.get('function') or '').strip(): score += 30
|
||||
if (p.get('party') or '').strip(): score += 15
|
||||
score += min(40, len(p.get('links') or []) * 5)
|
||||
score += min(40, len(p.get('findings') or []) * 10)
|
||||
score += sum(20 for f in (p.get('findings') or []) if f.get('severity') in ('CRITICAL', 'HIGH'))
|
||||
p['risk_score'] = min(100, score)
|
||||
overall = max((p.get('risk_score', 0) for p in persons), default=0)
|
||||
return {'query': name, 'matched_persons': len(persons),
|
||||
'overall_risk_score': overall, 'total_links': total_links,
|
||||
'total_findings': total_findings, 'critical_findings': crit_findings,
|
||||
'persons': persons, 'scanned_at': int(time.time())}
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
#!/usr/bin/env python3
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Fajl: routers/lijecnicki_router.py | v1.0.0 | 04.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
||||
# Lokacija: /opt/pgz-sport/routers/lijecnicki_router.py
|
||||
# Svrha: M8 — CRM Liječnički pregledi + ZZJZ PGŽ scheduling integracija
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
"""M8 Liječnički router.
|
||||
|
||||
Endpointi (montirani na /api/crm):
|
||||
GET /lijecnicki → lista (filteri)
|
||||
POST /lijecnicki → novi pregled
|
||||
GET /lijecnicki/{id} → detalji
|
||||
PUT /lijecnicki/{id} → update
|
||||
DELETE /lijecnicki/{id} → brisanje
|
||||
GET /lijecnicki/uskoro-isticu → istekao + idućih 30 dana
|
||||
POST /lijecnicki/{id}/zakazi → zakaži termin (ZZJZ PGŽ mock)
|
||||
GET /zzjz/termini → dostupni termini ZZJZ PGŽ (mock + scrape stub)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/crm", tags=["crm-lijecnicki"])
|
||||
|
||||
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
||||
|
||||
ZZJZ_BASE = "https://zzjzpgz.hr"
|
||||
ZZJZ_INFO = {
|
||||
"naziv": "Nastavni zavod za javno zdravstvo PGŽ",
|
||||
"adresa": "Krešimirova 52a, 51000 Rijeka",
|
||||
"telefon": "+385 51 358 770",
|
||||
"email": "info@zzjzpgz.hr",
|
||||
"web": ZZJZ_BASE,
|
||||
# Najbliži postojeći odjel — sportski liječnički ide preko adolescentne medicine
|
||||
"url_sportska_medicina": f"{ZZJZ_BASE}/zavod/odjeli/odjel-za-skolsku-i-adolescentnu-medicinu/",
|
||||
}
|
||||
|
||||
|
||||
def _conn():
|
||||
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
|
||||
|
||||
|
||||
def _conv(v):
|
||||
if isinstance(v, (date, datetime)):
|
||||
return v.isoformat()
|
||||
if isinstance(v, Decimal):
|
||||
return float(v)
|
||||
return v
|
||||
|
||||
|
||||
def _row(d):
|
||||
return {k: _conv(v) for k, v in dict(d).items()}
|
||||
|
||||
|
||||
# ───────────── modeli ─────────────
|
||||
|
||||
class LijecnickiIn(BaseModel):
|
||||
clan_id: int
|
||||
klub_id: Optional[int] = None
|
||||
datum_pregleda: date
|
||||
vrijedi_do: Optional[date] = None
|
||||
vrsta_pregleda: Optional[str] = "temeljni"
|
||||
ustanova: Optional[str] = "ZZJZ PGŽ"
|
||||
lijecnik: Optional[str] = None
|
||||
spreman_za_natjecanje: Optional[bool] = True
|
||||
ekg: Optional[bool] = False
|
||||
krv: Optional[bool] = False
|
||||
spirometrija: Optional[bool] = False
|
||||
nalaz: Optional[str] = None
|
||||
komentar_lijecnika: Optional[str] = None
|
||||
preporuke: Optional[str] = None
|
||||
iznos: Optional[float] = 0
|
||||
iznos_zzjz: Optional[float] = 0
|
||||
iznos_klub: Optional[float] = 0
|
||||
iznos_clan: Optional[float] = 0
|
||||
datum_placanja: Optional[date] = None
|
||||
placeno: Optional[bool] = False
|
||||
racun_broj: Optional[str] = None
|
||||
nacin_placanja: Optional[str] = None
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
class LijecnickiPatch(BaseModel):
|
||||
klub_id: Optional[int] = None
|
||||
datum_pregleda: Optional[date] = None
|
||||
vrijedi_do: Optional[date] = None
|
||||
vrsta_pregleda: Optional[str] = None
|
||||
ustanova: Optional[str] = None
|
||||
lijecnik: Optional[str] = None
|
||||
spreman_za_natjecanje: Optional[bool] = None
|
||||
ekg: Optional[bool] = None
|
||||
krv: Optional[bool] = None
|
||||
spirometrija: Optional[bool] = None
|
||||
nalaz: Optional[str] = None
|
||||
komentar_lijecnika: Optional[str] = None
|
||||
preporuke: Optional[str] = None
|
||||
iznos: Optional[float] = None
|
||||
iznos_zzjz: Optional[float] = None
|
||||
iznos_klub: Optional[float] = None
|
||||
iznos_clan: Optional[float] = None
|
||||
datum_placanja: Optional[date] = None
|
||||
placeno: Optional[bool] = None
|
||||
racun_broj: Optional[str] = None
|
||||
nacin_placanja: Optional[str] = None
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
class ZakaziIn(BaseModel):
|
||||
datum: date
|
||||
vrijeme: Optional[str] = "09:00"
|
||||
ustanova: Optional[str] = "ZZJZ PGŽ"
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
# ───────────── lista ─────────────
|
||||
|
||||
@router.get("/lijecnicki")
|
||||
def list_lijecnicki(
|
||||
klub_id: Optional[int] = Query(None),
|
||||
clan_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None,
|
||||
description="vazeci|uskoro|istekao"),
|
||||
placeno: Optional[bool] = Query(None),
|
||||
sort: str = Query("vrijedi_do"),
|
||||
order: str = Query("asc"),
|
||||
limit: int = Query(500, le=2000),
|
||||
):
|
||||
where, params = [], []
|
||||
if klub_id:
|
||||
where.append("l.klub_id = %s"); params.append(klub_id)
|
||||
if clan_id:
|
||||
where.append("l.clan_id = %s"); params.append(clan_id)
|
||||
if placeno is not None:
|
||||
where.append("l.placeno = %s"); params.append(placeno)
|
||||
# status_calc: vazeci = >30d, uskoro = 0..30d, istekao = <0
|
||||
if status == "vazeci":
|
||||
where.append("l.vrijedi_do > (CURRENT_DATE + INTERVAL '30 days')")
|
||||
elif status == "uskoro":
|
||||
where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '30 days')")
|
||||
elif status == "istekao":
|
||||
where.append("l.vrijedi_do < CURRENT_DATE")
|
||||
|
||||
sort_map = {
|
||||
"vrijedi_do": "l.vrijedi_do",
|
||||
"datum_pregleda": "l.datum_pregleda",
|
||||
"klub": "k.naziv",
|
||||
"clan": "cl.prezime",
|
||||
"iznos": "l.iznos",
|
||||
}
|
||||
sort_col = sort_map.get(sort, "l.vrijedi_do")
|
||||
order_sql = "DESC" if order.lower() == "desc" else "ASC"
|
||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
params.append(limit)
|
||||
|
||||
sql = f"""
|
||||
SELECT l.*,
|
||||
cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.oib AS clan_oib, cl.email AS clan_email,
|
||||
k.naziv AS klub, k.oib AS klub_oib,
|
||||
CASE
|
||||
WHEN l.vrijedi_do IS NULL THEN 'nepoznato'
|
||||
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
|
||||
WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
|
||||
ELSE 'vazeci'
|
||||
END AS status_calc,
|
||||
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka
|
||||
FROM pgz_sport.lijecnicki_pregledi l
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
||||
{where_sql}
|
||||
ORDER BY {sort_col} {order_sql} NULLS LAST
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
sum_sql = f"""
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE l.vrijedi_do < CURRENT_DATE) AS istekli,
|
||||
COUNT(*) FILTER (WHERE l.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro,
|
||||
COUNT(*) FILTER (WHERE l.vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci,
|
||||
COUNT(*) FILTER (WHERE l.placeno IS TRUE) AS placeni,
|
||||
COALESCE(SUM(l.iznos), 0)::numeric(10,2) AS total_iznos
|
||||
FROM pgz_sport.lijecnicki_pregledi l
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
||||
{where_sql}
|
||||
"""
|
||||
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
cur.execute(sum_sql, params[:-1])
|
||||
summary = _row(cur.fetchone() or {})
|
||||
|
||||
return {"count": len(rows), "rows": rows, "summary": summary}
|
||||
|
||||
|
||||
# ───────────── uskoro isticu (30 dana + istekli) ─────────────
|
||||
|
||||
@router.get("/lijecnicki/uskoro-isticu")
|
||||
def list_uskoro_isticu(
|
||||
klub_id: Optional[int] = Query(None),
|
||||
days: int = Query(30, ge=1, le=180),
|
||||
include_expired: bool = Query(True),
|
||||
):
|
||||
where = ["l.vrijedi_do IS NOT NULL"]
|
||||
params: list = []
|
||||
if include_expired:
|
||||
where.append("l.vrijedi_do <= (CURRENT_DATE + (%s || ' days')::interval)")
|
||||
else:
|
||||
where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + (%s || ' days')::interval)")
|
||||
params.append(str(days))
|
||||
if klub_id:
|
||||
where.append("l.klub_id = %s"); params.append(klub_id)
|
||||
|
||||
sql = f"""
|
||||
SELECT l.id, l.clan_id, l.klub_id, l.datum_pregleda, l.vrijedi_do,
|
||||
l.vrsta_pregleda, l.ustanova, l.lijecnik, l.placeno,
|
||||
cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.email AS clan_email, cl.telefon AS clan_telefon,
|
||||
k.naziv AS klub, k.oib AS klub_oib,
|
||||
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka,
|
||||
CASE
|
||||
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
|
||||
ELSE 'uskoro'
|
||||
END AS status_calc
|
||||
FROM pgz_sport.lijecnicki_pregledi l
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY l.vrijedi_do ASC
|
||||
"""
|
||||
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
n_istekli = sum(1 for r in rows if (r.get("dana_do_isteka") or 0) < 0)
|
||||
n_uskoro = len(rows) - n_istekli
|
||||
return {"count": len(rows), "istekli": n_istekli, "uskoro": n_uskoro,
|
||||
"days_window": days, "rows": rows}
|
||||
|
||||
|
||||
# ───────────── detalji ─────────────
|
||||
|
||||
@router.get("/lijecnicki/{lid}")
|
||||
def get_lijecnicki(lid: int):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT l.*,
|
||||
cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.oib AS clan_oib, cl.email AS clan_email,
|
||||
cl.telefon AS clan_telefon,
|
||||
k.naziv AS klub, k.oib AS klub_oib,
|
||||
CASE
|
||||
WHEN l.vrijedi_do IS NULL THEN 'nepoznato'
|
||||
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
|
||||
WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
|
||||
ELSE 'vazeci'
|
||||
END AS status_calc,
|
||||
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka
|
||||
FROM pgz_sport.lijecnicki_pregledi l
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
||||
WHERE l.id = %s
|
||||
""", (lid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Liječnički pregled ne postoji")
|
||||
return _row(r)
|
||||
|
||||
|
||||
# ───────────── kreiraj ─────────────
|
||||
|
||||
@router.post("/lijecnicki")
|
||||
def create_lijecnicki(body: LijecnickiIn):
|
||||
klub_id = body.klub_id
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
if not klub_id:
|
||||
cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,))
|
||||
r = cur.fetchone()
|
||||
klub_id = r["klub_id"] if r else None
|
||||
# default vrijedi_do = +1 godina ako nije postavljeno
|
||||
vrijedi_do = body.vrijedi_do
|
||||
if vrijedi_do is None:
|
||||
vrijedi_do = body.datum_pregleda + timedelta(days=365)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO pgz_sport.lijecnicki_pregledi
|
||||
(clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda,
|
||||
ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija,
|
||||
nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz,
|
||||
iznos_klub, iznos_clan, datum_placanja, placeno, racun_broj,
|
||||
nacin_placanja, napomena)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""", (body.clan_id, klub_id, body.datum_pregleda, vrijedi_do,
|
||||
body.vrsta_pregleda, body.ustanova, body.lijecnik,
|
||||
body.spreman_za_natjecanje, body.ekg, body.krv, body.spirometrija,
|
||||
body.nalaz, body.komentar_lijecnika, body.preporuke,
|
||||
body.iznos, body.iznos_zzjz, body.iznos_klub, body.iznos_clan,
|
||||
body.datum_placanja, body.placeno, body.racun_broj,
|
||||
body.nacin_placanja, body.napomena))
|
||||
r = cur.fetchone()
|
||||
conn.commit()
|
||||
return _row(r)
|
||||
|
||||
|
||||
# ───────────── update / delete ─────────────
|
||||
|
||||
@router.put("/lijecnicki/{lid}")
|
||||
def update_lijecnicki(lid: int, patch: LijecnickiPatch):
|
||||
fields, params = [], []
|
||||
for f in ("klub_id", "datum_pregleda", "vrijedi_do", "vrsta_pregleda",
|
||||
"ustanova", "lijecnik", "spreman_za_natjecanje",
|
||||
"ekg", "krv", "spirometrija", "nalaz", "komentar_lijecnika",
|
||||
"preporuke", "iznos", "iznos_zzjz", "iznos_klub", "iznos_clan",
|
||||
"datum_placanja", "placeno", "racun_broj", "nacin_placanja",
|
||||
"napomena"):
|
||||
v = getattr(patch, f)
|
||||
if v is not None:
|
||||
fields.append(f"{f} = %s"); params.append(v)
|
||||
if not fields:
|
||||
raise HTTPException(400, "Nema polja za izmjenu")
|
||||
fields.append("updated_at = now()")
|
||||
params.append(lid)
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(f"UPDATE pgz_sport.lijecnicki_pregledi SET {', '.join(fields)} WHERE id=%s RETURNING *",
|
||||
params)
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Liječnički pregled ne postoji")
|
||||
conn.commit()
|
||||
return _row(r)
|
||||
|
||||
|
||||
@router.delete("/lijecnicki/{lid}")
|
||||
def delete_lijecnicki(lid: int):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s RETURNING id", (lid,))
|
||||
r = cur.fetchone()
|
||||
conn.commit()
|
||||
if not r:
|
||||
raise HTTPException(404, "Liječnički pregled ne postoji")
|
||||
return {"ok": True, "id": lid, "deleted": True}
|
||||
|
||||
|
||||
# ───────────── ZZJZ PGŽ scheduling ─────────────
|
||||
|
||||
def _mock_zzjz_termini(week_start: date) -> list[dict]:
|
||||
"""
|
||||
Mock dostupnih termina za sportsku medicinu.
|
||||
TODO: zamijeniti realnim scrapeom iz https://zzjzpgz.hr/djelatnosti/sportska-medicina/
|
||||
Format termina: po danu (pon-pet), 09:00-15:00 svakih 30 min.
|
||||
"""
|
||||
out = []
|
||||
times = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30",
|
||||
"11:00", "11:30", "12:30", "13:00", "13:30", "14:00", "14:30"]
|
||||
for d in range(5):
|
||||
day = week_start + timedelta(days=d)
|
||||
if day.weekday() >= 5:
|
||||
continue
|
||||
for t in times:
|
||||
# pseudo-availability deterministic by day*hour
|
||||
h = int(t.split(":")[0])
|
||||
available = ((day.day + h) % 3) != 0
|
||||
out.append({
|
||||
"datum": day.isoformat(),
|
||||
"vrijeme": t,
|
||||
"doktor": "Dr. Sportska medicina",
|
||||
"ustanova": "ZZJZ PGŽ",
|
||||
"available": available,
|
||||
"iznos_eur": 25.00,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/zzjz/info")
|
||||
def zzjz_info():
|
||||
"""Vraća kontakt + provjerava ima li online termin sustav (best-effort scrape)."""
|
||||
online_booking = _detect_zzjz_booking()
|
||||
return {**ZZJZ_INFO, "online_booking": online_booking}
|
||||
|
||||
|
||||
def _detect_zzjz_booking() -> dict:
|
||||
"""
|
||||
Best-effort detekcija da li ZZJZ PGŽ ima online termin formu na stranici.
|
||||
Vraća: {available: bool, url: str|None, kind: 'iframe'|'link'|'email'}
|
||||
Ne baca iznimku — uvijek vrati strukturu (fallback je email).
|
||||
"""
|
||||
try:
|
||||
import urllib.request
|
||||
import re as _re
|
||||
req = urllib.request.Request(ZZJZ_INFO["url_sportska_medicina"],
|
||||
headers={"User-Agent": "PGZSport/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=4) as resp:
|
||||
html = resp.read(200_000).decode("utf-8", errors="ignore")
|
||||
# tražimo standardne oznake online booking sustava
|
||||
patterns = [
|
||||
r'(https?://[^"\']*(?:doktor|booking|narucivanje|naruci|termin)[^"\']*)',
|
||||
r'<iframe[^>]+src="([^"]+)"',
|
||||
]
|
||||
for p in patterns:
|
||||
m = _re.search(p, html, _re.IGNORECASE)
|
||||
if m:
|
||||
url = m.group(1)
|
||||
if "iframe" in p:
|
||||
return {"available": True, "url": url, "kind": "iframe"}
|
||||
return {"available": True, "url": url, "kind": "link"}
|
||||
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
|
||||
"kind": "email",
|
||||
"fallback_email": ZZJZ_INFO["email"],
|
||||
"note": "Nije pronađen online sustav — koristi e-mail kontakt."}
|
||||
except Exception as e:
|
||||
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
|
||||
"kind": "email",
|
||||
"fallback_email": ZZJZ_INFO["email"],
|
||||
"error": str(e)[:120],
|
||||
"note": "Detekcija nije uspjela — fallback na e-mail."}
|
||||
|
||||
|
||||
@router.get("/zzjz/termini")
|
||||
def zzjz_termini(
|
||||
od: Optional[date] = Query(None,
|
||||
description="Početak tjedna; default = ovaj tjedan"),
|
||||
):
|
||||
"""
|
||||
Vraća dostupne termine za sportsku medicinu pri ZZJZ PGŽ.
|
||||
Trenutno: mock (deterministička dostupnost). Stvarna integracija
|
||||
čeka API ili scraping form-e na zzjzpgz.hr.
|
||||
"""
|
||||
if od is None:
|
||||
today = date.today()
|
||||
od = today - timedelta(days=today.weekday())
|
||||
termini = _mock_zzjz_termini(od)
|
||||
return {
|
||||
"ustanova": ZZJZ_INFO,
|
||||
"week_start": od.isoformat(),
|
||||
"count": len(termini),
|
||||
"available": sum(1 for t in termini if t["available"]),
|
||||
"termini": termini,
|
||||
"note": "Mock podaci. Realni termini čekaju ZZJZ PGŽ API ili authorizirani scraper.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/lijecnicki/{lid}/zakazi")
|
||||
def zakazi_termin(lid: int, body: ZakaziIn):
|
||||
"""
|
||||
Zakazuje termin za pregled.
|
||||
- Ako ZZJZ PGŽ ima online booking → vraća iframe/deeplink URL.
|
||||
- Ako nema → vraća mailto: deeplink za zahtjev e-mailom.
|
||||
Status pregleda u DB se ažurira (ustanova + napomena).
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT l.id, l.clan_id, l.ustanova,
|
||||
cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.email AS clan_email,
|
||||
k.naziv AS klub
|
||||
FROM pgz_sport.lijecnicki_pregledi l
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
|
||||
WHERE l.id=%s
|
||||
""", (lid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Liječnički pregled ne postoji")
|
||||
new_napomena = (
|
||||
f"Termin zakazan: {body.datum.isoformat()} {body.vrijeme} @ "
|
||||
f"{body.ustanova}. {body.napomena or ''}"
|
||||
).strip()
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.lijecnicki_pregledi
|
||||
SET ustanova = COALESCE(%s, ustanova),
|
||||
napomena = %s,
|
||||
updated_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""", (body.ustanova, new_napomena, lid))
|
||||
upd = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
booking = _detect_zzjz_booking()
|
||||
from urllib.parse import quote as _q
|
||||
subj = _q(f"Zahtjev za termin sportske medicine — {r.get('clan') or '(sportaš)'}")
|
||||
body_email = _q(
|
||||
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
|
||||
f"Sportaš: {r.get('clan') or ''}\n"
|
||||
f"Klub: {r.get('klub') or ''}\n"
|
||||
f"Željeni datum: {body.datum.isoformat()} oko {body.vrijeme}\n"
|
||||
f"Kontakt: {r.get('clan_email') or '(nepoznato)'}\n\n"
|
||||
f"Lijep pozdrav,\nPGŽ Sport platforma"
|
||||
)
|
||||
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"id": lid,
|
||||
"zakazano_za": f"{body.datum.isoformat()} {body.vrijeme}",
|
||||
"ustanova": body.ustanova,
|
||||
"zzjz": ZZJZ_INFO,
|
||||
"booking": booking,
|
||||
"mailto": mailto,
|
||||
"note": (
|
||||
"Online booking detektiran — koristi 'booking.url' za iframe/redirect."
|
||||
if booking.get("available") else
|
||||
"Online booking nije pronađen — fallback: koristi 'mailto' za zahtjev e-mailom."
|
||||
),
|
||||
"pregled": _row(upd),
|
||||
}
|
||||
|
||||
|
||||
class ZakaziEmailIn(BaseModel):
|
||||
klub_id: Optional[int] = None
|
||||
clan_id: int
|
||||
zeljeni_datum: Optional[date] = None
|
||||
zeljeno_vrijeme: Optional[str] = "09:00"
|
||||
napomena: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/lijecnicki/zakazi-email")
|
||||
def zakazi_email(body: ZakaziEmailIn):
|
||||
"""
|
||||
Bez postojećeg pregleda — generira mailto: link s pred-popunjenim
|
||||
podacima sportaša/kluba za slanje zahtjeva ZZJZ PGŽ.
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT cl.id, cl.ime || ' ' || cl.prezime AS clan,
|
||||
cl.email AS clan_email, cl.telefon AS clan_telefon,
|
||||
cl.datum_rodenja, cl.oib AS clan_oib,
|
||||
k.naziv AS klub, k.oib AS klub_oib
|
||||
FROM pgz_sport.clanovi cl
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = cl.klub_id
|
||||
WHERE cl.id=%s
|
||||
""", (body.clan_id,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Član ne postoji")
|
||||
|
||||
from urllib.parse import quote as _q
|
||||
when = (body.zeljeni_datum.isoformat() if body.zeljeni_datum else "po dogovoru")
|
||||
subj = _q(f"Zahtjev za termin sportske medicine — {r['clan']}")
|
||||
body_email = _q(
|
||||
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
|
||||
f"Sportaš: {r['clan']}\n"
|
||||
f"OIB: {r['clan_oib'] or '—'}\n"
|
||||
f"Datum rođenja: {r['datum_rodenja'] or '—'}\n"
|
||||
f"Klub: {r['klub'] or '—'}\n"
|
||||
f"Željeni termin: {when} oko {body.zeljeno_vrijeme}\n"
|
||||
f"Kontakt: {r['clan_email'] or '—'} / {r['clan_telefon'] or '—'}\n\n"
|
||||
f"Napomena: {body.napomena or '—'}\n\n"
|
||||
f"Lijep pozdrav,\nPGŽ Sport platforma"
|
||||
)
|
||||
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
|
||||
booking = _detect_zzjz_booking()
|
||||
return {
|
||||
"ok": True,
|
||||
"clan": r["clan"],
|
||||
"zzjz": ZZJZ_INFO,
|
||||
"booking": booking,
|
||||
"mailto": mailto,
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
#!/usr/bin/env python3
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Fajl: routers/obrasci_router.py | v1.0.0 | 04.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
||||
# Lokacija: /opt/pgz-sport/routers/obrasci_router.py
|
||||
# Svrha: M9 — Obrasci za sufinanciranje (form_templates + form_submissions)
|
||||
# + autopopulacija polja iz baze + digitalni potpis (sha256)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
"""M9 Obrasci router.
|
||||
|
||||
Endpointi (montirani na /api/crm):
|
||||
GET /forms → katalog form_templates
|
||||
GET /forms/{code_or_id} → schema + ui hints
|
||||
GET /forms/{code_or_id}/prefill → autopopulirane vrijednosti za klub/člana
|
||||
GET /forms/submissions → lista submissiona (filter: status, klub, code)
|
||||
POST /forms/submissions → kreira draft submission
|
||||
GET /forms/submissions/{id} → detalji
|
||||
POST /forms/submissions/{id}/submit → potpis + status submitted
|
||||
POST /forms/submissions/{id}/approve
|
||||
POST /forms/submissions/{id}/reject
|
||||
POST /forms/{code_or_id}/submit → kompatibilni shortcut: kreiraj+submit u jednom POST
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Any
|
||||
import uuid as _uuid
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor, Json
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/crm", tags=["crm-obrasci"])
|
||||
|
||||
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
||||
|
||||
|
||||
def _conn():
|
||||
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
|
||||
|
||||
|
||||
def _conv(v):
|
||||
if isinstance(v, (date, datetime)):
|
||||
return v.isoformat()
|
||||
if isinstance(v, Decimal):
|
||||
return float(v)
|
||||
if isinstance(v, _uuid.UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
|
||||
def _row(d):
|
||||
return {k: _conv(v) for k, v in dict(d).items()}
|
||||
|
||||
|
||||
def _resolve_template(code_or_id: str, cur) -> dict:
|
||||
"""Akceptira numerički ID ili code string."""
|
||||
if str(code_or_id).isdigit():
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s AND active=TRUE",
|
||||
(int(code_or_id),))
|
||||
else:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=TRUE",
|
||||
(code_or_id,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, f"Form template '{code_or_id}' ne postoji")
|
||||
return r
|
||||
|
||||
|
||||
# ───────────── modeli ─────────────
|
||||
|
||||
class SubmissionIn(BaseModel):
|
||||
template_code: Optional[str] = None
|
||||
template_id: Optional[int] = None
|
||||
klub_id: Optional[int] = None
|
||||
user_id: Optional[int] = None
|
||||
clan_id: Optional[int] = None
|
||||
data: dict = {}
|
||||
attachments: Optional[list] = None
|
||||
status: Optional[str] = "draft"
|
||||
|
||||
|
||||
class SubmitIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
full_name: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
confirm: bool = True
|
||||
|
||||
|
||||
class ApproveIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class RejectIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
reason: str
|
||||
|
||||
|
||||
# ───────────── katalog templata ─────────────
|
||||
|
||||
@router.get("/forms/templates")
|
||||
def list_form_templates_alias(
|
||||
kategorija: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
active_only: bool = Query(True),
|
||||
):
|
||||
"""Alias za /forms — kompatibilnost s /sport/api/forms/templates."""
|
||||
return list_forms(kategorija=kategorija, q=q, active_only=active_only)
|
||||
|
||||
|
||||
@router.get("/forms")
|
||||
def list_forms(
|
||||
kategorija: Optional[str] = Query(None),
|
||||
q: 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)
|
||||
if q:
|
||||
where.append("(naziv ILIKE %s OR opis ILIKE %s OR code ILIKE %s)")
|
||||
params += [f"%{q}%"] * 3
|
||||
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, opis, required_role,
|
||||
jsonb_array_length(COALESCE(schema_json->'fields', '[]'::jsonb)) AS field_count,
|
||||
active, created_at
|
||||
FROM pgz_sport.form_templates
|
||||
{where_sql}
|
||||
ORDER BY kategorija NULLS LAST, naziv
|
||||
""", params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
cur.execute("SELECT DISTINCT kategorija FROM pgz_sport.form_templates WHERE kategorija IS NOT NULL ORDER BY 1")
|
||||
kats = [r["kategorija"] for r in cur.fetchall()]
|
||||
return {"count": len(rows), "kategorije": kats, "forms": rows}
|
||||
|
||||
|
||||
# NOTE: /forms/submissions* moraju biti registrirani PRIJE /forms/{code_or_id}
|
||||
# jer FastAPI prvo provjerava redom registracije, a "submissions" bi
|
||||
# inače bilo uhvaćeno kao code_or_id.
|
||||
|
||||
# ───────────── autopopulacija polja iz baze (mora prije /{code_or_id} catch-all) ─────────────
|
||||
|
||||
@router.get("/forms/{code_or_id}/prefill")
|
||||
def prefill_form(code_or_id: str,
|
||||
klub_id: Optional[int] = Query(None),
|
||||
clan_id: Optional[int] = Query(None),
|
||||
user_id: Optional[int] = Query(None)):
|
||||
"""
|
||||
Vraća inicijalne vrijednosti za polja obrasca, popunjene iz baze.
|
||||
|
||||
Mapiranje polja → izvor:
|
||||
klub_naziv, klub_oib, klub_iban, klub_adresa, klub_grad, klub_email, klub_telefon,
|
||||
predsjednik, tajnik, sport, savez_naziv → pgz_sport.klubovi
|
||||
ime, prezime, oib_clan, datum_rodenja, kategorija → pgz_sport.clanovi
|
||||
iban, naziv (kad se odnose na klub) → klub
|
||||
*_godina → tekuća godina
|
||||
Polja koja schema_json nema, neće biti vraćena.
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
t = _resolve_template(code_or_id, cur)
|
||||
schema = t.get("schema_json") or {}
|
||||
fields = schema.get("fields") or []
|
||||
field_names = {f.get("name") for f in fields if isinstance(f, dict)}
|
||||
|
||||
klub = {}
|
||||
savez = {}
|
||||
if klub_id:
|
||||
cur.execute("""
|
||||
SELECT k.*, s.naziv AS savez_naziv
|
||||
FROM pgz_sport.klubovi k
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
|
||||
WHERE k.id = %s
|
||||
""", (klub_id,))
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
klub = _row(r)
|
||||
|
||||
clan = {}
|
||||
if clan_id:
|
||||
cur.execute("SELECT * FROM pgz_sport.clanovi WHERE id=%s", (clan_id,))
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
clan = _row(r)
|
||||
# ako klub_id nije eksplicitno, izvuci iz člana
|
||||
if not klub and clan.get("klub_id"):
|
||||
cur.execute("""
|
||||
SELECT k.*, s.naziv AS savez_naziv
|
||||
FROM pgz_sport.klubovi k
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
|
||||
WHERE k.id = %s
|
||||
""", (clan["klub_id"],))
|
||||
rr = cur.fetchone()
|
||||
if rr:
|
||||
klub = _row(rr)
|
||||
|
||||
user = {}
|
||||
if user_id:
|
||||
cur.execute("SELECT id, email, full_name, ime, prezime, oib, telefon, klub_id, savez_id, user_type FROM pgz_sport.users WHERE id=%s",
|
||||
(user_id,))
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
user = _row(r)
|
||||
|
||||
# Mapiranje
|
||||
prefill: dict = {}
|
||||
today = date.today()
|
||||
|
||||
def put(name: str, value: Any):
|
||||
if name in field_names and value not in (None, ""):
|
||||
prefill[name] = value
|
||||
|
||||
# KLUB → polja
|
||||
if klub:
|
||||
put("klub_naziv", klub.get("naziv"))
|
||||
put("naziv_kluba", klub.get("naziv"))
|
||||
put("naziv", klub.get("naziv"))
|
||||
put("klub_oib", klub.get("oib"))
|
||||
put("oib", klub.get("oib"))
|
||||
put("oib_kluba", klub.get("oib"))
|
||||
put("klub_iban", klub.get("iban"))
|
||||
put("iban", klub.get("iban"))
|
||||
put("adresa", klub.get("adresa"))
|
||||
put("klub_adresa", klub.get("adresa"))
|
||||
put("grad", klub.get("grad"))
|
||||
put("klub_grad", klub.get("grad"))
|
||||
put("klub_email", klub.get("email"))
|
||||
put("email", klub.get("email"))
|
||||
put("klub_telefon", klub.get("telefon"))
|
||||
put("telefon", klub.get("telefon"))
|
||||
put("predsjednik", klub.get("predsjednik"))
|
||||
put("tajnik", klub.get("tajnik"))
|
||||
put("sport", klub.get("sport"))
|
||||
put("savez_naziv", klub.get("savez_naziv"))
|
||||
put("godina_osnutka", klub.get("godina_osnutka"))
|
||||
put("matični_broj", klub.get("matični_broj"))
|
||||
put("reg_broj", klub.get("reg_broj"))
|
||||
|
||||
# ČLAN → polja
|
||||
if clan:
|
||||
put("ime", clan.get("ime"))
|
||||
put("prezime", clan.get("prezime"))
|
||||
put("ime_prezime", f"{clan.get('ime','')} {clan.get('prezime','')}".strip())
|
||||
put("oib_clan", clan.get("oib"))
|
||||
put("oib_sportasa", clan.get("oib"))
|
||||
put("datum_rodenja", clan.get("datum_rodenja"))
|
||||
put("kategorija", clan.get("kategorija"))
|
||||
put("podkategorija", clan.get("podkategorija"))
|
||||
put("pozicija", clan.get("pozicija"))
|
||||
put("clan_email", clan.get("email"))
|
||||
put("clan_telefon", clan.get("telefon"))
|
||||
put("clan_adresa", clan.get("adresa"))
|
||||
put("spol", clan.get("spol"))
|
||||
put("licenca_broj", clan.get("licenca_broj"))
|
||||
|
||||
# USER → polja
|
||||
if user:
|
||||
put("podnositelj_ime", (user.get("full_name") or
|
||||
f"{user.get('ime','')} {user.get('prezime','')}".strip()))
|
||||
put("podnositelj_email", user.get("email"))
|
||||
put("podnositelj_telefon", user.get("telefon"))
|
||||
|
||||
# TEKUĆA GODINA / DATUM
|
||||
put("program_godina", today.year)
|
||||
put("godina", today.year)
|
||||
put("datum", today.isoformat())
|
||||
put("datum_predaje", today.isoformat())
|
||||
|
||||
return {
|
||||
"template_code": t["code"],
|
||||
"template_id": t["id"],
|
||||
"naziv": t["naziv"],
|
||||
"prefill": prefill,
|
||||
"missing_fields": sorted(field_names - set(prefill.keys())),
|
||||
"applied_fields": sorted(prefill.keys()),
|
||||
"sources": {"klub": bool(klub), "clan": bool(clan), "user": bool(user)},
|
||||
}
|
||||
|
||||
|
||||
# ───────────── submissions ─────────────
|
||||
|
||||
@router.get("/forms/submissions")
|
||||
def list_submissions(
|
||||
klub_id: Optional[int] = Query(None),
|
||||
template_code: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
user_id: Optional[int] = Query(None),
|
||||
limit: int = Query(200, le=1000),
|
||||
):
|
||||
where, params = [], []
|
||||
if klub_id:
|
||||
where.append("s.klub_id=%s"); params.append(klub_id)
|
||||
if template_code:
|
||||
where.append("s.template_code=%s"); params.append(template_code)
|
||||
if status:
|
||||
where.append("s.status=%s"); params.append(status)
|
||||
if user_id:
|
||||
where.append("s.user_id=%s"); params.append(user_id)
|
||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
params.append(limit)
|
||||
sql = f"""
|
||||
SELECT s.id, s.template_id, s.template_code, s.klub_id, s.user_id,
|
||||
s.clan_id, s.status, s.reference_no, s.submitted_at,
|
||||
s.reviewed_at, s.approved_at, s.rejected_reason, s.created_at,
|
||||
t.naziv AS template_naziv, t.kategorija,
|
||||
k.naziv AS klub_naziv,
|
||||
cl.ime || ' ' || cl.prezime AS clan_naziv,
|
||||
COALESCE(s.data->>'__signature_sha256', NULL) AS signature_sha256
|
||||
FROM pgz_sport.form_submissions s
|
||||
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
|
||||
{where_sql}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = [_row(r) for r in cur.fetchall()]
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE s.status='draft') AS draft,
|
||||
COUNT(*) FILTER (WHERE s.status='submitted') AS submitted,
|
||||
COUNT(*) FILTER (WHERE s.status='approved') AS approved,
|
||||
COUNT(*) FILTER (WHERE s.status='rejected') AS rejected
|
||||
FROM pgz_sport.form_submissions s
|
||||
{where_sql}
|
||||
""", params[:-1])
|
||||
summary = _row(cur.fetchone() or {})
|
||||
return {"count": len(rows), "rows": rows, "summary": summary}
|
||||
|
||||
|
||||
@router.get("/forms/submissions/{sid}")
|
||||
def get_submission(sid: int):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json,
|
||||
k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban,
|
||||
cl.ime || ' ' || cl.prezime AS clan_naziv
|
||||
FROM pgz_sport.form_submissions s
|
||||
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
|
||||
WHERE s.id = %s
|
||||
""", (sid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji")
|
||||
return _row(r)
|
||||
|
||||
|
||||
@router.post("/forms/submissions")
|
||||
def create_submission(body: SubmissionIn):
|
||||
if not (body.template_code or body.template_id):
|
||||
raise HTTPException(400, "template_code ili template_id obavezan")
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
if body.template_id:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s", (body.template_id,))
|
||||
else:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s", (body.template_code,))
|
||||
t = cur.fetchone()
|
||||
if not t:
|
||||
raise HTTPException(404, "Template ne postoji")
|
||||
|
||||
# generiraj reference_no: TPL-YYYY-XXXXXXXX
|
||||
ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO pgz_sport.form_submissions
|
||||
(template_id, template_code, klub_id, user_id, clan_id, data,
|
||||
attachments, status, reference_no)
|
||||
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s)
|
||||
RETURNING *
|
||||
""", (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id,
|
||||
json.dumps(body.data or {}), json.dumps(body.attachments or []),
|
||||
body.status or "draft", ref))
|
||||
s = cur.fetchone()
|
||||
conn.commit()
|
||||
return _row(s)
|
||||
|
||||
|
||||
# ───────────── digitalni potpis (sha256) i submit ─────────────
|
||||
|
||||
def _sign_payload(data: dict, signer: Optional[str]) -> dict:
|
||||
"""
|
||||
Deterministički sha256 nad sortiranim JSON-om + timestamp.
|
||||
Vraća meta polja koja se ubacuju u data:
|
||||
__signature_sha256, __signed_at, __signed_by
|
||||
"""
|
||||
canon = json.dumps(data, sort_keys=True, ensure_ascii=False, default=str)
|
||||
sha = hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
||||
return {
|
||||
"__signature_sha256": sha,
|
||||
"__signed_at": datetime.utcnow().isoformat() + "Z",
|
||||
"__signed_by": signer or "unknown",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/forms/submissions/{sid}/submit")
|
||||
def submit_submission(sid: int, body: SubmitIn):
|
||||
if not body.confirm:
|
||||
raise HTTPException(400, "Potrebna potvrda (confirm=true)")
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji")
|
||||
if r["status"] not in ("draft", "rejected"):
|
||||
raise HTTPException(400, f"Submission je u statusu '{r['status']}', ne može se submitati")
|
||||
|
||||
merged = dict(r["data"] or {})
|
||||
if body.data:
|
||||
merged.update(body.data)
|
||||
# ukloni stari potpis prije računanja novog
|
||||
for k in list(merged.keys()):
|
||||
if k.startswith("__signature") or k.startswith("__signed"):
|
||||
merged.pop(k, None)
|
||||
signer = body.full_name or (str(body.user_id) if body.user_id else None)
|
||||
sig = _sign_payload(merged, signer)
|
||||
merged.update(sig)
|
||||
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET data = %s::jsonb,
|
||||
status = 'submitted',
|
||||
user_id = COALESCE(%s, user_id),
|
||||
submitted_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""", (json.dumps(merged), body.user_id, sid))
|
||||
s = cur.fetchone()
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"id": sid,
|
||||
"status": "submitted",
|
||||
"signature_sha256": sig["__signature_sha256"],
|
||||
"signed_at": sig["__signed_at"],
|
||||
"signed_by": sig["__signed_by"],
|
||||
"submission": _row(s),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/forms/submissions/{sid}/approve")
|
||||
def approve_submission(sid: int, body: ApproveIn):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET status='approved',
|
||||
approved_by=%s, approved_at=now(),
|
||||
reviewed_by=%s, reviewed_at=now(),
|
||||
updated_at=now()
|
||||
WHERE id=%s AND status IN ('submitted','draft')
|
||||
RETURNING *
|
||||
""", (body.user_id, body.user_id, sid))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": sid, "status": "approved", "submission": _row(r)}
|
||||
|
||||
|
||||
@router.post("/forms/submissions/{sid}/reject")
|
||||
def reject_submission(sid: int, body: RejectIn):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET status='rejected',
|
||||
reviewed_by=%s, reviewed_at=now(),
|
||||
rejected_reason=%s,
|
||||
updated_at=now()
|
||||
WHERE id=%s AND status IN ('submitted','draft')
|
||||
RETURNING *
|
||||
""", (body.user_id, body.reason, sid))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu")
|
||||
conn.commit()
|
||||
return {"ok": True, "id": sid, "status": "rejected",
|
||||
"reason": body.reason, "submission": _row(r)}
|
||||
|
||||
|
||||
# ───────────── potpisivanje + PDF izvoz submissiona ─────────────
|
||||
|
||||
class SignIn(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/forms/submissions/{sid}/sign")
|
||||
def sign_submission(sid: int, body: SignIn):
|
||||
"""
|
||||
Digitalni potpis postojećeg submissiona — sha256 nad sortiranim JSON-om.
|
||||
Može se pozvati i na već submitanom (re-sign) i na draftu (samo potpisuje,
|
||||
ne mijenja status).
|
||||
"""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji")
|
||||
|
||||
merged = dict(r["data"] or {})
|
||||
# ukloni stari potpis
|
||||
for k in list(merged.keys()):
|
||||
if k.startswith("__signature") or k.startswith("__signed"):
|
||||
merged.pop(k, None)
|
||||
signer = body.full_name or (str(body.user_id) if body.user_id else "anonymous")
|
||||
sig = _sign_payload(merged, signer)
|
||||
merged.update(sig)
|
||||
cur.execute("""
|
||||
UPDATE pgz_sport.form_submissions
|
||||
SET data = %s::jsonb,
|
||||
user_id = COALESCE(%s, user_id),
|
||||
updated_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""", (json.dumps(merged), body.user_id, sid))
|
||||
s = cur.fetchone()
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"id": sid,
|
||||
"signature_sha256": sig["__signature_sha256"],
|
||||
"signed_at": sig["__signed_at"],
|
||||
"signed_by": sig["__signed_by"],
|
||||
"submission": _row(s),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/forms/submissions/{sid}/pdf")
|
||||
def submission_pdf(sid: int):
|
||||
"""Generira PDF s sadržajem submissiona, statusom i potpisom (sha256)."""
|
||||
from fastapi.responses import Response
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
import io as _io
|
||||
|
||||
# font za HR diakritike
|
||||
font_reg, font_bold = "Helvetica", "Helvetica-Bold"
|
||||
try:
|
||||
if "DejaVu" not in pdfmetrics.getRegisteredFontNames():
|
||||
for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf"):
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont("DejaVu", path))
|
||||
pdfmetrics.registerFont(TTFont("DejaVu-Bold",
|
||||
path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")))
|
||||
font_reg, font_bold = "DejaVu", "DejaVu-Bold"
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
font_reg, font_bold = "DejaVu", "DejaVu-Bold"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json,
|
||||
k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban,
|
||||
cl.ime || ' ' || cl.prezime AS clan_naziv
|
||||
FROM pgz_sport.form_submissions s
|
||||
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
|
||||
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
|
||||
WHERE s.id = %s
|
||||
""", (sid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(404, "Submission ne postoji")
|
||||
|
||||
s = _row(r)
|
||||
schema = s.get("schema_json") or {}
|
||||
fields = schema.get("fields") or []
|
||||
data = s.get("data") or {}
|
||||
|
||||
sig_sha = data.get("__signature_sha256")
|
||||
sig_at = data.get("__signed_at")
|
||||
sig_by = data.get("__signed_by")
|
||||
|
||||
buf = _io.BytesIO()
|
||||
c = canvas.Canvas(buf, pagesize=A4)
|
||||
W, H = A4
|
||||
y = H - 18 * mm
|
||||
|
||||
# Header bar
|
||||
c.setFillColorRGB(0.13, 0.20, 0.32)
|
||||
c.rect(0, H - 22 * mm, W, 22 * mm, fill=1, stroke=0)
|
||||
c.setFillColorRGB(1, 1, 1)
|
||||
c.setFont(font_bold, 14)
|
||||
c.drawString(15 * mm, H - 12 * mm, "PGŽ SPORT — OBRAZAC")
|
||||
c.setFont(font_reg, 10)
|
||||
c.drawString(15 * mm, H - 18 * mm, str(s.get("template_naziv") or s.get("template_code") or ""))
|
||||
c.drawRightString(W - 15 * mm, H - 12 * mm, f"REF: {s.get('reference_no') or ''}")
|
||||
c.drawRightString(W - 15 * mm, H - 18 * mm,
|
||||
f"Status: {s.get('status','').upper()}")
|
||||
|
||||
y = H - 30 * mm
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
|
||||
# Meta
|
||||
def line(label, value, bold=False):
|
||||
nonlocal y
|
||||
if y < 25 * mm:
|
||||
c.showPage()
|
||||
y = H - 20 * mm
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
c.setFont(font_reg, 8)
|
||||
c.setFillColorRGB(0.45, 0.45, 0.45)
|
||||
c.drawString(15 * mm, y, label)
|
||||
c.setFont(font_bold if bold else font_reg, 10)
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
v = "" if value is None else str(value)
|
||||
# wrap
|
||||
max_w = W - 30 * mm
|
||||
while v:
|
||||
chunk = v
|
||||
while pdfmetrics.stringWidth(chunk, font_bold if bold else font_reg, 10) > max_w and len(chunk) > 5:
|
||||
chunk = chunk[:-2]
|
||||
c.drawString(15 * mm, y - 4 * mm, chunk)
|
||||
v = v[len(chunk):].lstrip() if len(chunk) < len(v) else ""
|
||||
y -= 5 * mm
|
||||
if v:
|
||||
if y < 25 * mm:
|
||||
c.showPage(); y = H - 20 * mm
|
||||
y -= 3 * mm
|
||||
|
||||
line("KLUB", s.get("klub_naziv"), bold=True)
|
||||
line("OIB KLUBA", s.get("klub_oib"))
|
||||
line("IBAN KLUBA", s.get("klub_iban"))
|
||||
if s.get("clan_naziv"):
|
||||
line("ČLAN/SPORTAŠ", s.get("clan_naziv"))
|
||||
line("DATUM PREDAJE", s.get("submitted_at") or s.get("created_at"))
|
||||
line("STATUS", s.get("status"), bold=True)
|
||||
|
||||
# Section divider
|
||||
y -= 4 * mm
|
||||
c.setStrokeColorRGB(0.13, 0.20, 0.32)
|
||||
c.setLineWidth(0.6)
|
||||
c.line(15 * mm, y, W - 15 * mm, y)
|
||||
y -= 6 * mm
|
||||
c.setFont(font_bold, 11)
|
||||
c.setFillColorRGB(0.13, 0.20, 0.32)
|
||||
c.drawString(15 * mm, y, "SADRŽAJ OBRASCA")
|
||||
y -= 8 * mm
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
|
||||
# Polja iz schema_json (skip meta __keys)
|
||||
if fields:
|
||||
for f in fields:
|
||||
name = f.get("name")
|
||||
if not name or name.startswith("__"):
|
||||
continue
|
||||
label = f.get("label") or name
|
||||
val = data.get(name)
|
||||
line(label, val)
|
||||
else:
|
||||
# fallback — sve ključeve iz data
|
||||
for k, v in data.items():
|
||||
if k.startswith("__"):
|
||||
continue
|
||||
line(k, v)
|
||||
|
||||
# Potpis
|
||||
y -= 6 * mm
|
||||
if y < 50 * mm:
|
||||
c.showPage(); y = H - 20 * mm
|
||||
c.setFillColorRGB(0.13, 0.20, 0.32)
|
||||
c.setStrokeColorRGB(0.13, 0.20, 0.32)
|
||||
c.setLineWidth(0.6)
|
||||
c.line(15 * mm, y, W - 15 * mm, y)
|
||||
y -= 6 * mm
|
||||
c.setFont(font_bold, 11)
|
||||
c.drawString(15 * mm, y, "DIGITALNI POTPIS")
|
||||
y -= 8 * mm
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
if sig_sha:
|
||||
line("Potpisao", sig_by or "")
|
||||
line("Vrijeme potpisa (UTC)", sig_at or "")
|
||||
line("SHA-256 hash sadržaja", sig_sha)
|
||||
line("Verifikacija",
|
||||
"PGŽ Sport ERP/CRM — hash izračunat nad sortiranim JSON sadržajem (bez __* polja).")
|
||||
else:
|
||||
c.setFont(font_reg, 9)
|
||||
c.setFillColorRGB(0.7, 0.3, 0.3)
|
||||
c.drawString(15 * mm, y, "Obrazac NIJE digitalno potpisan.")
|
||||
y -= 6 * mm
|
||||
|
||||
# Footer
|
||||
c.setFont(font_reg, 7)
|
||||
c.setFillColorRGB(0.55, 0.55, 0.55)
|
||||
c.drawString(15 * mm, 10 * mm,
|
||||
f"PGŽ Sport ERP/CRM • Generirano {datetime.now().strftime('%d.%m.%Y. %H:%M')} • REF {s.get('reference_no') or sid}")
|
||||
|
||||
c.save()
|
||||
pdf = buf.getvalue()
|
||||
return Response(content=pdf, media_type="application/pdf",
|
||||
headers={"Content-Disposition":
|
||||
f"inline; filename=obrazac-{sid}.pdf"})
|
||||
|
||||
|
||||
# ───────────── /forms/{code_or_id} (catch-all GET — mora biti POSLIJE submissions!) ─────────────
|
||||
|
||||
@router.get("/forms/{code_or_id}")
|
||||
def get_form(code_or_id: str):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
t = _resolve_template(code_or_id, cur)
|
||||
return _row(t)
|
||||
|
||||
|
||||
# ───────────── shortcut: kreiraj+submit u jednom ─────────────
|
||||
|
||||
@router.post("/forms/{code_or_id}/submit")
|
||||
def quick_submit(code_or_id: str, body: SubmissionIn):
|
||||
"""Kompatibilni shortcut — kreira draft + odmah submita s potpisom."""
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
t = _resolve_template(code_or_id, cur)
|
||||
ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
merged = dict(body.data or {})
|
||||
signer = str(body.user_id) if body.user_id else "anonymous"
|
||||
sig = _sign_payload(merged, signer)
|
||||
merged.update(sig)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO pgz_sport.form_submissions
|
||||
(template_id, template_code, klub_id, user_id, clan_id, data,
|
||||
attachments, status, reference_no, submitted_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,'submitted',%s, now())
|
||||
RETURNING *
|
||||
""", (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id,
|
||||
json.dumps(merged), json.dumps(body.attachments or []), ref))
|
||||
s = cur.fetchone()
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"id": s["id"],
|
||||
"reference_no": s["reference_no"],
|
||||
"status": "submitted",
|
||||
"signature_sha256": sig["__signature_sha256"],
|
||||
"signed_at": sig["__signed_at"],
|
||||
"submission": _row(s),
|
||||
}
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
geocode_v3_osm.py — fuzzy-match objekti against OSM sports facilities
|
||||
|
||||
Strategy:
|
||||
1) Pull all named sports leisure objects from OSM via Overpass API in PGŽ bounds.
|
||||
2) For each pgz_sport.sportski_objekti row, compute a similarity match against OSM names.
|
||||
3) When a confident match is found AND new coords differ from current by >100m,
|
||||
update the DB.
|
||||
"""
|
||||
import os, time, json, urllib.parse, urllib.request
|
||||
import psycopg2, psycopg2.extras
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
PG = dict(host=os.environ.get('PG_HOST','10.10.0.2'),
|
||||
port=int(os.environ.get('PG_PORT','6432')),
|
||||
dbname=os.environ.get('PG_DB','rinet_v3'),
|
||||
user=os.environ.get('PG_USER','rinet'),
|
||||
password=os.environ.get('PG_PASS',''))
|
||||
|
||||
UA = 'pgz-sport/2.0 (dradulic@outlook.com)'
|
||||
|
||||
OVERPASS = """[out:json][timeout:60];
|
||||
(
|
||||
node["leisure"~"sports_centre|sports_hall|stadium|pitch|swimming_pool|ice_rink"](44.5,14.0,45.6,15.1);
|
||||
way["leisure"~"sports_centre|sports_hall|stadium|pitch|swimming_pool|ice_rink"](44.5,14.0,45.6,15.1);
|
||||
node["sport"]["name"](44.5,14.0,45.6,15.1);
|
||||
way["sport"]["name"](44.5,14.0,45.6,15.1);
|
||||
node["amenity"~"sports_centre|gymnasium"](44.5,14.0,45.6,15.1);
|
||||
way["amenity"~"sports_centre|gymnasium"](44.5,14.0,45.6,15.1);
|
||||
);
|
||||
out center tags;"""
|
||||
|
||||
def fetch_osm():
|
||||
req = urllib.request.Request(
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
data=urllib.parse.urlencode({'data': OVERPASS}).encode(),
|
||||
headers={'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
def normalize(s):
|
||||
s = (s or '').lower()
|
||||
s = re.sub(r'[^\w\s]', ' ', s, flags=re.UNICODE)
|
||||
# Strip common Croatian sport prefixes that confuse matching
|
||||
for w in ['sportska dvorana', 'gradska sportska dvorana', 'multifunkcionalna dvorana',
|
||||
'sportski centar', 'gradski stadion', 'sportski kompleks', 'srednja skola',
|
||||
'srednje skole', 'osnovna skola', 'os ', 'ss ', 'dr ', 'prof ',
|
||||
'centar', 'stadion', 'dvorana', 'bazen', 'bazeni']:
|
||||
s = s.replace(w, ' ')
|
||||
s = re.sub(r'\s+', ' ', s).strip()
|
||||
return s
|
||||
|
||||
def similarity(a, b):
|
||||
return SequenceMatcher(None, normalize(a), normalize(b)).ratio()
|
||||
|
||||
def haversine(lat1, lng1, lat2, lng2):
|
||||
"""Distance in meters."""
|
||||
import math
|
||||
R = 6371000
|
||||
p1 = math.radians(lat1); p2 = math.radians(lat2)
|
||||
dp = math.radians(lat2-lat1); dl = math.radians(lng2-lng1)
|
||||
a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
|
||||
return 2*R*math.asin(math.sqrt(a))
|
||||
|
||||
def main():
|
||||
print('Fetching OSM sports data...')
|
||||
osm = fetch_osm()
|
||||
elems = []
|
||||
for e in osm.get('elements', []):
|
||||
t = e.get('tags', {})
|
||||
name = t.get('name')
|
||||
if not name: continue
|
||||
lat = e.get('lat') or e.get('center',{}).get('lat')
|
||||
lon = e.get('lon') or e.get('center',{}).get('lon')
|
||||
if lat is None or lon is None: continue
|
||||
elems.append({'name': name, 'lat': lat, 'lng': lon, 'tags': t})
|
||||
print(f'OSM named sports elements: {len(elems)}')
|
||||
|
||||
conn = psycopg2.connect(**PG)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT id, naziv, grad, lat, lng FROM pgz_sport.sportski_objekti ORDER BY id")
|
||||
objekti = cur.fetchall()
|
||||
print(f'DB objekti: {len(objekti)}')
|
||||
|
||||
updated = 0
|
||||
skipped_close = 0
|
||||
skipped_low = 0
|
||||
for o in objekti:
|
||||
# Find best fuzzy match
|
||||
best = None
|
||||
best_sim = 0.0
|
||||
nname = normalize(o['naziv'])
|
||||
if not nname: continue
|
||||
for e in elems:
|
||||
sim = similarity(o['naziv'], e['name'])
|
||||
# Boost if same city contained in either name
|
||||
if o['grad'] and (o['grad'].lower() in (e['name'] or '').lower() or
|
||||
o['grad'].lower() in (e['tags'].get('addr:city','') or '').lower()):
|
||||
sim += 0.05
|
||||
if sim > best_sim:
|
||||
best_sim = sim
|
||||
best = e
|
||||
# Require strong match
|
||||
if best_sim < 0.55:
|
||||
skipped_low += 1
|
||||
continue
|
||||
# Skip if already within 100m
|
||||
if o['lat'] and o['lng']:
|
||||
d = haversine(float(o['lat']), float(o['lng']), best['lat'], best['lng'])
|
||||
if d < 100:
|
||||
skipped_close += 1
|
||||
continue
|
||||
else:
|
||||
pass
|
||||
# Apply update
|
||||
print(f" #{o['id']:3} {o['naziv'][:55]:55} -> '{best['name'][:40]}' sim={best_sim:.2f} {best['lat']:.6f},{best['lng']:.6f}")
|
||||
cur.execute("UPDATE pgz_sport.sportski_objekti SET lat=%s, lng=%s WHERE id=%s",
|
||||
(best['lat'], best['lng'], o['id']))
|
||||
conn.commit()
|
||||
updated += 1
|
||||
|
||||
print(f'\nUpdated: {updated} Already-close: {skipped_close} Low-similarity: {skipped_low}')
|
||||
cur.close(); conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+219
-81
@@ -9,6 +9,8 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
|
||||
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
|
||||
<script src="https://unpkg.com/3d-force-graph@1.73.0/dist/3d-force-graph.min.js"></script>
|
||||
<style>
|
||||
:root{
|
||||
--pgz-blue:#003087; --pgz-blue2:#004CC4; --pgz-gold:#F4C430;
|
||||
@@ -143,6 +145,12 @@ table tbody tr.no-click:hover{background:transparent}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
.tag{display:inline-block;padding:2px 7px;font-size:10px;border-radius:3px;background:var(--bg4);color:var(--t1);font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-right:3px}
|
||||
a.tag,.tag[onclick]{cursor:pointer;text-decoration:none;transition:transform .12s,filter .12s}
|
||||
a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.15)}
|
||||
.link-chip{color:var(--cyan);cursor:pointer;text-decoration:none;border-bottom:1px dashed transparent;transition:all .15s}
|
||||
.link-chip:hover{color:var(--pgz-gold);border-bottom-color:var(--pgz-gold)}
|
||||
.kv .v a{color:var(--cyan)}
|
||||
.kv .v a:hover{color:var(--pgz-gold)}
|
||||
.tag.b{background:var(--pgz-blue);color:#fff}
|
||||
.tag.gd{background:var(--pgz-gold);color:var(--bg0)}
|
||||
.tag.gr{background:var(--green);color:var(--bg0)}
|
||||
@@ -211,7 +219,7 @@ table tbody tr.no-click:hover{background:transparent}
|
||||
<div class="sb-h">
|
||||
<div class="logo">PGŽ <span class="g">SPORT</span></div>
|
||||
<div class="sub">Primorsko-goranska županija</div>
|
||||
<div class="sb-toggle" id="sb-toggle" onclick="toggleSidebar()" title="Skupi/raširi">⮜</div>
|
||||
<div class="sb-toggle" id="sb-toggle" onclick="toggleSidebar()" title="Skupi/raširi sidebar">≡</div>
|
||||
</div>
|
||||
<nav class="sb-nav" id="nav"></nav>
|
||||
<div class="sb-foot">v2.0 · 2026</div>
|
||||
@@ -467,7 +475,7 @@ function toggleSidebar(){
|
||||
const tg = document.getElementById('sb-toggle');
|
||||
if(!sb) return;
|
||||
const isCollapsed = sb.classList.toggle('collapsed');
|
||||
if(tg) tg.textContent = isCollapsed ? '⮞' : '⮜';
|
||||
if(tg) tg.textContent = '≡';
|
||||
try { localStorage.setItem('sidebar-state', isCollapsed ? 'collapsed' : 'expanded'); } catch(e){}
|
||||
}
|
||||
function restoreSidebar(){
|
||||
@@ -477,7 +485,7 @@ function restoreSidebar(){
|
||||
const sb = document.getElementById('sb');
|
||||
const tg = document.getElementById('sb-toggle');
|
||||
if(sb) sb.classList.add('collapsed');
|
||||
if(tg) tg.textContent = '⮞';
|
||||
if(tg) tg.textContent = '≡';
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
@@ -1078,6 +1086,72 @@ function renderSportasiShell(){
|
||||
$('#sp-rep').addEventListener('change', applySportasiFilter);
|
||||
$('#sp-foto').addEventListener('change', applySportasiFilter);
|
||||
}
|
||||
function openOIB(oib){
|
||||
const cleanOib = String(oib||'').replace(/[^0-9]/g,'');
|
||||
const url = 'https://sudreg.pravosudje.hr/registar/oc/index.html#osnovniPodaci?o='+encodeURIComponent(cleanOib);
|
||||
// Open external — sudreg supports OIB lookup
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
function filterKluboviByCity(grad){
|
||||
closePanel();
|
||||
navTo('klubovi');
|
||||
setTimeout(() => {
|
||||
const sel = $('#kl-grad');
|
||||
if(sel){ sel.value = grad; applyKluboviFilter(); }
|
||||
}, 100);
|
||||
}
|
||||
function filterKluboviBySport(sport){
|
||||
closePanel();
|
||||
navTo('klubovi');
|
||||
setTimeout(() => {
|
||||
const sel = $('#kl-sport');
|
||||
if(sel){ sel.value = sport; applyKluboviFilter(); }
|
||||
}, 100);
|
||||
}
|
||||
function filterObjektiByCity(grad){
|
||||
closePanel();
|
||||
navTo('objekti');
|
||||
setTimeout(() => {
|
||||
const sel = $('#ob-grad');
|
||||
if(sel){ sel.value = grad; applyObjektiFilter(); }
|
||||
}, 100);
|
||||
}
|
||||
function filterSportasiBy(field, value){
|
||||
closePanel();
|
||||
navTo('sportasi');
|
||||
setTimeout(() => {
|
||||
if(field === 'sport'){
|
||||
// Use search box since there's no sport dropdown
|
||||
const q = $('#sp-q'); if(q){ q.value = value; }
|
||||
} else if(field === 'mjesto_rodjenja' || field === 'grad'){
|
||||
const q = $('#sp-q'); if(q){ q.value = value; }
|
||||
} else if(field === 'reprezentativac'){
|
||||
const cb = $('#sp-rep'); if(cb){ cb.checked = !!value; }
|
||||
} else if(field === 'hoo'){
|
||||
const sel = $('#sp-hoo'); if(sel){ sel.value = String(value); }
|
||||
} else if(field === 'aktivan'){
|
||||
// Add to extra-filters slot if exists; else search by status string
|
||||
_state.spExtraAktivan = value ? 'true' : 'false';
|
||||
} else if(field === 'stipendiran'){
|
||||
_state.spExtraStipendiran = !!value;
|
||||
}
|
||||
applySportasiFilter();
|
||||
}, 100);
|
||||
}
|
||||
function filterSportasiByYear(year){
|
||||
closePanel();
|
||||
navTo('sportasi');
|
||||
setTimeout(() => {
|
||||
_state.spYear = String(year);
|
||||
applySportasiFilter();
|
||||
}, 100);
|
||||
}
|
||||
function clearSportasiExtras(){
|
||||
_state.spExtraAktivan = '';
|
||||
_state.spExtraStipendiran = false;
|
||||
_state.spYear = '';
|
||||
}
|
||||
|
||||
function setSportasiView(v){
|
||||
_state.viewSportasi = v;
|
||||
$('#sp-card').classList.toggle('active', v==='card');
|
||||
@@ -1094,6 +1168,12 @@ function applySportasiFilter(){
|
||||
if(rep) rows = rows.filter(c => c.reprezentativac);
|
||||
if(foto) rows = rows.filter(c => c.slika_url);
|
||||
if(hoo) rows = rows.filter(c => String(c.hoo_kategorija||c.kategorija_hoo||'')===hoo);
|
||||
if(_state.spYear){
|
||||
rows = rows.filter(c => String(c.datum_rodenja||c.datum_rodjenja||'').slice(0,4) === _state.spYear);
|
||||
}
|
||||
if(_state.spExtraAktivan==='true') rows = rows.filter(c => c.aktivan);
|
||||
if(_state.spExtraAktivan==='false') rows = rows.filter(c => !c.aktivan);
|
||||
if(_state.spExtraStipendiran) rows = rows.filter(c => c.stipendiran);
|
||||
if(_sort.sportasi) rows = sortRows(rows, _sort.sportasi.key, _sort.sportasi.dir);
|
||||
$('#sp-cnt').textContent = rows.length+' sportaša';
|
||||
const top = rows.slice(0, 300);
|
||||
@@ -1165,14 +1245,21 @@ async function openSportas(id){
|
||||
<div class="pp-foto">${photo}</div>
|
||||
<div class="pp-info">
|
||||
<div class="pp-name">${esc(d.ime||'')} ${esc(d.prezime||'')}</div>
|
||||
<div class="pp-meta">${txt(d.sport,'—')} · ${txt(d.pozicija,'')} · <b>${esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')}</b></div>
|
||||
<div class="pp-meta">📅 ${fmtDate(dob)}${(d.mjesto_rodjenja||d.mjesto_rodenja)?' · '+esc(d.mjesto_rodjenja||d.mjesto_rodenja):''}</div>
|
||||
<div class="pp-meta">
|
||||
${d.sport?'<a class="link-chip" onclick="filterSportasiBy("sport","'+esc(d.sport)+'")">'+esc(d.sport)+'</a>':'—'} ·
|
||||
${txt(d.pozicija,'')} ·
|
||||
${d.klub_id ? '<a class="link-chip" onclick="closePanel();setTimeout(()=>openKlub('+d.klub_id+'),250)"><b>'+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+'</b></a>' : '<b>'+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+'</b>'}
|
||||
</div>
|
||||
<div class="pp-meta">
|
||||
${dob ? '<a class="link-chip" onclick="filterSportasiByYear("'+esc((dob||'').slice(0,4))+'")">📅 '+fmtDate(dob)+'</a>' : '📅 —'}
|
||||
${(d.mjesto_rodjenja||d.mjesto_rodenja)?' · <a class="link-chip" onclick="filterSportasiBy("mjesto_rodjenja","'+esc(d.mjesto_rodjenja||d.mjesto_rodenja)+'")">'+esc(d.mjesto_rodjenja||d.mjesto_rodenja)+'</a>':''}
|
||||
</div>
|
||||
<div class="pp-tags">
|
||||
${d.aktivan?'<span class="tag gr">AKTIVAN</span>':'<span class="tag rd">NEAKTIVAN</span>'}
|
||||
${d.reprezentativac?'<span class="tag gd">REPR</span>':''}
|
||||
${hooCat?'<span class="tag b">HOO '+esc(hooCat)+'</span>':''}
|
||||
<a class="tag ${d.aktivan?'gr':'rd'}" onclick="filterSportasiBy('aktivan',${d.aktivan?'true':'false'})">${d.aktivan?'AKTIVAN':'NEAKTIVAN'}</a>
|
||||
${d.reprezentativac?'<a class="tag gd" onclick="filterSportasiBy("reprezentativac",true)">REPR</a>':''}
|
||||
${hooCat?'<a class="tag b" onclick="filterSportasiBy("hoo","'+esc(hooCat)+'")">HOO '+esc(hooCat)+'</a>':''}
|
||||
${d.broj_dresa?'<span class="tag">#'+esc(d.broj_dresa)+'</span>':''}
|
||||
${d.stipendiran?'<span class="tag am">STIP</span>':''}
|
||||
${d.stipendiran?'<a class="tag am" onclick="filterSportasiBy("stipendiran",true)">STIP</a>':''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1233,7 +1320,7 @@ async function openSportas(id){
|
||||
|
||||
<div id="p-bio" class="ptab" style="display:none">
|
||||
<div class="kv">
|
||||
<div class="k">OIB</div><div class="v">${txt(d.oib)}</div>
|
||||
<div class="k">OIB</div><div class="v">${d.oib?'<a class="link-chip" onclick="openOIB("'+esc(d.oib)+'")">'+esc(d.oib)+'</a>':'—'}</div>
|
||||
<div class="k">Datum rođenja</div><div class="v">${fmtDate(dob)}</div>
|
||||
<div class="k">Mjesto rođenja</div><div class="v">${txt(d.mjesto_rodjenja||d.mjesto_rodenja)}</div>
|
||||
<div class="k">Spol</div><div class="v">${txt(d.spol)}</div>
|
||||
@@ -1700,9 +1787,10 @@ function renderMrezaShell(){
|
||||
<span class="tb-s" id="mr-cnt"></span>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div id="mr-graph" style="width:100%;height:640px;background:radial-gradient(ellipse at center,var(--bg2) 0%,var(--bg0) 100%);position:relative">
|
||||
<svg id="mr-svg" style="width:100%;height:100%"></svg>
|
||||
<div class="card" style="padding:0;overflow:hidden;position:relative">
|
||||
<div id="mr-graph" style="width:100%;height:640px;background:#08090e;position:relative;cursor:grab"></div>
|
||||
<div style="position:absolute;top:10px;right:14px;font-size:10px;color:var(--t4);background:rgba(13,16,33,0.7);padding:4px 8px;border-radius:4px;pointer-events:none">
|
||||
🖱 Drag • Scroll zoom • Right-drag pan • Click node
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1712,7 +1800,7 @@ function renderMrezaShell(){
|
||||
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#8b5cf6;vertical-align:middle;margin-right:5px"></span>Osoba</div>
|
||||
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#ff4466;vertical-align:middle;margin-right:5px"></span>Entitet (high risk)</div>
|
||||
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#00e68a;vertical-align:middle;margin-right:5px"></span>Dobavljač</div>
|
||||
<div style="color:var(--t2)">Veličina = risk / promet · Klikni čvor za detalje</div>
|
||||
<div style="color:var(--t2)">Veličina = risk / promet · Klikni čvor za detalje · 3D force graph (drag rotate, scroll zoom)</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1777,77 +1865,73 @@ function resetMreza(){
|
||||
function renderMrezaGraph(nodes, edges){
|
||||
if(!nodes) nodes = (_mreza.allNodes||[]).slice();
|
||||
if(!edges) edges = (_mreza.allEdges||[]).slice();
|
||||
const svgEl = document.getElementById('mr-svg');
|
||||
if(!svgEl) return;
|
||||
const container = document.getElementById('mr-graph');
|
||||
const W = container.clientWidth || 800;
|
||||
const H = container.clientHeight || 640;
|
||||
if(!container) return;
|
||||
|
||||
if(_mreza.sim){ try{_mreza.sim.stop();}catch(e){} _mreza.sim = null; }
|
||||
|
||||
const svg = d3.select(svgEl);
|
||||
svg.selectAll('*').remove();
|
||||
svg.attr('viewBox', '0 0 '+W+' '+H);
|
||||
|
||||
// Deep-copy so D3 sim doesn't mutate originals
|
||||
// Deep-copy so 3d-force-graph doesn't mutate originals across re-renders
|
||||
const N = nodes.map(n => Object.assign({}, n));
|
||||
const Nmap = new Map(N.map(n=>[n.id, n]));
|
||||
const E = edges.map(e => ({
|
||||
source: Nmap.get(e.source.id||e.source) || (e.source.id||e.source),
|
||||
target: Nmap.get(e.target.id||e.target) || (e.target.id||e.target),
|
||||
source: e.source.id || e.source,
|
||||
target: e.target.id || e.target,
|
||||
color: e.color, size: e.size
|
||||
})).filter(e => typeof e.source === 'object' && typeof e.target === 'object');
|
||||
})).filter(e => Nmap.has(e.source) && Nmap.has(e.target));
|
||||
|
||||
// Zoom/pan
|
||||
const g = svg.append('g');
|
||||
svg.call(d3.zoom().scaleExtent([0.2, 5]).on('zoom', (ev) => g.attr('transform', ev.transform)));
|
||||
// Tear down previous graph (re-create on each render to avoid stale state)
|
||||
if(_mreza.graph){
|
||||
try{ _mreza.graph._destructor && _mreza.graph._destructor(); }catch(e){}
|
||||
container.innerHTML = '';
|
||||
_mreza.graph = null;
|
||||
}
|
||||
|
||||
const sim = d3.forceSimulation(N)
|
||||
.force('link', d3.forceLink(E).id(d => d.id).distance(d => 60 + 20/(d.size||1)))
|
||||
.force('charge', d3.forceManyBody().strength(d => -50 - (d.size||5)*4))
|
||||
.force('center', d3.forceCenter(W/2, H/2))
|
||||
.force('collide', d3.forceCollide().radius(d => Math.max(6, (d.size||5)*0.7 + 4)));
|
||||
_mreza.sim = sim;
|
||||
if(typeof ForceGraph3D === 'undefined'){
|
||||
container.innerHTML = '<div class="empty" style="padding:40px;color:var(--red)">3D Force Graph biblioteka nije učitana. Provjeri unpkg.com pristup.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const link = g.append('g')
|
||||
.attr('stroke-opacity', 0.5)
|
||||
.selectAll('line').data(E).join('line')
|
||||
.attr('stroke', d => d.color || '#283560')
|
||||
.attr('stroke-width', d => Math.max(0.4, (d.size||0.4)));
|
||||
const W = container.clientWidth || 800;
|
||||
const H = container.clientHeight || 640;
|
||||
const Graph = ForceGraph3D()(container)
|
||||
.width(W)
|
||||
.height(H)
|
||||
.backgroundColor('#08090e')
|
||||
.graphData({nodes: N, links: E})
|
||||
.nodeLabel(n => '<div style="background:rgba(13,16,33,.95);border:1px solid #283560;border-radius:5px;padding:6px 10px;font-family:Inter,sans-serif;font-size:12px;color:#fff"><b>'+(n.label||'').replace(/</g,'<')+'</b><br><span style="color:#8a95b4">'+(n.type||'')+(n.meta&&n.meta.risk?' · risk '+n.meta.risk:'')+'</span></div>')
|
||||
.nodeColor(n => n.color || '#004CC4')
|
||||
.nodeVal(n => Math.max(2, (n.size||5)*0.6))
|
||||
.nodeOpacity(0.92)
|
||||
.linkColor(l => (l.color||'#283560').replace(/22$/,'') )
|
||||
.linkWidth(l => Math.max(0.3, (l.size||0.4)*1.5))
|
||||
.linkOpacity(0.5)
|
||||
.linkDirectionalParticles(0)
|
||||
.onNodeClick(n => {
|
||||
// Center camera on node + open detail panel
|
||||
const dist = 80;
|
||||
const distRatio = 1 + dist/Math.hypot(n.x||1, n.y||1, n.z||1);
|
||||
Graph.cameraPosition(
|
||||
{ x:(n.x||0)*distRatio, y:(n.y||0)*distRatio, z:(n.z||0)*distRatio },
|
||||
n,
|
||||
800
|
||||
);
|
||||
openMrezaNode(n);
|
||||
})
|
||||
.onNodeHover(n => { container.style.cursor = n ? 'pointer' : 'grab'; });
|
||||
|
||||
const node = g.append('g')
|
||||
.selectAll('g').data(N).join('g')
|
||||
.style('cursor','pointer')
|
||||
.call(d3.drag()
|
||||
.on('start', (ev,d) => { if(!ev.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
|
||||
.on('drag', (ev,d) => { d.fx=ev.x; d.fy=ev.y; })
|
||||
.on('end', (ev,d) => { if(!ev.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }))
|
||||
.on('click', (ev,d) => openMrezaNode(d));
|
||||
_mreza.graph = Graph;
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', d => Math.max(5, (d.size||5)*0.7))
|
||||
.attr('fill', d => d.color || '#004CC4')
|
||||
.attr('stroke', '#0d1021')
|
||||
.attr('stroke-width', 1.5);
|
||||
|
||||
node.append('text')
|
||||
.text(d => (d.label||'').slice(0,28))
|
||||
.attr('x', d => Math.max(6, (d.size||5)*0.7) + 4)
|
||||
.attr('y', 4)
|
||||
.attr('fill', '#e2e6f0')
|
||||
.attr('font-size', '10px')
|
||||
.attr('font-family', 'Inter, sans-serif')
|
||||
.style('pointer-events','none');
|
||||
|
||||
node.append('title').text(d => (d.label||'')+' ['+d.type+']');
|
||||
|
||||
sim.on('tick', () => {
|
||||
link.attr('x1', d=>d.source.x).attr('y1', d=>d.source.y)
|
||||
.attr('x2', d=>d.target.x).attr('y2', d=>d.target.y);
|
||||
node.attr('transform', d => 'translate('+d.x+','+d.y+')');
|
||||
// Resize handler — re-flow when container dims change
|
||||
if(_mreza.resizeObs){ try{_mreza.resizeObs.disconnect();}catch(e){} }
|
||||
_mreza.resizeObs = new ResizeObserver(entries => {
|
||||
for(const ent of entries){
|
||||
const cw = ent.contentRect.width|0, ch = ent.contentRect.height|0;
|
||||
if(cw>0 && ch>0 && _mreza.graph){
|
||||
try{ _mreza.graph.width(cw).height(ch); }catch(e){}
|
||||
}
|
||||
}
|
||||
});
|
||||
_mreza.resizeObs.observe(container);
|
||||
|
||||
if(!$('#mr-cnt').textContent){
|
||||
if($('#mr-cnt') && !$('#mr-cnt').textContent){
|
||||
$('#mr-cnt').textContent = N.length+' čvorova · '+E.length+' veza';
|
||||
}
|
||||
}
|
||||
@@ -2041,10 +2125,22 @@ function renderForenzikaShell(d){
|
||||
<option value="">Svi tipovi</option>
|
||||
${tipovi.map(t=>'<option value="'+esc(t)+'">'+esc(t)+'</option>').join('')}
|
||||
</select>
|
||||
<button class="btn primary" onclick="runForensicScan()">⚡ Pokreni novu analizu</button>
|
||||
<span class="tb-s" id="fz-cnt"></span>
|
||||
</div>
|
||||
|
||||
<div class="card" style="border-color:var(--pgz-gold)">
|
||||
<div class="card-h">
|
||||
<div class="card-t">⚡ Pokreni novu analizu osobe</div>
|
||||
<div class="tb-s">civic.persons + entity_links + forensic_findings</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<input type="text" id="fz-scan-name" placeholder="Ime i prezime (npr. Velimir Liverić)" style="background:var(--bg2);border:1px solid var(--rim);border-radius:5px;padding:8px 12px;color:var(--t1);font-size:13px;flex:1;min-width:240px" value="Velimir Liverić">
|
||||
<button class="btn primary" onclick="runForensicScan()">▶ Pokreni</button>
|
||||
<button class="btn" onclick="document.getElementById('fz-scan-out').innerHTML=''">Očisti</button>
|
||||
</div>
|
||||
<div id="fz-scan-out" style="margin-top:12px"></div>
|
||||
</div>
|
||||
|
||||
<div id="fz-out"></div>
|
||||
`;
|
||||
$('#fz-q').addEventListener('input', debounce(applyForenzikaFilter, 200));
|
||||
@@ -2235,14 +2331,56 @@ function renderAlertPanel(a){
|
||||
}
|
||||
|
||||
async function runForensicScan(){
|
||||
const btn = event && event.target;
|
||||
if(btn){ btn.disabled = true; btn.textContent = '⏳ Skeniranje…'; }
|
||||
const r = await api('/v2/alerts/scan');
|
||||
if(btn){ btn.disabled = false; btn.textContent = '⚡ Pokreni novu analizu'; }
|
||||
// Reload
|
||||
_forenzika.alerts = null;
|
||||
await loadForenzika();
|
||||
alert(r ? 'Skeniranje pokrenuto. Pronađeno alarma: '+(_forenzika.alerts||[]).length : 'Greška pri pokretanju skeniranja.');
|
||||
const inputEl = document.getElementById('fz-scan-name');
|
||||
const outEl = document.getElementById('fz-scan-out');
|
||||
if(!inputEl || !outEl) return;
|
||||
const name = (inputEl.value||'').trim();
|
||||
if(name.length < 3){ outEl.innerHTML = '<div class="empty">Unesi barem 3 znaka</div>'; return; }
|
||||
outEl.innerHTML = '<div class="loading">Skeniram civic.persons… tražim povezane entitete… provjeravam forensic_findings…</div>';
|
||||
const r = await apiPost('/v2/forensic/scan', {name: name});
|
||||
if(!r){ outEl.innerHTML = '<div class="empty" style="color:var(--red)">Greška pri pokretanju analize</div>'; return; }
|
||||
const ovr = r.overall_risk_score || 0;
|
||||
const ovrCls = ovr>=70?'r':(ovr>=40?'':'g');
|
||||
outEl.innerHTML = `
|
||||
<div class="kpi-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px">
|
||||
<div class="kpi ${ovrCls}"><div class="kpi-l">Overall risk</div><div class="kpi-v">${ovr}<span style="font-size:13px;color:var(--t2)">/100</span></div></div>
|
||||
<div class="kpi b"><div class="kpi-l">Pronađeno osoba</div><div class="kpi-v">${r.matched_persons}</div></div>
|
||||
<div class="kpi"><div class="kpi-l">Veza</div><div class="kpi-v">${r.total_links}</div></div>
|
||||
<div class="kpi r"><div class="kpi-l">Findings</div><div class="kpi-v">${r.total_findings}<span style="font-size:13px;color:var(--t2)"> (${r.critical_findings} crit)</span></div></div>
|
||||
</div>
|
||||
${(r.persons||[]).length ? `
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
${r.persons.map(p => {
|
||||
const cls = p.risk_score>=70?'crit':(p.risk_score>=40?'crit':'');
|
||||
return `
|
||||
<div class="alert-card ${cls}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="at">${esc(p.name)} <span class="tag b">id ${p.id}</span> ${p.oib?'<a class="tag" onclick="openOIB("'+esc(p.oib)+'")" style="cursor:pointer">OIB '+esc(p.oib)+'</a>':''}</div>
|
||||
<div class="ad">${p.function?esc(p.function):''}${p.party?' · '+esc(p.party):''}${p.county?' · '+esc(p.county):''}</div>
|
||||
<div style="margin-top:6px;font-size:11px;color:var(--t2)">
|
||||
🔗 ${(p.links||[]).length} povezanih entiteta
|
||||
· ⚠ ${(p.findings||[]).length} forenzičkih nalaza
|
||||
${p.trust_tier!=null?' · trust tier '+p.trust_tier:''}
|
||||
</div>
|
||||
${(p.links||[]).length ? '<div style="margin-top:8px;font-size:11px"><b style="color:var(--t2)">Veze:</b> '+
|
||||
p.links.slice(0,8).map(l => '<span class="tag b" style="margin-right:3px">'+esc(l.entity_name||'#'+l.entity_id)+(l.roles?' · '+esc(l.roles):'')+'</span>').join('')+
|
||||
((p.links||[]).length>8?' <span class="tag">+'+((p.links||[]).length-8)+' više</span>':'')+
|
||||
'</div>' : ''}
|
||||
${(p.findings||[]).length ? '<div style="margin-top:8px;font-size:11px"><b style="color:var(--t2)">Nalazi:</b><br>'+
|
||||
p.findings.slice(0,5).map(f => '<div style="margin-top:3px"><span class="tag '+(f.severity==='CRITICAL'?'rd':f.severity==='HIGH'?'am':'b')+'">'+esc(f.severity)+'</span> '+esc(f.title||f.finding_type)+'</div>').join('')+
|
||||
'</div>' : ''}
|
||||
</div>
|
||||
<div style="text-align:center;flex-shrink:0">
|
||||
<div style="font-size:24px;font-weight:800;color:${p.risk_score>=70?'var(--red)':p.risk_score>=40?'var(--amber)':'var(--green)'};font-family:var(--mono)">${p.risk_score}</div>
|
||||
<div style="font-size:10px;color:var(--t4);text-transform:uppercase">RISK</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : '<div class="empty">Nema pronađenih osoba s tim imenom</div>'}
|
||||
`;
|
||||
}
|
||||
|
||||
//=========== INIT ===========
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
# CC SWARM MONITOR v2 — radni tiled prikaz
|
||||
|
||||
case "${1:-help}" in
|
||||
view|loop)
|
||||
while true; do
|
||||
clear
|
||||
echo "╔════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ PGŽ SPORT — CC SWARM ($(date '+%Y-%m-%d %H:%M:%S')) ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════════╝"
|
||||
for s in cc1 cc2 cc3 cc4 cc5 cc6; do
|
||||
echo
|
||||
echo "─── [$s] ───────────────────────────────────────────────────────"
|
||||
tmux capture-pane -t ${s}:0 -p 2>/dev/null | grep -v "^─*$" | grep -v "^$" | tail -5 | sed 's/^/ /'
|
||||
done
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════════════════════"
|
||||
echo "Git: $(cd /opt/pgz-sport && git log --oneline -1 2>/dev/null)"
|
||||
echo "Refresh 30s | Ctrl+C izlaz"
|
||||
echo "════════════════════════════════════════════════════════════════════"
|
||||
sleep 30
|
||||
done
|
||||
;;
|
||||
|
||||
tiled|tile)
|
||||
# Stvori SAMO display sesiju koja gleda svih 6 odjednom
|
||||
tmux kill-session -t swarm-view 2>/dev/null
|
||||
|
||||
# Nova sesija
|
||||
tmux new-session -d -s swarm-view -x 240 -y 60
|
||||
|
||||
# Koristi watch da gleda capture-pane svakih 2s — to RADI bez nestanja
|
||||
tmux send-keys -t swarm-view:0 'watch -n 2 -t "echo === CC1 ===; tmux capture-pane -t cc1:0 -p | tail -8"' Enter
|
||||
|
||||
# Split na 6 panela
|
||||
tmux split-window -h -t swarm-view:0
|
||||
tmux send-keys -t swarm-view:0.1 'watch -n 2 -t "echo === CC2 ===; tmux capture-pane -t cc2:0 -p | tail -8"' Enter
|
||||
|
||||
tmux split-window -v -t swarm-view:0.0
|
||||
tmux send-keys -t swarm-view:0.2 'watch -n 2 -t "echo === CC3 ===; tmux capture-pane -t cc3:0 -p | tail -8"' Enter
|
||||
|
||||
tmux split-window -v -t swarm-view:0.1
|
||||
tmux send-keys -t swarm-view:0.3 'watch -n 2 -t "echo === CC4 ===; tmux capture-pane -t cc4:0 -p | tail -8"' Enter
|
||||
|
||||
tmux split-window -v -t swarm-view:0.0
|
||||
tmux send-keys -t swarm-view:0.4 'watch -n 2 -t "echo === CC5 ===; tmux capture-pane -t cc5:0 -p | tail -8"' Enter
|
||||
|
||||
tmux split-window -v -t swarm-view:0.1
|
||||
tmux send-keys -t swarm-view:0.5 'watch -n 2 -t "echo === CC6 ===; tmux capture-pane -t cc6:0 -p | tail -8"' Enter
|
||||
|
||||
tmux select-layout -t swarm-view tiled
|
||||
|
||||
echo "═════════════════════════════════════════════════"
|
||||
echo " Sesija swarm-view kreirana s 6 panela"
|
||||
echo "═════════════════════════════════════════════════"
|
||||
echo " Pogledaj: tmux attach -t swarm-view"
|
||||
echo " Detach: Ctrl+B pa D"
|
||||
echo "═════════════════════════════════════════════════"
|
||||
|
||||
# Auto-attach
|
||||
tmux attach -t swarm-view
|
||||
;;
|
||||
|
||||
status|s)
|
||||
for s in cc1 cc2 cc3 cc4 cc5 cc6; do
|
||||
echo "=== $s ==="
|
||||
tmux capture-pane -t ${s}:0 -p 2>/dev/null | tail -8
|
||||
echo
|
||||
done
|
||||
;;
|
||||
|
||||
git|log)
|
||||
cd /opt/pgz-sport
|
||||
echo "═══ COMMITS ═══"
|
||||
git log --oneline -20
|
||||
;;
|
||||
|
||||
cc1|1) tmux attach -t cc1 ;;
|
||||
cc2|2) tmux attach -t cc2 ;;
|
||||
cc3|3) tmux attach -t cc3 ;;
|
||||
cc4|4) tmux attach -t cc4 ;;
|
||||
cc5|5) tmux attach -t cc5 ;;
|
||||
cc6|6) tmux attach -t cc6 ;;
|
||||
|
||||
*)
|
||||
cat << 'HELP'
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
CC SWARM — PGŽ SPORT
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
bash swarm.sh tiled 6 panela live prikaz (ATTACHA ODMAH)
|
||||
bash swarm.sh view Auto-refresh 30s (cijela slika u 1 ekranu)
|
||||
bash swarm.sh status Brzi snapshot
|
||||
bash swarm.sh git Git log
|
||||
bash swarm.sh cc1..6 Attach na specifični agent
|
||||
|
||||
Detach iz attached: Ctrl+B pa D
|
||||
Switch tmux panel: Ctrl+B pa strijelica
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
HELP
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user