Files
pgz-sport/auth/admin_users.py
T
damir 1d02c0897d Sidebar: +ERP +CRM +Dokumenti, godišnjaci import (18 PDFs), filter helpers
- pgz nav now includes /erp/full, /crm/v2, /admin/users, /dokumenti
- 4 dokumenti endpoints: list, godišnjaci/list, godišnjak/{godina} PDF, detail
- 18 godišnjaka u pgz_sport.dokumenti (2006-2024) with savez_id=333
- PGŽ filter helpers (window._pgz_filter_priority, togglePGZFilter)
- navItemClick handler for nav items with href
2026-05-05 13:08:11 +02:00

500 lines
26 KiB
Python

#!/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,
issue_action_token, INVITE_TTL, _build_link,
)
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)):
"""Generate a single-use invite token; the user clicks the emailed link
and lands on /login/setup-password?token=… to set their password."""
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")
ip, ua = _client(request)
# Mark must_change_pwd and revoke any existing sessions so old creds can't log in
db_exec("""UPDATE pgz_sport.users SET must_change_pwd=true, updated_at=now()
WHERE id=%s""", (uid,))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
raw_token = issue_action_token(uid, "invite", INVITE_TTL,
created_by=actor["id"], ip=ip,
meta={"email": target["email"], "note": req.note})
invite_link = _build_link("/static/login.html?setup=1", raw_token)
api_link = _build_link("/api/auth/setup-password", raw_token)
# R6 #3: send invite email (mock in dev)
mail_result = None
if req.send_email:
try:
from .mailer import send_invite
mail_result = send_invite(
target["email"], invite_link,
int(INVITE_TTL.total_seconds()),
inviter=actor.get("email"),
role=target.get("user_type"),
)
except Exception as e:
print(f"[invite mail WARN] {e}")
audit(actor["id"], "user.invite", "user", uid,
{"email": target["email"], "send_email": req.send_email,
"ttl_days": INVITE_TTL.days,
"mail_sent": bool(mail_result and mail_result.get("sent")),
"mail_mock": bool(mail_result and mail_result.get("mock"))}, ip, ua)
return {"status": "ok", "id": uid,
"email": target["email"],
"invite_link": invite_link,
"api_link": api_link,
"expires_in_seconds": int(INVITE_TTL.total_seconds()),
"email_sent": bool(mail_result and mail_result.get("sent")),
"email_mock": bool(mail_result and mail_result.get("mock")),
"email_file": (mail_result or {}).get("file")}
# ─────────────────────────── Role change ───────────────────────────
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}
# ─────────────────────────── 2FA admin (status / force disable) ───────────────────────────
@router.get("/users/{uid}/2fa-status")
def admin_2fa_status(uid: int, 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")
row = db_one("""SELECT enabled, verified_at, created_at, updated_at
FROM pgz_sport.user_2fa WHERE user_id=%s""", (uid,))
return {"enabled": bool(row and row.get("enabled")),
"verified_at": row and row.get("verified_at"),
"created_at": row and row.get("created_at"),
"updated_at": row and row.get("updated_at")}
@router.post("/users/{uid}/2fa-disable")
def admin_2fa_disable(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")
db_exec("DELETE FROM pgz_sport.user_2fa WHERE user_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.2fa.admin_disable", "user", uid,
{"email": target["email"]}, ip, ua)
return {"status": "ok", "id": uid, "two_factor_enabled": False}
# ─────────────────────────── 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]}