v_1.5
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"""Backoffice routes — admins only.
|
||||
Sections:
|
||||
3. Manage Users & Permissions
|
||||
- Mail / SMTP configuration
|
||||
- Global settings (default reminder days, scheduler, run-now)
|
||||
- Audit log viewer
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, current_app, Response
|
||||
from flask_login import current_user
|
||||
|
||||
from .models import db, User, Alert, AppSetting, AuditLog, MailLog
|
||||
from .forms import UserForm, MailConfigForm, TestMailForm, GlobalSettingsForm, LogEmailForm
|
||||
from .auth_utils import admin_required, get_remote_ip
|
||||
from .mailer import send_mail, get_mail_config
|
||||
from .scheduler import trigger_now
|
||||
|
||||
backoffice_bp = Blueprint("backoffice", __name__)
|
||||
|
||||
|
||||
def _csv_response(filename: str, header: list, rows) -> Response:
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(header)
|
||||
for r in rows:
|
||||
writer.writerow(r)
|
||||
data = buf.getvalue()
|
||||
return Response(
|
||||
data,
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _audit_rows():
|
||||
for e in AuditLog.query.order_by(AuditLog.timestamp.desc()).all():
|
||||
yield [
|
||||
e.timestamp.strftime("%Y-%m-%d %H:%M:%S") if e.timestamp else "",
|
||||
e.username or "",
|
||||
e.action or "",
|
||||
e.details or "",
|
||||
e.ip_address or "",
|
||||
]
|
||||
|
||||
|
||||
def _maillog_rows():
|
||||
for m in MailLog.query.order_by(MailLog.timestamp.desc()).all():
|
||||
yield [
|
||||
m.timestamp.strftime("%Y-%m-%d %H:%M:%S") if m.timestamp else "",
|
||||
m.kind or "",
|
||||
m.recipients or "",
|
||||
m.subject or "",
|
||||
m.status or "",
|
||||
m.sent_copy or "",
|
||||
m.triggered_by or "",
|
||||
m.info or "",
|
||||
]
|
||||
|
||||
|
||||
AUDIT_CSV_HEADER = ["timestamp_utc", "user", "action", "details", "ip"]
|
||||
MAILLOG_CSV_HEADER = ["timestamp_utc", "kind", "recipients", "subject", "status", "sent_copy", "triggered_by", "info"]
|
||||
|
||||
|
||||
def _build_csv_string(header, rows) -> str:
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(header)
|
||||
for r in rows:
|
||||
writer.writerow(r)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Backoffice home — admin dashboard
|
||||
# --------------------------------------------------------------------------- #
|
||||
@backoffice_bp.route("/")
|
||||
@admin_required
|
||||
def dashboard():
|
||||
user_count = User.query.count()
|
||||
admin_count = User.query.filter_by(role="admin").count()
|
||||
active_alerts = Alert.query.filter_by(is_active=True).count()
|
||||
today = date.today()
|
||||
expiring_soon = Alert.query.filter(
|
||||
Alert.is_active.is_(True),
|
||||
Alert.expiration_date >= today,
|
||||
).order_by(Alert.expiration_date.asc()).limit(5).all()
|
||||
recent_audit = AuditLog.query.order_by(AuditLog.timestamp.desc()).limit(10).all()
|
||||
return render_template(
|
||||
"backoffice/dashboard.html",
|
||||
user_count=user_count,
|
||||
admin_count=admin_count,
|
||||
active_alerts=active_alerts,
|
||||
expiring_soon=expiring_soon,
|
||||
recent_audit=recent_audit,
|
||||
today=today,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# User management
|
||||
# --------------------------------------------------------------------------- #
|
||||
@backoffice_bp.route("/users")
|
||||
@admin_required
|
||||
def users_list():
|
||||
users = User.query.order_by(User.username.asc()).all()
|
||||
return render_template("backoffice/users_list.html", users=users)
|
||||
|
||||
|
||||
@backoffice_bp.route("/users/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def user_new():
|
||||
form = UserForm()
|
||||
if form.validate_on_submit():
|
||||
if not form.password.data:
|
||||
flash("Password is required for a new user.", "error")
|
||||
elif User.query.filter((User.username == form.username.data) | (User.email == form.email.data)).first():
|
||||
flash("Username or email already exists.", "error")
|
||||
elif len(form.password.data) < current_app.config["PASSWORD_MIN_LENGTH"]:
|
||||
flash(f"Password must be at least {current_app.config['PASSWORD_MIN_LENGTH']} characters.", "error")
|
||||
else:
|
||||
user = User(
|
||||
username=form.username.data.strip(),
|
||||
email=form.email.data.strip().lower(),
|
||||
role=form.role.data,
|
||||
is_active=form.is_active.data,
|
||||
)
|
||||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
AuditLog.record("user.create", user=current_user, details=f"new={user.username} role={user.role}", ip=get_remote_ip())
|
||||
flash(f"User '{user.username}' created.", "success")
|
||||
return redirect(url_for("backoffice.users_list"))
|
||||
return render_template("backoffice/user_form.html", form=form, user=None)
|
||||
|
||||
|
||||
@backoffice_bp.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def user_edit(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
form = UserForm(obj=user)
|
||||
if form.validate_on_submit():
|
||||
# Username / email uniqueness check (excluding self)
|
||||
clash = User.query.filter(
|
||||
User.id != user.id,
|
||||
(User.username == form.username.data) | (User.email == form.email.data.lower())
|
||||
).first()
|
||||
if clash:
|
||||
flash("Another user already has that username or email.", "error")
|
||||
else:
|
||||
# Prevent self-demotion of last admin / self-deactivation lock-out
|
||||
if user.id == current_user.id:
|
||||
if form.role.data != "admin":
|
||||
flash("You cannot demote yourself.", "error")
|
||||
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||
if not form.is_active.data:
|
||||
flash("You cannot deactivate your own account.", "error")
|
||||
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||
|
||||
# Prevent removing the last active admin
|
||||
if user.role == "admin" and form.role.data != "admin":
|
||||
other_admins = User.query.filter(User.id != user.id, User.role == "admin", User.is_active.is_(True)).count()
|
||||
if other_admins == 0:
|
||||
flash("Cannot demote the last administrator.", "error")
|
||||
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||
|
||||
user.username = form.username.data.strip()
|
||||
user.email = form.email.data.strip().lower()
|
||||
user.role = form.role.data
|
||||
user.is_active = form.is_active.data
|
||||
if form.password.data:
|
||||
if len(form.password.data) < current_app.config["PASSWORD_MIN_LENGTH"]:
|
||||
flash(f"Password must be at least {current_app.config['PASSWORD_MIN_LENGTH']} characters.", "error")
|
||||
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||
user.set_password(form.password.data)
|
||||
db.session.commit()
|
||||
AuditLog.record("user.update", user=current_user, details=f"id={user.id} username={user.username}", ip=get_remote_ip())
|
||||
flash("User updated.", "success")
|
||||
return redirect(url_for("backoffice.users_list"))
|
||||
return render_template("backoffice/user_form.html", form=form, user=user)
|
||||
|
||||
|
||||
@backoffice_bp.route("/users/<int:user_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def user_delete(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
if user.id == current_user.id:
|
||||
flash("You cannot delete your own account.", "error")
|
||||
return redirect(url_for("backoffice.users_list"))
|
||||
if user.role == "admin":
|
||||
other_admins = User.query.filter(User.id != user.id, User.role == "admin", User.is_active.is_(True)).count()
|
||||
if other_admins == 0:
|
||||
flash("Cannot delete the last administrator.", "error")
|
||||
return redirect(url_for("backoffice.users_list"))
|
||||
username = user.username
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
AuditLog.record("user.delete", user=current_user, details=f"deleted={username}", ip=get_remote_ip())
|
||||
flash(f"User '{username}' deleted.", "success")
|
||||
return redirect(url_for("backoffice.users_list"))
|
||||
|
||||
|
||||
@backoffice_bp.route("/users/<int:user_id>/reset-mfa", methods=["POST"])
|
||||
@admin_required
|
||||
def user_reset_mfa(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
user.mfa_enabled = False
|
||||
user.mfa_secret = None
|
||||
db.session.commit()
|
||||
AuditLog.record("user.mfa_reset", user=current_user, details=f"target={user.username}", ip=get_remote_ip())
|
||||
flash(f"MFA reset for '{user.username}'. They can re-enrol on next login.", "info")
|
||||
return redirect(url_for("backoffice.users_list"))
|
||||
|
||||
|
||||
@backoffice_bp.route("/users/<int:user_id>/unlock", methods=["POST"])
|
||||
@admin_required
|
||||
def user_unlock(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
user.locked_until = None
|
||||
user.failed_login_count = 0
|
||||
db.session.commit()
|
||||
AuditLog.record("user.unlock", user=current_user, details=f"target={user.username}", ip=get_remote_ip())
|
||||
flash(f"Unlocked '{user.username}'.", "success")
|
||||
return redirect(url_for("backoffice.users_list"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mail configuration
|
||||
# --------------------------------------------------------------------------- #
|
||||
@backoffice_bp.route("/mail-config", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def mail_config():
|
||||
form = MailConfigForm()
|
||||
test_form = TestMailForm()
|
||||
|
||||
if request.method == "GET":
|
||||
cfg = get_mail_config()
|
||||
form.smtp_host.data = cfg["host"]
|
||||
form.smtp_port.data = cfg["port"]
|
||||
form.smtp_username.data = cfg["username"]
|
||||
form.smtp_encryption.data = cfg["encryption"]
|
||||
form.smtp_from_address.data = cfg["from_address"]
|
||||
form.smtp_from_name.data = cfg["from_name"]
|
||||
form.imap_enabled.data = cfg["imap_enabled"]
|
||||
form.imap_host.data = cfg["imap_host"]
|
||||
form.imap_port.data = cfg["imap_port"]
|
||||
form.imap_username.data = cfg["imap_username"]
|
||||
form.imap_sent_folder.data = cfg["imap_sent_folder"]
|
||||
|
||||
if form.submit.data and form.validate_on_submit():
|
||||
AppSetting.set("smtp_host", form.smtp_host.data.strip())
|
||||
AppSetting.set("smtp_port", str(form.smtp_port.data))
|
||||
AppSetting.set("smtp_username", form.smtp_username.data.strip())
|
||||
if form.smtp_password.data:
|
||||
AppSetting.set("smtp_password", form.smtp_password.data)
|
||||
AppSetting.set("smtp_encryption", form.smtp_encryption.data)
|
||||
AppSetting.set("smtp_from_address", form.smtp_from_address.data.strip().lower())
|
||||
AppSetting.set("smtp_from_name", (form.smtp_from_name.data or "").strip() or "AlertHub")
|
||||
# ---- IMAP (Sent-folder copy) ----
|
||||
AppSetting.set("imap_enabled", "true" if form.imap_enabled.data else "false")
|
||||
AppSetting.set("imap_host", (form.imap_host.data or "").strip())
|
||||
AppSetting.set("imap_port", str(form.imap_port.data or 993))
|
||||
AppSetting.set("imap_username", (form.imap_username.data or "").strip())
|
||||
if form.imap_password.data:
|
||||
AppSetting.set("imap_password", form.imap_password.data)
|
||||
AppSetting.set("imap_sent_folder", (form.imap_sent_folder.data or "").strip() or "Sent")
|
||||
AuditLog.record("mailconfig.update", user=current_user, ip=get_remote_ip())
|
||||
flash("Mail configuration saved.", "success")
|
||||
return redirect(url_for("backoffice.mail_config"))
|
||||
|
||||
return render_template("backoffice/mail_config.html", form=form, test_form=test_form)
|
||||
|
||||
|
||||
@backoffice_bp.route("/mail-config/test", methods=["POST"])
|
||||
@admin_required
|
||||
def mail_test():
|
||||
test_form = TestMailForm()
|
||||
if test_form.validate_on_submit():
|
||||
ok, info = send_mail(
|
||||
test_form.test_recipient.data.strip(),
|
||||
"[AlertHub] Test email",
|
||||
"This is a test email from AlertHub. If you received this, SMTP is configured correctly.",
|
||||
"<p>This is a <strong>test email</strong> from AlertHub. If you received this, SMTP is configured correctly.</p>",
|
||||
kind="test",
|
||||
triggered_by=current_user.username,
|
||||
)
|
||||
AuditLog.record("mailconfig.test", user=current_user, details=f"to={test_form.test_recipient.data} ok={ok}", ip=get_remote_ip())
|
||||
flash(("Test email sent. " if ok else "Test failed: ") + info, "success" if ok else "error")
|
||||
else:
|
||||
flash("Provide a valid recipient email address.", "error")
|
||||
return redirect(url_for("backoffice.mail_config"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Global settings + scheduler trigger
|
||||
# --------------------------------------------------------------------------- #
|
||||
@backoffice_bp.route("/settings", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def settings():
|
||||
form = GlobalSettingsForm()
|
||||
if request.method == "GET":
|
||||
form.default_reminder_days.data = AppSetting.get("default_reminder_days", "30,14,7,1")
|
||||
if form.validate_on_submit():
|
||||
cleaned = ",".join(
|
||||
str(x) for x in sorted({int(p.strip()) for p in form.default_reminder_days.data.split(",") if p.strip().isdigit()}, reverse=True)
|
||||
)
|
||||
if not cleaned:
|
||||
flash("Please enter at least one valid day value.", "error")
|
||||
else:
|
||||
AppSetting.set("default_reminder_days", cleaned)
|
||||
AuditLog.record("settings.update", user=current_user, details=f"default_reminder_days={cleaned}", ip=get_remote_ip())
|
||||
flash("Settings saved.", "success")
|
||||
return redirect(url_for("backoffice.settings"))
|
||||
scheduler_interval = current_app.config.get("SCHEDULER_INTERVAL_MINUTES", 60)
|
||||
return render_template("backoffice/settings.html", form=form, scheduler_interval=scheduler_interval)
|
||||
|
||||
|
||||
@backoffice_bp.route("/settings/run-check", methods=["POST"])
|
||||
@admin_required
|
||||
def run_check_now():
|
||||
"""Manually run the reminder scan once."""
|
||||
try:
|
||||
trigger_now(current_app._get_current_object())
|
||||
AuditLog.record("scheduler.manual_run", user=current_user, ip=get_remote_ip())
|
||||
flash("Reminder check executed.", "success")
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Manual scheduler run failed")
|
||||
flash(f"Reminder check failed: {e}", "error")
|
||||
return redirect(url_for("backoffice.settings"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Audit log viewer
|
||||
# --------------------------------------------------------------------------- #
|
||||
@backoffice_bp.route("/audit-log")
|
||||
@admin_required
|
||||
def audit_log():
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = 50
|
||||
pagination = AuditLog.query.order_by(AuditLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||
return render_template("backoffice/audit_log.html", pagination=pagination, log_email_form=LogEmailForm())
|
||||
|
||||
|
||||
@backoffice_bp.route("/audit-log/export")
|
||||
@admin_required
|
||||
def audit_log_export():
|
||||
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
AuditLog.record("auditlog.export", user=current_user, ip=get_remote_ip())
|
||||
return _csv_response(f"alerthub-audit-log-{stamp}.csv", AUDIT_CSV_HEADER, _audit_rows())
|
||||
|
||||
|
||||
@backoffice_bp.route("/audit-log/email", methods=["POST"])
|
||||
@admin_required
|
||||
def audit_log_email():
|
||||
form = LogEmailForm()
|
||||
if not form.validate_on_submit():
|
||||
flash("Provide a valid destination email address.", "error")
|
||||
return redirect(url_for("backoffice.audit_log"))
|
||||
recipient = form.log_recipient.data.strip()
|
||||
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
csv_str = _build_csv_string(AUDIT_CSV_HEADER, _audit_rows())
|
||||
ok, info = send_mail(
|
||||
recipient,
|
||||
f"[AlertHub] Audit log export ({stamp} UTC)",
|
||||
"Attached is the AlertHub audit log export requested from the backoffice.",
|
||||
"<p>Attached is the <strong>AlertHub audit log</strong> export requested from the backoffice.</p>",
|
||||
attachments=[(f"alerthub-audit-log-{stamp}.csv", "text", "csv", csv_str.encode("utf-8"))],
|
||||
kind="log-export",
|
||||
triggered_by=current_user.username,
|
||||
)
|
||||
AuditLog.record("auditlog.email", user=current_user, details=f"to={recipient} ok={ok}", ip=get_remote_ip())
|
||||
flash(("Audit log emailed. " if ok else "Failed to email log: ") + info, "success" if ok else "error")
|
||||
return redirect(url_for("backoffice.audit_log"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mail log viewer (every message the platform sends)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@backoffice_bp.route("/mail-log")
|
||||
@admin_required
|
||||
def mail_log():
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = 50
|
||||
pagination = MailLog.query.order_by(MailLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||
return render_template("backoffice/mail_log.html", pagination=pagination, log_email_form=LogEmailForm())
|
||||
|
||||
|
||||
@backoffice_bp.route("/mail-log/export")
|
||||
@admin_required
|
||||
def mail_log_export():
|
||||
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
AuditLog.record("maillog.export", user=current_user, ip=get_remote_ip())
|
||||
return _csv_response(f"alerthub-mail-log-{stamp}.csv", MAILLOG_CSV_HEADER, _maillog_rows())
|
||||
|
||||
|
||||
@backoffice_bp.route("/mail-log/email", methods=["POST"])
|
||||
@admin_required
|
||||
def mail_log_email():
|
||||
form = LogEmailForm()
|
||||
if not form.validate_on_submit():
|
||||
flash("Provide a valid destination email address.", "error")
|
||||
return redirect(url_for("backoffice.mail_log"))
|
||||
recipient = form.log_recipient.data.strip()
|
||||
stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
csv_str = _build_csv_string(MAILLOG_CSV_HEADER, _maillog_rows())
|
||||
ok, info = send_mail(
|
||||
recipient,
|
||||
f"[AlertHub] Mail log export ({stamp} UTC)",
|
||||
"Attached is the AlertHub mail log export requested from the backoffice.",
|
||||
"<p>Attached is the <strong>AlertHub mail log</strong> export requested from the backoffice.</p>",
|
||||
attachments=[(f"alerthub-mail-log-{stamp}.csv", "text", "csv", csv_str.encode("utf-8"))],
|
||||
kind="log-export",
|
||||
triggered_by=current_user.username,
|
||||
)
|
||||
AuditLog.record("maillog.email", user=current_user, details=f"to={recipient} ok={ok}", ip=get_remote_ip())
|
||||
flash(("Mail log emailed. " if ok else "Failed to email log: ") + info, "success" if ok else "error")
|
||||
return redirect(url_for("backoffice.mail_log"))
|
||||
Reference in New Issue
Block a user