"""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//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//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//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//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.", "

This is a test email from AlertHub. If you received this, SMTP is configured correctly.

", 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.", "

Attached is the AlertHub audit log export requested from the backoffice.

", 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.", "

Attached is the AlertHub mail log export requested from the backoffice.

", 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"))