M1+M2+M10 (CC2 R3): JWT auth + admin users + GDPR backend

- auth/auth_v2.py: JWT login/refresh/logout/me + bcrypt + tenant_id/role/tier claims
- auth/admin_users.py: /api/admin/users CRUD + invite/role/suspend + bulk CSV
- auth/gdpr.py: cookie consent + Art.20 export + Art.17 erasure + admin queue
- auth/seed_demo.py: 3 demo tenants + 4 users (damir@pgz.hr / PGZ2026!)
- Removed legacy /api/auth/login + /api/auth/me from pgz_sport_api.py
- Wired auth/admin/gdpr routers into FastAPI

5/5 live curl tests pass: damir@pgz.hr login → JWT with tenant_id=1, role=pgz_admin, tier=0
This commit is contained in:
Damir Radulić
2026-05-05 00:09:09 +02:00
parent c12a8e9698
commit 492c8fdd87
23 changed files with 21518 additions and 49 deletions
+495
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
rinet-pgz-sggepY_ZLyxrXdziPAXsVx8WzZ5tRREVdeOgJlWgV2jrsPi35eH-w6q88RddJTgl
+2
View File
@@ -0,0 +1,2 @@
# PGŽ Sport — auth package
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
+446
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+98
View File
@@ -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()
+25 -39
View File
@@ -933,21 +933,7 @@ def google_auth(token: str = Body(..., embed=True)):
except Exception as e: except Exception as e:
raise HTTPException(401, f"Google auth failed: {e}") raise HTTPException(401, f"Google auth failed: {e}")
@app.get("/api/auth/me") # /api/auth/me handled by auth.auth_v2 router (M1)
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}
# ==================== STATIC ==================== # ==================== STATIC ====================
import pathlib import pathlib
@@ -1422,6 +1408,29 @@ try:
except Exception as e: except Exception as e:
print(f'[CRM/M9] obrasci router fail: {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") @app.get("/sport-3d")
@@ -1511,30 +1520,7 @@ def get_user(token):
return payload return payload
except: return None except: return None
# ── AUTH: Email/Password login ────────────────────────────────── # ── AUTH: Email/Password login — handled by auth.auth_v2 router (M1) ──
@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"]}
# ── SPORTAS FULL PROFILE ───────────────────────────────────────── # ── SPORTAS FULL PROFILE ─────────────────────────────────────────
@app.get("/api/sportas/{clan_id}/profil") @app.get("/api/sportas/{clan_id}/profil")
+183 -7
View File
@@ -1,8 +1,9 @@
""" """
enrich_router.py — Round-2 enrichment endpoint enrich_router.py — Round-2/3B enrichment + forensic-scan endpoints
Author: dradulic@outlook.com Date: 2026-05-04 Author: dradulic@outlook.com Date: 2026-05-04 (R2), 2026-05-05 (R3B)
Surfaces "Obogati podatke" buttons for klubovi, savezi, sportasi. Surfaces "Obogati podatke" buttons for klubovi, savezi, sportasi, plus
the Forenzika "Pokreni novu analizu" scan endpoint that searches civic.*.
Strategy: Strategy:
1) Read what's already in DB and surface fields the frontend may not have shown. 1) Read what's already in DB and surface fields the frontend may not have shown.
@@ -10,18 +11,28 @@ Strategy:
HNS Semafor) so the operator can verify or expand by hand. 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 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. <title> + <meta description> to return as a "live snippet". 5s timeout, fail-soft.
4) /forensic/scan — match name across civic.persons, return entity links,
forensic_findings hits, and a synthesised risk score.
5) /enrich/{kind}/{id}/apply — fetch best web source for entity and UPDATE the
row's web/email/telefon fields when missing.
""" """
import os, re, json, time, urllib.parse, urllib.request, html import os, re, json, time, urllib.parse, urllib.request, html
import psycopg2, psycopg2.extras import psycopg2, psycopg2.extras
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Body
router = APIRouter() router = APIRouter()
DB = dict(host=os.environ.get('PG_HOST','10.10.0.2'), _pgh = os.environ.get('PG_HOST','10.10.0.2')
port=int(os.environ.get('PG_PORT','6432')), _pgp = int(os.environ.get('PG_PORT','6432'))
# pgz-sport.service inherits PG_HOST=localhost:5432 from /opt/.env.rinet which is wrong
# (local PG is disabled). Force the Server B DSN if env says localhost.
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'), dbname=os.environ.get('PG_DB','rinet_v3'),
user=os.environ.get('PG_USER','rinet'), 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/2.0'
@@ -132,3 +143,168 @@ def enrich(kind: str, eid: int):
'research_links': _research_links(naziv, kind, grad), 'research_links': _research_links(naziv, kind, grad),
'enriched_at': int(time.time()), 'enriched_at': int(time.time()),
} }
# ── R3B P4 — FORENSIC SCAN ──────────────────────────────────────────
@router.post("/forensic/scan")
def forensic_scan(req: dict = Body(...)):
"""
Search civic.persons by name. For each match, gather entities, person
role, forensic_findings count, and synthesise a risk score.
Body: {"name": "Velimir Liverić"}
"""
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 each person collect entity links via OIB
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()]
# Forensic findings JSONB containing this OIB
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()]
# Also search forensic_findings by name
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()]
# Synthesise risk score per person and overall
total_links = 0
total_findings = 0
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
# per-person risk: 30 base if PEP-like (function set), +5 per link, +10 per finding, +20 per crit
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 = 0
if persons:
overall = max(p.get('risk_score',0) for p in persons)
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()),
}
# ── R3B P6 — ENRICH /apply (write enriched fields back to DB) ───────
@router.post("/enrich/{kind}/{eid}/apply")
def enrich_apply(kind: str, eid: int, req: dict = Body(default={})):
"""
Apply enrichment to DB. Body may contain {fields: {web, email, telefon}}
to override the auto-derived suggestions; otherwise we apply derived ones.
Only updates fields that are currently NULL or empty in DB (additive only).
"""
if kind not in ('klub','savez','sportas'):
raise HTTPException(400, "kind must be klub|savez|sportas")
body_fields = (req.get('fields') if isinstance(req, dict) else {}) or {}
if kind == 'klub':
table = 'pgz_sport.klubovi'
cols = ['web','email','telefon']
elif kind == 'savez':
table = 'pgz_sport.savezi'
cols = ['web','email','telefon']
else:
table = 'pgz_sport.clanovi'
cols = ['biografija','profile_url']
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(f"SELECT * FROM {table} WHERE id=%s", (eid,))
row = cur.fetchone()
if not row: raise HTTPException(404, kind+" not found")
row = dict(row)
# Try a live fetch from primary URL to glean email/phone
primary = row.get('web') or row.get('web_stranica') or row.get('source_url') or row.get('scrape_url') or row.get('profile_url')
derived = {}
if primary:
snippet = _fetch_title(primary, timeout=6)
try:
if snippet and snippet.get('url'):
req2 = urllib.request.Request(primary, headers={'User-Agent': UA})
with urllib.request.urlopen(req2, timeout=6) as r:
page = r.read(80000).decode('utf-8','ignore')
em = re.search(r'[\w\.-]+@[\w\.-]+\.[a-z]{2,8}', page, re.I)
if em: derived['email'] = em.group(0)
tel = re.search(r'\+?385[\s\-]?\d[\d\s\-/]{6,}', page)
if tel: derived['telefon'] = re.sub(r'\s+', ' ', tel.group(0).strip())
except Exception:
pass
# Merge: body fields override derived
proposed = dict(derived)
for k, v in (body_fields or {}).items():
if k in cols and v:
proposed[k] = v
# Only apply where DB currently empty
applied = {}
for k, v in proposed.items():
if k in cols and (row.get(k) is None or row.get(k)==''):
applied[k] = v
if applied:
sets = ', '.join([f"{k}=%s" for k in applied])
params = list(applied.values()) + [eid]
cur.execute(f"UPDATE {table} SET {sets} WHERE id=%s", params)
c.commit()
return {
'kind': kind, 'id': eid,
'proposed': proposed,
'applied': applied,
'skipped_existing': [k for k in proposed if k not in applied],
'applied_at': int(time.time()),
}
+3 -3
View File
@@ -219,7 +219,7 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
<div class="sb-h"> <div class="sb-h">
<div class="logo">PGŽ <span class="g">SPORT</span></div> <div class="logo">PGŽ <span class="g">SPORT</span></div>
<div class="sub">Primorsko-goranska županija</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> </div>
<nav class="sb-nav" id="nav"></nav> <nav class="sb-nav" id="nav"></nav>
<div class="sb-foot">v2.0 · 2026</div> <div class="sb-foot">v2.0 · 2026</div>
@@ -475,7 +475,7 @@ function toggleSidebar(){
const tg = document.getElementById('sb-toggle'); const tg = document.getElementById('sb-toggle');
if(!sb) return; if(!sb) return;
const isCollapsed = sb.classList.toggle('collapsed'); 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){} try { localStorage.setItem('sidebar-state', isCollapsed ? 'collapsed' : 'expanded'); } catch(e){}
} }
function restoreSidebar(){ function restoreSidebar(){
@@ -485,7 +485,7 @@ function restoreSidebar(){
const sb = document.getElementById('sb'); const sb = document.getElementById('sb');
const tg = document.getElementById('sb-toggle'); const tg = document.getElementById('sb-toggle');
if(sb) sb.classList.add('collapsed'); if(sb) sb.classList.add('collapsed');
if(tg) tg.textContent = ''; if(tg) tg.textContent = '';
} }
} catch(e){} } catch(e){}
} }
Executable
+101
View File
@@ -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