Files
pgz-sport/erp/notifications.py
T
Damir Radulić f9ebcddf28 CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended:
- Was: /api/admin/* only
- Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT
- Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password,
  /password/reset, /reset-password, /setup-password, /google;
  /api/gdpr/consent; any path ending /avatar
- 14 mutating endpoints verified to return 401 without token

#2 Avatar upload demo mode (routers/clan_panel_router.py):
- Anonymous → returns {demo_mode:true, slika_url:null,
  message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'},
  no FS write, no DB write
- Authenticated (valid JWT, allowed role) → real save as before
- Auth check now uses auth.auth_v2.decode_token (proper secret + revocation)
  instead of the broken local _resolve_role

#3 Mock mailer (auth/mailer.py):
- send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl
- send_password_reset, send_invite helpers with HR text + HTML alt
- Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default)
- forgot-password and admin invite both call mailer; audit logs mail status

#5 Rate limiting on /api/auth/login:
- Per-user: 5 wrong attempts → 5-minute DB-backed lockout
  (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES.
- Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429
  Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful
  login clears the IP counter.
- Failed attempts respond '(N/5) — račun je zaključan na 5 minuta'
- New audit actions: login.ratelimit.ip; login.fail meta now
  includes fails count, locked, lock_minutes

#4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14
   mutating endpoints, public path whitelist, demo-mode avatar +
   real save, forgot-password e-mail to mailbox, no-leak unknown email,
   5-fail lockout, 423 during lockout, audit coverage.
2026-05-05 01:42:53 +02:00

208 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# erp/notifications.py — PGŽ Sport ERP mock e-mail notifikacije (R6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: Mock e-mail / channel='email' notifikacije pri promjeni statusa
# ERP entiteta. Upisuje u pgz_sport.notifications + log line.
# U produkciji se može zamijeniti pravim SMTP/Mailgun adapterom.
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Optional, Iterable
import psycopg2
import psycopg2.extras
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
LOG_PATH = os.environ.get("ERP_NOTIFY_LOG", "/var/log/pgz-sport-erp-notify.log")
logger = logging.getLogger("erp.notifications")
if not logger.handlers:
logger.setLevel(logging.INFO)
try:
fh = logging.FileHandler(LOG_PATH)
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(fh)
except Exception:
pass
sh = logging.StreamHandler()
sh.setFormatter(logging.Formatter("[ERP-NOTIFY] %(message)s"))
logger.addHandler(sh)
def _db():
c = psycopg2.connect(**DB); c.autocommit = True; return c
def _resolve_recipients(klub_id: Optional[int], user_id: Optional[int]) -> list[dict]:
"""Vrati listu primatelja: voditelj putovanja (user_id), klub_admin svog kluba,
+ pgz_admin kao info kopija."""
out: list[dict] = []
seen = set()
try:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if user_id:
cur.execute(
"SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name "
"FROM pgz_sport.users WHERE id=%s AND status='active'", (user_id,))
r = cur.fetchone()
if r and r["email"] and r["id"] not in seen:
out.append({**r, "rola": "voditelj"}); seen.add(r["id"])
if klub_id:
cur.execute(
"""SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name
FROM pgz_sport.users
WHERE klub_id=%s AND user_type='klub_admin' AND status='active'""",
(klub_id,))
for r in cur.fetchall():
if r["email"] and r["id"] not in seen:
out.append({**r, "rola": "klub_admin"}); seen.add(r["id"])
cur.execute(
"""SELECT id, email, COALESCE(full_name, ime||' '||prezime, email) AS name
FROM pgz_sport.users
WHERE user_type='pgz_admin' AND status='active' LIMIT 5""")
for r in cur.fetchall():
if r["email"] and r["id"] not in seen:
out.append({**r, "rola": "pgz_admin"}); seen.add(r["id"])
except Exception as e:
logger.warning("recipients fetch fail: %s", e)
return out
def _store(user_id: Optional[int], subject: str, body: str, meta: dict,
channel: str = "email", status: str = "queued") -> Optional[int]:
try:
with _db() as c:
cur = c.cursor()
cur.execute(
"""INSERT INTO pgz_sport.notifications
(user_id, channel, subject, body, status, scheduled_at, meta)
VALUES (%s,%s,%s,%s,%s,NOW(),%s)
RETURNING id""",
(user_id, channel, subject[:200], body[:5000], status,
json.dumps(meta, ensure_ascii=False, default=str)),
)
return cur.fetchone()[0]
except Exception as e:
logger.warning("notification insert fail: %s", e)
return None
def _dispatch(subject: str, body: str, *, klub_id: Optional[int] = None,
user_id: Optional[int] = None, meta: Optional[dict] = None) -> dict:
meta = dict(meta or {})
recipients = _resolve_recipients(klub_id, user_id)
delivered = []
if not recipients:
# Mock: nemamo korisnika, samo log + jedan info zapis bez user_id
nid = _store(None, subject, body,
{**meta, "to": "(no_recipient)", "klub_id": klub_id})
logger.info("MOCK email (no recipient) [%s] %s", nid, subject)
return {"sent": 0, "queued": 1 if nid else 0, "ids": [nid] if nid else [],
"recipients": []}
for r in recipients:
nid = _store(r["id"], subject, body,
{**meta, "to": r["email"], "rola": r.get("rola"),
"name": r.get("name")})
if nid:
delivered.append({"id": nid, "user_id": r["id"], "email": r["email"]})
logger.info(
"MOCK email queued [%s] to=%s rola=%s subj=%r",
nid, r["email"], r.get("rola"), subject,
)
return {"sent": 0, "queued": len(delivered), "ids": [d["id"] for d in delivered],
"recipients": [d["email"] for d in delivered]}
# ─── Public helpers ────────────────────────────────────────────────────
def notify_invoice_created(invoice: dict) -> dict:
"""Račun spremljen iz OCR-a — info klub_admin."""
subj = f"Novi račun #{invoice.get('id')}: {invoice.get('vendor_name','')} (€{invoice.get('amount_gross')})"
body = (
f"Račun {invoice.get('invoice_no')} od {invoice.get('vendor_name')} "
f"(OIB {invoice.get('vendor_oib')}) iznosa €{invoice.get('amount_gross')} "
f"na datum {invoice.get('invoice_date')} unesen je u sustav.\n\n"
f"Klub: {invoice.get('klub_id')} · Vrsta: {invoice.get('invoice_kind')} · Status: {invoice.get('payment_status')}"
)
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
meta={"event": "invoice_created", "invoice_id": invoice.get("id")})
def notify_invoice_paid(invoice: dict, payment: Optional[dict] = None) -> dict:
iban = (payment or {}).get("iban_to") or invoice.get("iban_to") or ""
subj = f"Račun #{invoice.get('id')} označen kao plaćen — €{invoice.get('amount_gross')}"
body = (
f"Račun {invoice.get('invoice_no')} izdan od {invoice.get('vendor_name')} "
f"je označen kao plaćen.\n"
f"Iznos: €{invoice.get('amount_gross')}\n"
f"Datum uplate: {invoice.get('paid_date')}\n"
f"IBAN primatelja: {iban}\n"
f"Referenca: {(payment or {}).get('reference','')}"
)
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
meta={"event": "invoice_paid", "invoice_id": invoice.get("id")})
def notify_invoice_cancelled(invoice: dict, razlog: str = "") -> dict:
subj = f"Račun #{invoice.get('id')} otkazan"
body = f"Račun {invoice.get('invoice_no')} ({invoice.get('vendor_name')}) je otkazan.\nRazlog: {razlog or ''}"
return _dispatch(subj, body, klub_id=invoice.get("klub_id"),
meta={"event": "invoice_cancelled", "invoice_id": invoice.get("id"),
"razlog": razlog})
def notify_pn_submitted(pn: dict) -> dict:
subj = f"Putni nalog #{pn.get('id')} poslan na odobrenje (€{pn.get('cost_total')})"
body = (
f"Putni nalog za destinaciju '{pn.get('destination')}' "
f"({pn.get('date_from')} {pn.get('date_to')}) "
f"poslan je na odobrenje.\nIznos: €{pn.get('cost_total')}"
)
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
meta={"event": "pn_submitted", "pn_id": pn.get("id")})
def notify_pn_approved(pn: dict) -> dict:
subj = f"Putni nalog #{pn.get('id')} ODOBREN — €{pn.get('cost_total')}"
body = (
f"Putni nalog za '{pn.get('destination')}' "
f"({pn.get('date_from')} {pn.get('date_to')}) je odobren.\n"
f"Iznos: €{pn.get('cost_total')}"
)
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
meta={"event": "pn_approved", "pn_id": pn.get("id")})
def notify_pn_rejected(pn: dict, razlog: str = "") -> dict:
subj = f"Putni nalog #{pn.get('id')} ODBIJEN"
body = f"Putni nalog za '{pn.get('destination')}' je odbijen.\nRazlog: {razlog or ''}"
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
meta={"event": "pn_rejected", "pn_id": pn.get("id"), "razlog": razlog})
def notify_pn_paid(pn: dict, payment: Optional[dict] = None) -> dict:
iban = (payment or {}).get("iban_to") or ""
subj = f"Putni nalog #{pn.get('id')} ISPLAĆEN — €{pn.get('cost_total')}"
body = (
f"Putni nalog za '{pn.get('destination')}' isplaćen je voditelju.\n"
f"Iznos: €{(payment or {}).get('amount') or pn.get('cost_total')}\n"
f"IBAN primatelja: {iban}\n"
f"Datum isplate: {pn.get('paid_at') or (payment or {}).get('payment_date')}\n"
f"Referenca: {(payment or {}).get('reference','')}"
)
return _dispatch(subj, body, klub_id=pn.get("klub_id"), user_id=pn.get("user_id"),
meta={"event": "pn_paid", "pn_id": pn.get("id")})
__all__ = [
"notify_invoice_created", "notify_invoice_paid", "notify_invoice_cancelled",
"notify_pn_submitted", "notify_pn_approved", "notify_pn_rejected", "notify_pn_paid",
]