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:
+183
-7
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
enrich_router.py — Round-2 enrichment endpoint
|
||||
Author: dradulic@outlook.com Date: 2026-05-04
|
||||
enrich_router.py — Round-2/3B enrichment + forensic-scan endpoints
|
||||
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:
|
||||
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.
|
||||
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.
|
||||
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 psycopg2, psycopg2.extras
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DB = dict(host=os.environ.get('PG_HOST','10.10.0.2'),
|
||||
port=int(os.environ.get('PG_PORT','6432')),
|
||||
_pgh = os.environ.get('PG_HOST','10.10.0.2')
|
||||
_pgp = int(os.environ.get('PG_PORT','6432'))
|
||||
# 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'),
|
||||
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'
|
||||
|
||||
@@ -132,3 +143,168 @@ def enrich(kind: str, eid: int):
|
||||
'research_links': _research_links(naziv, kind, grad),
|
||||
'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()),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user