0046b8d695
#1 JWT middleware: - pgz_sport_api.py: starlette middleware require_jwt_on_admin runs before every /api/admin/* route. Even routes that lack Depends(require_user) cannot be reached without a valid Bearer token (verifies signature, exp, typ='access', revocation via user_sessions). OPTIONS passes for CORS. #2 Invitation flow: - pgz_sport.user_action_tokens table (token_hash, user_id, kind, expires_at, used_at, created_by, ip, meta). Single-use, raw token never persisted. - POST /api/admin/users/{id}/invite — issues 'invite' token (TTL 7d), marks must_change_pwd, revokes existing sessions, returns invite_link. - GET /api/auth/setup-password?token=X — preflight (no consume). - POST /api/auth/setup-password — consumes token, sets password, sets email_verified=true. #3 Password reset flow: - POST /api/auth/forgot-password — generic 'ako račun postoji' response; issues 'reset' token (TTL 2h) only for active users. Token returned in response only on localhost or if PGZ_REVEAL_RESET_TOKEN=1. - GET /api/auth/reset-password?token=X — preflight. - POST /api/auth/reset-password — consumes token, sets new password, revokes all active sessions. #4 Audit coverage (auth events): - login.ok, login.fail (with reason), login.locked, login.2fa_required, login.2fa_fail, logout, auth.refresh, password.change, password.reset.ok, password.reset.fail, password.forgot.issue, password.forgot.miss, invite.consume.ok, invite.consume.fail, user.invite, user.create, user.update, user.delete, user.role.change, user.suspend, user.unsuspend, user.password.reset, 2fa.verify.ok, 2fa.verify.fail, 2fa.disable. #5 Live tests: 41/41 across 6 demo users (incl. fresh invited+deleted user). Phase 2 verifies 14 endpoints reject no-auth and accept valid Bearer.
457 lines
24 KiB
Python
457 lines
24 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)
|
|
audit(actor["id"], "user.invite", "user", uid,
|
|
{"email": target["email"], "send_email": req.send_email,
|
|
"ttl_days": INVITE_TTL.days}, ip, ua)
|
|
# NOTE: real deployment must e-mail invite_link via a mailer (M11);
|
|
# for now, the link is returned to the admin who triggered the invite.
|
|
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": False}
|
|
|
|
# ─────────────────────────── 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]}
|