868 lines
39 KiB
Python
868 lines
39 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
pgz_sport_extended_api.py - Multi-tenant + ERP/CRM extension for /api/v1/*
|
|
Author: Damir Radulić (damir@rinet.one)
|
|
Date: 28.04.2026
|
|
Port: 8095 (mounted under /api/v2/)
|
|
Endpoints: auth, users, roles, klub-access, invoices, forms, alerts, expense reports, RAG sport agent
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Query, Body, Header, Depends, UploadFile, File, Form
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import date, datetime, timedelta
|
|
import psycopg2, psycopg2.extras
|
|
import hashlib, secrets, json, requests, os, re, time
|
|
|
|
DB = dict(host='localhost', port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
|
QDRANT = "http://10.10.0.2:6333"
|
|
EMBED = "http://localhost:9879/api/embeddings"
|
|
COLL = "pgz_sport_v1"
|
|
|
|
router = APIRouter(prefix="/api/v2", tags=["pgz_sport_v2"])
|
|
|
|
# ---------------- DB helpers ----------------
|
|
def db_query(sql: str, params=()):
|
|
with psycopg2.connect(**DB) 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 psycopg2.connect(**DB) as c:
|
|
cur = c.cursor()
|
|
cur.execute(sql, params)
|
|
if cur.description:
|
|
r = cur.fetchone()
|
|
return r[0] if r else None
|
|
c.commit()
|
|
|
|
# ---------------- Auth helpers ----------------
|
|
def hash_pw(pw: str) -> str:
|
|
return hashlib.sha256(pw.encode()).hexdigest()
|
|
|
|
def make_token() -> str:
|
|
return secrets.token_urlsafe(32)
|
|
|
|
def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]:
|
|
if not authorization: return None
|
|
token = authorization.replace('Bearer ','').strip()
|
|
th = hashlib.sha256(token.encode()).hexdigest()
|
|
s = db_one("""SELECT s.user_id, u.email, u.full_name, u.status
|
|
FROM pgz_sport.user_sessions s
|
|
JOIN pgz_sport.users u ON u.id=s.user_id
|
|
WHERE s.token_hash=%s AND s.revoked=false AND s.expires_at>now()""", (th,))
|
|
if not s: return None
|
|
return s
|
|
|
|
def require_user(user = Depends(get_current_user)):
|
|
if not user: raise HTTPException(401, "Authentication required")
|
|
return user
|
|
|
|
def user_has_role(user_id: int, role_code: str, scope_type: str = None, scope_id: int = None) -> bool:
|
|
sql = """SELECT 1 FROM pgz_sport.user_roles ur
|
|
JOIN pgz_sport.roles r ON r.id=ur.role_id
|
|
WHERE ur.user_id=%s AND r.code=%s AND ur.active=true
|
|
AND (ur.expires_at IS NULL OR ur.expires_at>now())"""
|
|
args = [user_id, role_code]
|
|
if scope_type:
|
|
sql += " AND (ur.scope_type=%s OR ur.scope_type='global')"
|
|
args.append(scope_type)
|
|
if scope_id:
|
|
sql += " AND (ur.scope_id=%s OR ur.scope_id IS NULL)"
|
|
args.append(scope_id)
|
|
return bool(db_one(sql, tuple(args)))
|
|
|
|
# ============== AUTH ENDPOINTS ==============
|
|
class LoginReq(BaseModel):
|
|
email: str
|
|
password: str
|
|
|
|
@router.post("/auth/login")
|
|
def login(req: LoginReq):
|
|
u = db_one("""SELECT id, email, full_name, password_hash, status
|
|
FROM pgz_sport.users WHERE email=%s""", (req.email.lower().strip(),))
|
|
if not u or u['status'] != 'active':
|
|
raise HTTPException(401, "Invalid credentials")
|
|
if not u['password_hash'] or u['password_hash'] != hash_pw(req.password):
|
|
raise HTTPException(401, "Invalid credentials")
|
|
|
|
token = make_token()
|
|
th = hashlib.sha256(token.encode()).hexdigest()
|
|
expires = datetime.now() + timedelta(days=30)
|
|
db_exec("""INSERT INTO pgz_sport.user_sessions (user_id, token_hash, expires_at)
|
|
VALUES (%s,%s,%s)""", (u['id'], th, expires))
|
|
db_exec("UPDATE pgz_sport.users SET last_login=now() WHERE id=%s", (u['id'],))
|
|
db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action) VALUES (%s,'login')""", (u['id'],))
|
|
|
|
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""", (u['id'],))
|
|
|
|
return {
|
|
"token": token,
|
|
"expires_at": expires.isoformat(),
|
|
"user": {"id":u['id'],"email":u['email'],"full_name":u['full_name'],"roles":roles}
|
|
}
|
|
|
|
@router.post("/auth/logout")
|
|
def logout(user = Depends(require_user)):
|
|
th = hashlib.sha256(user.get('_token','').encode()).hexdigest()
|
|
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (user['user_id'],))
|
|
return {"status":"ok"}
|
|
|
|
@router.get("/auth/me")
|
|
def me(user = Depends(require_user)):
|
|
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['user_id'],))
|
|
klubovi = db_query("""SELECT k.id, k.naziv, ukl.link_type, ukl.primary_klub
|
|
FROM pgz_sport.user_klub_links ukl JOIN pgz_sport.klubovi k ON k.id=ukl.klub_id
|
|
WHERE ukl.user_id=%s AND (ukl.do_datuma IS NULL OR ukl.do_datuma>now()::date)""",
|
|
(user['user_id'],))
|
|
return {**user, "roles":roles, "klubovi":klubovi}
|
|
|
|
# ============== USER MANAGEMENT (super_admin / pgz_admin) ==============
|
|
class CreateUserReq(BaseModel):
|
|
email: str
|
|
full_name: str
|
|
password: Optional[str] = None
|
|
oib: Optional[str] = None
|
|
phone: Optional[str] = None
|
|
role_code: str = 'klub_user'
|
|
scope_type: Optional[str] = None
|
|
scope_id: Optional[int] = None
|
|
|
|
@router.post("/users")
|
|
def create_user(req: CreateUserReq, user = Depends(require_user)):
|
|
if not (user_has_role(user['user_id'],'super_admin') or
|
|
user_has_role(user['user_id'],'pgz_admin') or
|
|
user_has_role(user['user_id'],'klub_admin', 'klub', req.scope_id)):
|
|
raise HTTPException(403, "Forbidden")
|
|
|
|
pw_hash = hash_pw(req.password) if req.password else None
|
|
uid = db_exec("""INSERT INTO pgz_sport.users
|
|
(email, full_name, oib, phone, password_hash, status, email_verified)
|
|
VALUES (%s,%s,%s,%s,%s,'active',false) RETURNING id""",
|
|
(req.email.lower().strip(), req.full_name, req.oib, req.phone, pw_hash))
|
|
|
|
role_id = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role_code,))
|
|
if role_id:
|
|
db_exec("""INSERT INTO pgz_sport.user_roles (user_id, role_id, scope_type, scope_id, granted_by)
|
|
VALUES (%s,%s,%s,%s,%s)""", (uid, role_id['id'], req.scope_type, req.scope_id, user['user_id']))
|
|
|
|
db_exec("""INSERT INTO pgz_sport.audit_events (user_id, action, resource_type, resource_id)
|
|
VALUES (%s,'create_user','user',%s)""", (user['user_id'], uid))
|
|
return {"id": uid, "email": req.email}
|
|
|
|
@router.get("/users")
|
|
def list_users(klub_id: Optional[int]=None, role: Optional[str]=None, limit:int=100, user = Depends(require_user)):
|
|
where, args = ["1=1"], []
|
|
if klub_id:
|
|
where.append("EXISTS (SELECT 1 FROM pgz_sport.user_klub_links ukl WHERE ukl.user_id=u.id AND ukl.klub_id=%s)")
|
|
args.append(klub_id)
|
|
if role:
|
|
where.append("""EXISTS (SELECT 1 FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
|
|
WHERE ur.user_id=u.id AND r.code=%s AND ur.active=true)""")
|
|
args.append(role)
|
|
args.append(limit)
|
|
return db_query(f"""SELECT u.id, u.email, u.full_name, u.status, u.last_login,
|
|
(SELECT array_agg(r.code) FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
|
|
WHERE ur.user_id=u.id AND ur.active=true) AS roles
|
|
FROM pgz_sport.users u WHERE {' AND '.join(where)} ORDER BY u.id LIMIT %s""", args)
|
|
|
|
class GrantRoleReq(BaseModel):
|
|
user_id: int
|
|
role_code: str
|
|
scope_type: Optional[str] = None
|
|
scope_id: Optional[int] = None
|
|
expires_at: Optional[datetime] = None
|
|
|
|
@router.post("/users/grant-role")
|
|
def grant_role(req: GrantRoleReq, user = Depends(require_user)):
|
|
if not user_has_role(user['user_id'],'super_admin'):
|
|
if not user_has_role(user['user_id'],'pgz_admin'):
|
|
raise HTTPException(403, "Only super_admin or pgz_admin can grant roles")
|
|
role = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role_code,))
|
|
if not role: raise HTTPException(404, "Unknown role")
|
|
db_exec("""INSERT INTO pgz_sport.user_roles
|
|
(user_id, role_id, scope_type, scope_id, granted_by, expires_at)
|
|
VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
|
|
(req.user_id, role['id'], req.scope_type, req.scope_id, user['user_id'], req.expires_at))
|
|
db_exec("""INSERT INTO pgz_sport.audit_events
|
|
(user_id, action, resource_type, resource_id, meta)
|
|
VALUES (%s,'grant_role','user',%s,%s::jsonb)""",
|
|
(user['user_id'], req.user_id, json.dumps({'role':req.role_code,'scope':req.scope_type})))
|
|
return {"status":"ok"}
|
|
|
|
@router.get("/roles")
|
|
def list_roles():
|
|
return db_query("SELECT id, code, naziv, opis, permissions FROM pgz_sport.roles ORDER BY id")
|
|
|
|
# ============== KLUB MEMBERSHIP LINKS ==============
|
|
class LinkUserKlubReq(BaseModel):
|
|
user_id: int
|
|
klub_id: int
|
|
clan_id: Optional[int] = None
|
|
link_type: str # 'sportas','trener','tajnik','predsjednik','clan_uprave','volonter'
|
|
od_datuma: Optional[date] = None
|
|
primary_klub: bool = True
|
|
napomena: Optional[str] = None
|
|
|
|
@router.post("/klub-links")
|
|
def link_user_klub(req: LinkUserKlubReq, user = Depends(require_user)):
|
|
if not (user_has_role(user['user_id'],'super_admin') or
|
|
user_has_role(user['user_id'],'pgz_admin') or
|
|
user_has_role(user['user_id'],'klub_admin','klub', req.klub_id)):
|
|
raise HTTPException(403, "Forbidden")
|
|
db_exec("""INSERT INTO pgz_sport.user_klub_links
|
|
(user_id, klub_id, clan_id, link_type, od_datuma, primary_klub, napomena)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
|
|
(req.user_id, req.klub_id, req.clan_id, req.link_type,
|
|
req.od_datuma or date.today(), req.primary_klub, req.napomena))
|
|
return {"status":"ok"}
|
|
|
|
# ============== INVOICES (ERP) ==============
|
|
class CreateInvoiceReq(BaseModel):
|
|
klub_id: int
|
|
invoice_kind: str # 'ulazni'|'izlazni'
|
|
invoice_no: str
|
|
vendor_name: Optional[str] = None
|
|
vendor_oib: Optional[str] = None
|
|
customer_name: Optional[str] = None
|
|
customer_oib: Optional[str] = None
|
|
invoice_date: date
|
|
due_date: Optional[date] = None
|
|
amount_net: Optional[float] = None
|
|
amount_vat: Optional[float] = None
|
|
amount_gross: float
|
|
vat_rate: Optional[float] = 25
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
account_code: Optional[str] = None
|
|
|
|
@router.post("/invoices")
|
|
def create_invoice(req: CreateInvoiceReq, user = Depends(require_user)):
|
|
if not (user_has_role(user['user_id'],'super_admin') or
|
|
user_has_role(user['user_id'],'klub_admin','klub',req.klub_id)):
|
|
raise HTTPException(403, "Forbidden")
|
|
iid = db_exec("""INSERT INTO pgz_sport.invoices
|
|
(klub_id, invoice_kind, invoice_no, vendor_name, vendor_oib, customer_name, customer_oib,
|
|
invoice_date, due_date, amount_net, amount_vat, amount_gross, vat_rate,
|
|
description, category, account_code, created_by)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
RETURNING id""", (req.klub_id, req.invoice_kind, req.invoice_no,
|
|
req.vendor_name, req.vendor_oib, req.customer_name, req.customer_oib,
|
|
req.invoice_date, req.due_date, req.amount_net, req.amount_vat, req.amount_gross,
|
|
req.vat_rate, req.description, req.category, req.account_code, user['user_id']))
|
|
db_exec("""INSERT INTO pgz_sport.audit_events
|
|
(user_id, action, resource_type, resource_id)
|
|
VALUES (%s,'create_invoice','invoice',%s)""", (user['user_id'], iid))
|
|
return {"id": iid}
|
|
|
|
@router.get("/invoices")
|
|
def list_invoices(klub_id: Optional[int]=None, kind: Optional[str]=None,
|
|
status: Optional[str]=None, year: Optional[int]=None,
|
|
limit:int=200, user=Depends(require_user)):
|
|
where, args = ["1=1"], []
|
|
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
|
|
if kind: where.append("invoice_kind=%s"); args.append(kind)
|
|
if status: where.append("payment_status=%s"); args.append(status)
|
|
if year: where.append("EXTRACT(year FROM invoice_date)=%s"); args.append(year)
|
|
args.append(limit)
|
|
return db_query(f"""SELECT id, invoice_no, invoice_kind, vendor_name, customer_name,
|
|
invoice_date, due_date, amount_gross, currency, payment_status, category, klub_id
|
|
FROM pgz_sport.invoices WHERE {' AND '.join(where)}
|
|
ORDER BY invoice_date DESC LIMIT %s""", args)
|
|
|
|
@router.get("/invoices/{invoice_id}")
|
|
def get_invoice(invoice_id: int, user=Depends(require_user)):
|
|
inv = db_one("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,))
|
|
if not inv: raise HTTPException(404)
|
|
inv['lines'] = db_query("""SELECT * FROM pgz_sport.invoice_lines
|
|
WHERE invoice_id=%s ORDER BY line_no""", (invoice_id,))
|
|
inv['payments'] = db_query("""SELECT * FROM pgz_sport.payments
|
|
WHERE invoice_id=%s ORDER BY payment_date""", (invoice_id,))
|
|
return inv
|
|
|
|
# ============== INVOICE OCR UPLOAD ==============
|
|
class OcrUploadReq(BaseModel):
|
|
klub_id: int
|
|
file_name: str
|
|
file_path: str
|
|
file_size: Optional[int] = None
|
|
mime: Optional[str] = None
|
|
sha256: Optional[str] = None
|
|
|
|
@router.post("/invoice-uploads")
|
|
def create_upload(req: OcrUploadReq, user = Depends(require_user)):
|
|
if not (user_has_role(user['user_id'],'super_admin') or
|
|
user_has_role(user['user_id'],'klub_admin','klub',req.klub_id) or
|
|
user_has_role(user['user_id'],'klub_user','klub',req.klub_id)):
|
|
raise HTTPException(403, "Forbidden")
|
|
uid = db_exec("""INSERT INTO pgz_sport.invoice_uploads
|
|
(klub_id, uploaded_by, file_name, file_path, file_size, mime, sha256)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
|
|
(req.klub_id, user['user_id'], req.file_name, req.file_path,
|
|
req.file_size, req.mime, req.sha256))
|
|
return {"id":uid, "status":"queued_for_ocr"}
|
|
|
|
@router.get("/invoice-uploads")
|
|
def list_uploads(klub_id: Optional[int]=None, status: Optional[str]=None, limit:int=100):
|
|
where, args = ["1=1"], []
|
|
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
|
|
if status: where.append("ocr_status=%s"); args.append(status)
|
|
args.append(limit)
|
|
return db_query(f"""SELECT id, klub_id, file_name, ocr_status, ai_invoice_no,
|
|
ai_amount_gross, ai_vendor_name, ai_invoice_date, uploaded_at, processed_at
|
|
FROM pgz_sport.invoice_uploads WHERE {' AND '.join(where)}
|
|
ORDER BY uploaded_at DESC LIMIT %s""", args)
|
|
|
|
# ============== EXPENSE REPORTS ==============
|
|
class ExpenseReportReq(BaseModel):
|
|
klub_id: int
|
|
user_id: Optional[int] = None
|
|
clan_id: Optional[int] = None
|
|
report_type: str # 'putni_nalog','putni_trosak','dnevnice','vlastiti_auto'
|
|
destination: Optional[str] = None
|
|
purpose: Optional[str] = None
|
|
date_from: date
|
|
date_to: date
|
|
vehicle_type: Optional[str] = None
|
|
km_driven: Optional[float] = None
|
|
cost_transport: float = 0
|
|
cost_lodging: float = 0
|
|
cost_meals: float = 0
|
|
cost_other: float = 0
|
|
dnevnice_count: int = 0
|
|
notes: Optional[str] = None
|
|
|
|
@router.post("/expense-reports")
|
|
def create_expense(req: ExpenseReportReq, user = Depends(require_user)):
|
|
cost_total = (req.cost_transport + req.cost_lodging + req.cost_meals + req.cost_other +
|
|
(req.km_driven or 0)*0.42 + req.dnevnice_count*30)
|
|
eid = db_exec("""INSERT INTO pgz_sport.expense_reports
|
|
(klub_id, user_id, clan_id, report_type, destination, purpose, date_from, date_to,
|
|
vehicle_type, km_driven, cost_transport, cost_lodging, cost_meals, cost_other,
|
|
cost_total, dnevnice_count, notes)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
RETURNING id""", (req.klub_id, req.user_id or user['user_id'], req.clan_id,
|
|
req.report_type, req.destination, req.purpose, req.date_from, req.date_to,
|
|
req.vehicle_type, req.km_driven, req.cost_transport, req.cost_lodging,
|
|
req.cost_meals, req.cost_other, cost_total, req.dnevnice_count, req.notes))
|
|
return {"id": eid, "cost_total": float(cost_total)}
|
|
|
|
@router.get("/expense-reports")
|
|
def list_expenses(klub_id: Optional[int]=None, status: Optional[str]=None, limit:int=100):
|
|
where, args = ["1=1"], []
|
|
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
|
|
if status: where.append("status=%s"); args.append(status)
|
|
args.append(limit)
|
|
return db_query(f"""SELECT * FROM pgz_sport.expense_reports
|
|
WHERE {' AND '.join(where)} ORDER BY created_at DESC LIMIT %s""", args)
|
|
|
|
# ============== FORMS ==============
|
|
@router.get("/forms/templates")
|
|
def list_form_templates(kategorija: Optional[str]=None):
|
|
where, args = ["active=true"], []
|
|
if kategorija: where.append("kategorija=%s"); args.append(kategorija)
|
|
return db_query(f"""SELECT id, code, naziv, kategorija, opis, schema_json, required_role
|
|
FROM pgz_sport.form_templates WHERE {' AND '.join(where)} ORDER BY kategorija, naziv""", args)
|
|
|
|
@router.get("/forms/templates/{code}")
|
|
def get_form_template(code: str):
|
|
t = db_one("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=true", (code,))
|
|
if not t: raise HTTPException(404)
|
|
return t
|
|
|
|
class SubmitFormReq(BaseModel):
|
|
template_code: str
|
|
klub_id: int
|
|
clan_id: Optional[int] = None
|
|
data: Dict[str, Any]
|
|
submit: bool = False # True = submit, False = save draft
|
|
|
|
@router.post("/forms/submit")
|
|
def submit_form(req: SubmitFormReq, user = Depends(require_user)):
|
|
t = db_one("SELECT id FROM pgz_sport.form_templates WHERE code=%s", (req.template_code,))
|
|
if not t: raise HTTPException(404, "Unknown template")
|
|
sid = db_exec("""INSERT INTO pgz_sport.form_submissions
|
|
(template_id, template_code, klub_id, user_id, clan_id, data, status, submitted_at)
|
|
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s) RETURNING id""",
|
|
(t['id'], req.template_code, req.klub_id, user['user_id'], req.clan_id,
|
|
json.dumps(req.data), 'submitted' if req.submit else 'draft',
|
|
datetime.now() if req.submit else None))
|
|
db_exec("""INSERT INTO pgz_sport.audit_events
|
|
(user_id, action, resource_type, resource_id, meta)
|
|
VALUES (%s,'submit_form','form',%s,%s::jsonb)""",
|
|
(user['user_id'], sid, json.dumps({'template':req.template_code})))
|
|
return {"id": sid}
|
|
|
|
@router.get("/forms/submissions")
|
|
def list_submissions(klub_id: Optional[int]=None, template_code: Optional[str]=None,
|
|
status: Optional[str]=None, limit:int=100):
|
|
where, args = ["1=1"], []
|
|
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
|
|
if template_code: where.append("template_code=%s"); args.append(template_code)
|
|
if status: where.append("status=%s"); args.append(status)
|
|
args.append(limit)
|
|
return db_query(f"""SELECT id, template_code, klub_id, user_id, clan_id, status,
|
|
submitted_at, approved_at, reference_no
|
|
FROM pgz_sport.form_submissions WHERE {' AND '.join(where)}
|
|
ORDER BY created_at DESC LIMIT %s""", args)
|
|
|
|
# ============== ALERTS ==============
|
|
@router.get("/alerts")
|
|
def list_alerts(klub_id: Optional[int]=None, severity: Optional[str]=None,
|
|
resolved: bool=False, limit:int=100):
|
|
where, args = ["rijeseno=%s"], [resolved]
|
|
if klub_id: where.append("klub_id=%s"); args.append(klub_id)
|
|
if severity: where.append("razina=%s"); args.append(severity.upper())
|
|
args.append(limit)
|
|
return db_query(f"""SELECT id, tip, razina, poruka, klub_id, clan_id, due_date,
|
|
iznos, datum, rijeseno, created_at
|
|
FROM pgz_sport.alertovi WHERE {' AND '.join(where)}
|
|
ORDER BY
|
|
CASE razina WHEN 'CRITICAL' THEN 1 WHEN 'WARNING' THEN 2 ELSE 3 END,
|
|
due_date NULLS LAST LIMIT %s""", args)
|
|
|
|
@router.post("/alerts/{alert_id}/resolve")
|
|
def resolve_alert(alert_id: int, user = Depends(require_user)):
|
|
db_exec("""UPDATE pgz_sport.alertovi SET rijeseno=true, rijeseno_at=now(), rijeseno_od=%s
|
|
WHERE id=%s""", (user['user_id'], alert_id))
|
|
return {"status":"ok"}
|
|
|
|
@router.post("/alerts/scan")
|
|
def trigger_scan(user = Depends(require_user)):
|
|
"""Run alert rules now."""
|
|
if not (user_has_role(user['user_id'],'super_admin') or user_has_role(user['user_id'],'pgz_admin')):
|
|
raise HTTPException(403)
|
|
return {"status":"scan_queued","note":"Scan triggers will be honoured on next cron tick"}
|
|
|
|
# ============== CLUB DASHBOARD ==============
|
|
@router.get("/klub/{klub_id}/dashboard")
|
|
def klub_dashboard(klub_id: int):
|
|
klub = db_one("SELECT * FROM pgz_sport.v_klub_full WHERE id=%s", (klub_id,))
|
|
if not klub: raise HTTPException(404)
|
|
return {
|
|
"klub": klub,
|
|
"clanovi_count": db_one("SELECT COUNT(*) AS n FROM pgz_sport.clanovi WHERE klub_id=%s AND aktivan=true", (klub_id,)),
|
|
"lijecnicki_isteka_30d": db_one("""SELECT COUNT(*) AS n FROM pgz_sport.lijecnicki_pregledi lp
|
|
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
|
|
WHERE c.klub_id=%s AND lp.vrijedi_do BETWEEN now()::date AND now()::date+interval '30 days'""", (klub_id,)),
|
|
"alerts_open": db_query("""SELECT id, tip, razina, poruka, due_date FROM pgz_sport.alertovi
|
|
WHERE klub_id=%s AND rijeseno=false ORDER BY
|
|
CASE razina WHEN 'CRITICAL' THEN 1 WHEN 'WARNING' THEN 2 ELSE 3 END LIMIT 10""", (klub_id,)),
|
|
"invoices_unpaid": db_one("""SELECT COUNT(*) AS n, COALESCE(SUM(amount_gross),0) AS sum_eur
|
|
FROM pgz_sport.invoices WHERE klub_id=%s AND payment_status='unpaid'""", (klub_id,)),
|
|
"clanarine_status": db_one("""SELECT
|
|
SUM(CASE WHEN status='podmireno' THEN 1 ELSE 0 END) AS placena,
|
|
SUM(CASE WHEN status='nepodmireno' THEN 1 ELSE 0 END) AS neplacena,
|
|
SUM(CASE WHEN status='djelomicno' THEN 1 ELSE 0 END) AS djelomicno,
|
|
COALESCE(SUM(iznos_propisan-COALESCE(iznos_placen,0)),0) AS dug_total
|
|
FROM pgz_sport.clanarine WHERE klub_id=%s AND godina=EXTRACT(year FROM now())::int""", (klub_id,)),
|
|
"form_drafts": db_query("""SELECT id, template_code, status, updated_at
|
|
FROM pgz_sport.form_submissions WHERE klub_id=%s AND status='draft'
|
|
ORDER BY updated_at DESC LIMIT 5""", (klub_id,)),
|
|
}
|
|
|
|
# ============== AI RAG SPORT AGENT ==============
|
|
class AskReq(BaseModel):
|
|
query: str
|
|
limit: int = 5
|
|
|
|
@router.post("/sport/ask")
|
|
def sport_ask(req: AskReq):
|
|
"""RAG over pgz_sport_v1 — return relevant context for an LLM."""
|
|
# Embed query
|
|
try:
|
|
r = requests.post(EMBED, json={"input":[req.query, req.query]}, timeout=30)
|
|
j = r.json()
|
|
emb = j.get('embeddings', [j.get('embedding')])[0]
|
|
except Exception as e:
|
|
raise HTTPException(503, f"Embedder unavailable: {e}")
|
|
# Search qdrant
|
|
r = requests.post(f"{QDRANT}/collections/{COLL}/points/search",
|
|
json={"vector": emb, "limit": req.limit, "with_payload": True}, timeout=30)
|
|
if r.status_code >= 400: raise HTTPException(503, f"Qdrant: {r.text[:200]}")
|
|
hits = r.json()['result']
|
|
return {
|
|
"query": req.query,
|
|
"results": [
|
|
{"score": h['score'],
|
|
"type": h['payload'].get('tip', h['payload'].get('type')),
|
|
"title": h['payload'].get('naziv') or h['payload'].get('title') or '?',
|
|
"snippet": (h['payload'].get('tekst') or '')[:500],
|
|
"payload": {k:v for k,v in h['payload'].items() if k != 'tekst'}}
|
|
for h in hits
|
|
]
|
|
}
|
|
|
|
# ============== CALENDAR / SCHEDULE ==============
|
|
|
|
@router.post("/sport/lawyer")
|
|
def sport_lawyer(payload: dict):
|
|
"""
|
|
AI Pravnik — odgovara na sve pravne, regulatorne i proceduralne nedoumice
|
|
iz pravilnika HOO, MINT, županije i klubova. Koristi RAG + DeepSeek/Groq LLM.
|
|
"""
|
|
q = (payload.get("query") or payload.get("question") or "").strip()
|
|
if not q:
|
|
raise HTTPException(400, "query je prazan")
|
|
|
|
# 1) RAG v2 — Fetch 25 candidates → dedup → top 6 unique docs
|
|
import requests as _requests
|
|
try:
|
|
emb_r = _requests.post(
|
|
"http://localhost:9879/api/embeddings",
|
|
json={"input": [q]}, timeout=20
|
|
)
|
|
if not emb_r.ok:
|
|
raise HTTPException(500, f"embed failed: {emb_r.status_code}")
|
|
_r = emb_r.json(); emb = _r.get("embedding") or _r.get("embeddings",[None])[0]
|
|
except Exception as e:
|
|
raise HTTPException(500, f"embedding error: {e}")
|
|
|
|
# Fetch 25 candidates (will dedup)
|
|
try:
|
|
qr = _requests.post(
|
|
"http://10.10.0.2:6333/collections/pgz_sport_v1/points/search",
|
|
json={"vector": emb, "limit": 25, "with_payload": True, "score_threshold": 0.35},
|
|
timeout=20
|
|
)
|
|
if not qr.ok:
|
|
raise HTTPException(500, f"qdrant http {qr.status_code}: {qr.text[:200]}")
|
|
all_hits = qr.json().get("result", [])
|
|
except HTTPException: raise
|
|
except Exception as e:
|
|
raise HTTPException(500, f"qdrant error: {e}")
|
|
|
|
if not all_hits:
|
|
return {"query": q, "answer": "Nema relevantnih pravilnika u bazi za ovaj upit. Probaj preformulirati pitanje konkretnije, npr. uključi specifičan sport, godinu, ili tip dokumenta (pravilnik, zakon, kriterij).", "sources": []}
|
|
|
|
# 2) Dedup by document — keep top chunk per unique doc_id/title (not all chunks of same doc)
|
|
seen_docs = {} # key = doc_id or normalized title
|
|
for h in all_hits:
|
|
p = h.get("payload") or {}
|
|
# Normalize doc identity
|
|
doc_key = p.get("doc_id") or p.get("source_url") or p.get("title", "?")
|
|
if doc_key not in seen_docs or h.get("score", 0) > seen_docs[doc_key].get("score", 0):
|
|
seen_docs[doc_key] = h
|
|
|
|
# Sort by score, take top 6 unique docs
|
|
unique_hits = sorted(seen_docs.values(), key=lambda x: x.get("score", 0), reverse=True)[:6]
|
|
hits = unique_hits
|
|
|
|
# 3) PGŽ-relevance boost: prefer PGŽ-specific docs over general national
|
|
PGZ_KW = ['pgž','pgz','primorsk','rijeka','kvarner','crikvenic','opatij','krk','cres','lošinj','rab']
|
|
def pgz_boost(h):
|
|
p = h.get("payload") or {}
|
|
all_t = ((p.get("title","") or "") + " " + (p.get("text","")[:300] or "") + " " + (p.get("source_url","") or "")).lower()
|
|
if any(k in all_t for k in PGZ_KW):
|
|
return h.get("score", 0) * 1.15 # 15% boost
|
|
return h.get("score", 0)
|
|
hits = sorted(hits, key=pgz_boost, reverse=True)
|
|
|
|
# 4) Build context with metadata
|
|
ctx_chunks = []
|
|
sources = []
|
|
for i, h in enumerate(hits):
|
|
p = h.get("payload") or {}
|
|
text = (p.get("text") or "")[:1200] # more context per chunk
|
|
title = p.get("title", "(bez naslova)")
|
|
url = p.get("source_url") or p.get("url", "")
|
|
doc_type = p.get("doc_type", "")
|
|
publish_date = p.get("publish_date", "")
|
|
source = p.get("source", "")
|
|
date_str = f", {publish_date[:10]}" if publish_date else ""
|
|
if text and len(text) > 50:
|
|
ctx_chunks.append(f"[{i+1}] {title}{date_str} ({doc_type or source}):\n{text}")
|
|
sources.append({
|
|
"id": i+1, "title": title, "url": url,
|
|
"doc_type": doc_type, "publish_date": publish_date,
|
|
"source": source, "score": round(float(h.get("score", 0)), 3)
|
|
})
|
|
|
|
context = "\n\n".join(ctx_chunks)
|
|
|
|
# 3) LLM call — DeepSeek primary
|
|
SYSTEM = """Ti si AI PRAVNIK Zajednice sportova Primorsko-goranske županije (ZSPGŽ).
|
|
Specijaliziran si za hrvatsko sportsko pravo i propise koje primjenjuje ZSPGŽ.
|
|
|
|
ZNANJE TI DOLAZI ISKLJUČIVO IZ PRILOŽENIH IZVORA. Nikada ne izmišljaj ni datume ni iznose ni članke.
|
|
|
|
PRAVILA ODGOVORA:
|
|
1. KRATAK DIREKTAN ODGOVOR PRVI (1-3 rečenice). Što tražitelj treba znati odmah.
|
|
2. DETALJI: konkretni iznosi, rokovi, članci pravilnika, postupci. Citiraj BROJEVE [1], [2]... za svaki podatak.
|
|
3. AKO INFORMACIJA NIJE U IZVORIMA: jasno kaži "Ovo pitanje nije pokriveno priloženim pravilnicima — preporučujem provjeru izravno na sport-pgz.hr ili kod nadležne osobe."
|
|
4. AKO POSTOJE SLIČNI ALI NE IDENTIČNI PRAVILNICI: navedi razliku jasno.
|
|
5. PRIORITET: ZSPGŽ pravilnici > PGŽ županijski > nacionalni HOO/MINT > zakonski tekst.
|
|
6. STIL: stručan, ali razumljiv. Hrvatski jezik. Bez fraza poput "kako je navedeno" — direktno citiraj.
|
|
7. NE PONAVLJAJ pitanje. Ne počinji s "Prema priloženim pravilnicima..." — direktno na odgovor.
|
|
|
|
FORMAT:
|
|
**Odgovor:** [1-3 rečenice s ključnim podacima i [1] referencama]
|
|
|
|
**Detalji:**
|
|
- [bullet] [konkretan podatak] [referenca]
|
|
- [bullet] [postupak] [referenca]
|
|
|
|
**Reference:** [auto generirano ispod, ne pisati u odgovoru]
|
|
|
|
Ako tražitelj pita o konkretnom iznosu/rokovima a nemaš to u izvorima, **nemoj nagađati** — kaži da nisi siguran i preporuči direktan kontakt."""
|
|
|
|
user_msg = f"PITANJE: {q}\n\nPRILOŽENI PRAVILNICI/IZVORI:\n{context}\n\nODGOVOR (sa referencama [1], [2]...):"
|
|
|
|
answer = ""
|
|
llm_used = "none"
|
|
|
|
# Try DeepSeek
|
|
try:
|
|
ds_key = os.environ.get("DEEPSEEK_API_KEY")
|
|
if ds_key:
|
|
r = _requests.post(
|
|
"https://api.deepseek.com/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {ds_key}"},
|
|
json={
|
|
"model": "deepseek-chat",
|
|
"messages": [
|
|
{"role": "system", "content": SYSTEM},
|
|
{"role": "user", "content": user_msg},
|
|
],
|
|
"temperature": 0.15,
|
|
"max_tokens": 2000,
|
|
},
|
|
timeout=60,
|
|
)
|
|
if r.ok:
|
|
answer = r.json()["choices"][0]["message"]["content"]
|
|
llm_used = "deepseek"
|
|
except Exception as e:
|
|
pass
|
|
|
|
# Fallback Groq
|
|
if not answer:
|
|
try:
|
|
gk = os.environ.get("GROQ_API_KEY")
|
|
if gk:
|
|
r = _requests.post(
|
|
"https://api.groq.com/openai/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {gk}"},
|
|
json={
|
|
"model": "llama-3.3-70b-versatile",
|
|
"messages": [
|
|
{"role": "system", "content": SYSTEM},
|
|
{"role": "user", "content": user_msg},
|
|
],
|
|
"temperature": 0.15,
|
|
"max_tokens": 2000,
|
|
},
|
|
timeout=60,
|
|
)
|
|
if r.ok:
|
|
answer = r.json()["choices"][0]["message"]["content"]
|
|
llm_used = "groq"
|
|
except Exception as e:
|
|
pass
|
|
|
|
# Local Ollama fallback
|
|
if not answer:
|
|
try:
|
|
r = _requests.post(
|
|
"http://localhost:11434/api/chat",
|
|
json={
|
|
"model": "qwen2.5:7b",
|
|
"messages": [
|
|
{"role": "system", "content": SYSTEM},
|
|
{"role": "user", "content": user_msg},
|
|
],
|
|
"stream": False,
|
|
"options": {"temperature": 0.15, "num_predict": 1500},
|
|
},
|
|
timeout=120,
|
|
)
|
|
if r.ok:
|
|
answer = r.json().get("message", {}).get("content", "")
|
|
llm_used = "ollama_qwen2.5"
|
|
except Exception:
|
|
pass
|
|
|
|
if not answer:
|
|
# No LLM available — return pure RAG with notice
|
|
answer = "**[LLM nije dostupan, evo top relevantnih izvora:]**\n\n"
|
|
for i, src_item in enumerate(sources[:3], 1):
|
|
answer += f"**[{i}] {src_item['title']}**\n"
|
|
answer += f"_{src_item.get('doc_type','')}, score={src_item['score']:.2f}_\n\n"
|
|
llm_used = "rag_only"
|
|
|
|
# Audit
|
|
try:
|
|
from psycopg2 import connect
|
|
conn = connect(host='localhost', dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
|
cu = conn.cursor()
|
|
cu.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_text, payload)
|
|
VALUES (%s,%s,%s,%s::jsonb)""",
|
|
('lawyer.query', 'sport_lawyer', q[:500],
|
|
json.dumps({"llm": llm_used, "hits": len(hits), "sources": len(sources)})))
|
|
conn.commit(); conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"query": q,
|
|
"answer": answer,
|
|
"sources": sources,
|
|
"llm": llm_used,
|
|
"hits_count": len(hits),
|
|
}
|
|
|
|
|
|
@router.get("/calendar/upcoming")
|
|
def calendar_upcoming(klub_id: Optional[int]=None, days_ahead: int=30):
|
|
end = datetime.now() + timedelta(days=days_ahead)
|
|
out = []
|
|
|
|
# Liječnički pregledi koji ističu
|
|
where = ""; args = []
|
|
if klub_id:
|
|
where = "AND c.klub_id=%s"; args = [klub_id]
|
|
args = [end] + args
|
|
rows = db_query(f"""SELECT 'lijecnicki_istek' AS type,
|
|
'Liječnički istječe — '||c.ime||' '||c.prezime AS title,
|
|
lp.vrijedi_do AS date, c.klub_id, lp.clan_id, k.naziv AS klub_naziv
|
|
FROM pgz_sport.lijecnicki_pregledi lp
|
|
JOIN pgz_sport.clanovi c ON c.id=lp.clan_id
|
|
LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
|
|
WHERE lp.vrijedi_do BETWEEN now()::date AND %s::date {where}
|
|
ORDER BY lp.vrijedi_do""", args)
|
|
out.extend(rows)
|
|
|
|
# Računi koji dospijevaju
|
|
args2 = [end]
|
|
if klub_id: args2.append(klub_id)
|
|
where2 = " AND klub_id=%s" if klub_id else ""
|
|
rows = db_query(f"""SELECT 'invoice_due' AS type,
|
|
'Račun '||invoice_no||' — '||COALESCE(vendor_name,'?')||' — '||amount_gross::text||' EUR' AS title,
|
|
due_date AS date, klub_id, NULL::int AS clan_id, NULL::text AS klub_naziv
|
|
FROM pgz_sport.invoices WHERE due_date BETWEEN now()::date AND %s::date
|
|
AND payment_status='unpaid'{where2}
|
|
ORDER BY due_date""", args2)
|
|
out.extend(rows)
|
|
|
|
# Alerts otvorene
|
|
args3 = [end]
|
|
if klub_id: args3.append(klub_id)
|
|
where3 = " AND klub_id=%s" if klub_id else ""
|
|
rows = db_query(f"""SELECT 'alert' AS type, poruka AS title, due_date AS date,
|
|
klub_id, clan_id, NULL::text FROM pgz_sport.alertovi
|
|
WHERE rijeseno=false AND due_date IS NOT NULL AND due_date<=%s::date{where3}
|
|
ORDER BY due_date""", args3)
|
|
out.extend(rows)
|
|
|
|
return sorted(out, key=lambda x: x.get('date') or date.max)
|
|
|
|
# ============== EKOSUSTAV STATS (extended) ==============
|
|
@router.get("/ekosustav/v2")
|
|
def ekosustav_v2():
|
|
return {
|
|
"savezi": db_one("""SELECT
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE razina='nacional') AS nacional,
|
|
COUNT(*) FILTER (WHERE razina='zupanijski') AS zupanijski,
|
|
COUNT(*) FILTER (WHERE razina='gradski') AS gradski
|
|
FROM pgz_sport.savezi"""),
|
|
"klubovi": db_one("""SELECT
|
|
COUNT(*) AS total,
|
|
COUNT(oib) AS s_oib,
|
|
COUNT(*) FILTER (WHERE entity_id IS NOT NULL) AS linked
|
|
FROM pgz_sport.klubovi"""),
|
|
"documents": db_one("""SELECT
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE LENGTH(COALESCE(text_extracted,''))>=200) AS extracted
|
|
FROM sport.documents"""),
|
|
"qdrant_points": (lambda: requests.get(f"{QDRANT}/collections/{COLL}").json()['result']['points_count'])(),
|
|
"users": db_one("""SELECT COUNT(*) FROM pgz_sport.users WHERE status='active'"""),
|
|
"alerts_open": db_one("""SELECT
|
|
COUNT(*) FILTER (WHERE razina='CRITICAL') AS critical,
|
|
COUNT(*) FILTER (WHERE razina='WARNING') AS warning,
|
|
COUNT(*) FILTER (WHERE razina='INFO') AS info
|
|
FROM pgz_sport.alertovi WHERE rijeseno=false"""),
|
|
"forms_templates": db_one("SELECT COUNT(*) FROM pgz_sport.form_templates WHERE active=true"),
|
|
"invoices": db_one("""SELECT
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE payment_status='unpaid') AS unpaid,
|
|
COALESCE(SUM(amount_gross),0)::numeric AS total_eur
|
|
FROM pgz_sport.invoices"""),
|
|
}
|
|
|
|
# =========== MULTIPART UPLOAD + USER CREATE (added 28apr) ===========
|
|
UPLOAD_DIR = "/var/lib/pgz-sport/invoices"
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
|
|
@router.post("/invoice-uploads/file")
|
|
async def upload_invoice_file(
|
|
file: UploadFile = File(...),
|
|
klub_id: int = Form(...),
|
|
invoice_kind: str = Form("ulazni"),
|
|
user = Depends(require_user)
|
|
):
|
|
"""Multipart upload — saves to disk + queues for OCR."""
|
|
if not (user_has_role(user['user_id'],'super_admin') or
|
|
user_has_role(user['user_id'],'klub_admin','klub',klub_id) or
|
|
user_has_role(user['user_id'],'klub_user','klub',klub_id)):
|
|
raise HTTPException(403, "Forbidden")
|
|
raw = await file.read()
|
|
sha = hashlib.sha256(raw).hexdigest()
|
|
safe = re.sub(r'[^a-zA-Z0-9._-]','_', file.filename or 'invoice')
|
|
path = f"{UPLOAD_DIR}/{klub_id}_{int(time.time())}_{sha[:8]}_{safe}"
|
|
with open(path, 'wb') as f:
|
|
f.write(raw)
|
|
uid = db_exec("""INSERT INTO pgz_sport.invoice_uploads
|
|
(klub_id, uploaded_by, file_name, file_path, file_size, mime, sha256, ocr_status)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,'pending') RETURNING id""",
|
|
(klub_id, user['user_id'], file.filename, path, len(raw), file.content_type, sha))
|
|
db_exec("INSERT INTO pgz_sport.audit_events(user_id,action,resource_type,resource_id,meta) VALUES (%s,'upload_invoice','invoice_upload',%s,%s)",
|
|
(user['user_id'], uid, json.dumps({'klub_id':klub_id,'kind':invoice_kind,'sha':sha[:16]})))
|
|
return {"upload_id": uid, "ocr_status": "pending", "klub_id": klub_id, "size": len(raw), "sha": sha[:16]}
|
|
|
|
# =========== USER CREATE (simple, no token required from super_admin via API) ===========
|
|
class CreateUserReq(BaseModel):
|
|
email: str
|
|
full_name: Optional[str] = None
|
|
password: str
|
|
role: str = "viewer"
|
|
klub_id: Optional[int] = None
|
|
oib: Optional[str] = None
|
|
phone: Optional[str] = None
|
|
|
|
@router.post("/users")
|
|
def api_create_user(req: CreateUserReq, user = Depends(require_user)):
|
|
"""Create new user. Requires super_admin or pgz_admin or klub_admin."""
|
|
if not (user_has_role(user['user_id'],'super_admin') or
|
|
user_has_role(user['user_id'],'pgz_admin') or
|
|
(req.klub_id and user_has_role(user['user_id'],'klub_admin','klub',req.klub_id))):
|
|
raise HTTPException(403, "Need super_admin/pgz_admin/klub_admin")
|
|
# Existing user?
|
|
existing = db_one("SELECT id FROM pgz_sport.users WHERE email=%s", (req.email,))
|
|
if existing:
|
|
raise HTTPException(409, f"User {req.email} already exists (id={existing['id']})")
|
|
# Create
|
|
uid = db_exec("""INSERT INTO pgz_sport.users (email, full_name, oib, phone, password_hash, status)
|
|
VALUES (%s,%s,%s,%s,%s,'active') RETURNING id""",
|
|
(req.email, req.full_name, req.oib, req.phone, hash_pw(req.password)))
|
|
# Assign role
|
|
role_row = db_one("SELECT id FROM pgz_sport.roles WHERE code=%s", (req.role,))
|
|
if role_row:
|
|
scope = ('klub', req.klub_id) if req.klub_id else ('global', None)
|
|
db_exec("""INSERT INTO pgz_sport.user_roles (user_id, role_id, scope_type, scope_id, granted_by, active)
|
|
VALUES (%s,%s,%s,%s,%s,true)""",
|
|
(uid, role_row['id'], scope[0], scope[1], user['user_id']))
|
|
db_exec("INSERT INTO pgz_sport.audit_events(user_id,action,resource_type,resource_id,meta) VALUES (%s,'create_user','user',%s,%s)",
|
|
(user['user_id'], uid, json.dumps({'email':req.email,'role':req.role,'klub_id':req.klub_id})))
|
|
return {"user_id": uid, "email": req.email, "role": req.role, "klub_id": req.klub_id}
|
|
|