diff --git a/containers/backupchecks/src/backend/app/main/routes_run_checks.py b/containers/backupchecks/src/backend/app/main/routes_run_checks.py index 491a011..6044f8a 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -4,8 +4,8 @@ import calendar from datetime import date, datetime, time, timedelta, timezone -from flask import jsonify, render_template, request, url_for -from urllib.parse import urljoin +from flask import flash, jsonify, redirect, render_template, request, url_for +from urllib.parse import urlencode, urljoin from flask_login import current_user, login_required from sqlalchemy import and_, or_, func, text @@ -42,6 +42,64 @@ from ..ticketing_utils import link_open_internal_tickets_to_run AUTOTASK_TERMINAL_STATUS_IDS = {5} +RUN_CHECKS_SORT_MODES = {"customer", "status"} +RUN_CHECKS_STATUS_FILTER_VALUES = ("critical", "missed", "warning", "success_override", "success") + + +def _parse_bool_flag(raw: str | None, default: bool = False) -> bool: + if raw is None: + return bool(default) + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + +def _normalize_run_checks_sort_mode(raw: str | None) -> str: + v = (raw or "").strip().lower() + if v not in RUN_CHECKS_SORT_MODES: + return "customer" + return v + + +def _normalize_run_checks_status_filters(values: list[str] | tuple[str, ...] | None) -> list[str]: + selected = {(v or "").strip().lower() for v in (values or []) if (v or "").strip()} + return [k for k in RUN_CHECKS_STATUS_FILTER_VALUES if k in selected] + + +def _row_status_categories(status_counts: dict[str, int] | None) -> set[str]: + counts = status_counts or {} + + failed_count = int(counts.get("Failed", 0) or 0) + int(counts.get("Error", 0) or 0) + warning_count = int(counts.get("Warning", 0) or 0) + missed_count = int(counts.get("Missed", 0) or 0) + success_override_count = int(counts.get("Success (override)", 0) or 0) + success_count = int(counts.get("Success", 0) or 0) + + cats: set[str] = set() + if failed_count > 0: + cats.add("critical") + if missed_count > 0: + cats.add("missed") + if warning_count > 0: + cats.add("warning") + if success_override_count > 0: + cats.add("success_override") + if success_count > 0: + cats.add("success") + return cats + + +def _row_primary_status_rank(status_counts: dict[str, int] | None) -> int: + cats = _row_status_categories(status_counts) + if "critical" in cats: + return 0 + if "missed" in cats: + return 1 + if "warning" in cats: + return 2 + if "success_override" in cats: + return 3 + if "success" in cats: + return 4 + return 9 def _is_hidden_3cx_non_backup(backup_software: str | None, backup_type: str | None) -> bool: @@ -837,7 +895,35 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date) def run_checks_page(): """Run Checks page: list jobs that have runs to review (including generated missed runs).""" - q = (request.args.get("q") or "").strip() + default_q = (getattr(current_user, "run_checks_filter_q", None) or "").strip() + default_sort_mode = _normalize_run_checks_sort_mode(getattr(current_user, "run_checks_sort_mode", "customer")) + default_status_filters = _normalize_run_checks_status_filters( + (getattr(current_user, "run_checks_filter_statuses", "") or "").split(",") + ) + default_has_ticket = bool(getattr(current_user, "run_checks_filter_has_ticket", False)) + default_has_remark = bool(getattr(current_user, "run_checks_filter_has_remark", False)) + + q_arg = request.args.get("q") + q = (q_arg if q_arg is not None else default_q).strip() + + sort_mode_arg = request.args.get("sort") + sort_mode = _normalize_run_checks_sort_mode(sort_mode_arg if sort_mode_arg is not None else default_sort_mode) + + status_args = [(x or "").strip().lower() for x in request.args.getlist("status") if (x or "").strip()] + if status_args: + selected_status_filters = _normalize_run_checks_status_filters(status_args) + else: + statuses_csv_arg = request.args.get("statuses") + if statuses_csv_arg is not None: + selected_status_filters = _normalize_run_checks_status_filters(statuses_csv_arg.split(",")) + else: + selected_status_filters = default_status_filters + + has_ticket_arg = request.args.get("has_ticket") + has_ticket = _parse_bool_flag(has_ticket_arg, default=default_has_ticket) + + has_remark_arg = request.args.get("has_remark") + has_remark = _parse_bool_flag(has_remark_arg, default=default_has_remark) def _patterns(raw: str) -> list[str]: out = [] @@ -1159,6 +1245,41 @@ def run_checks_page(): } ) + if selected_status_filters: + selected_set = set(selected_status_filters) + payload = [ + row for row in payload + if bool(_row_status_categories(row.get("status_counts", {})) & selected_set) + ] + + if has_ticket: + payload = [row for row in payload if bool(row.get("has_active_ticket", False))] + + if has_remark: + payload = [row for row in payload if bool(row.get("has_active_remark", False))] + + if sort_mode == "status": + payload.sort( + key=lambda row: ( + _row_primary_status_rank(row.get("status_counts", {})), + str(row.get("customer_name") or "").lower(), + str(row.get("backup_software") or "").lower(), + str(row.get("backup_type") or "").lower(), + str(row.get("job_name") or "").lower(), + int(row.get("job_id") or 0), + ) + ) + else: + payload.sort( + key=lambda row: ( + str(row.get("customer_name") or "").lower(), + str(row.get("backup_software") or "").lower(), + str(row.get("backup_type") or "").lower(), + str(row.get("job_name") or "").lower(), + int(row.get("job_id") or 0), + ) + ) + settings = _get_or_create_settings() autotask_enabled = bool(getattr(settings, "autotask_enabled", False)) @@ -1169,9 +1290,54 @@ def run_checks_page(): include_reviewed=include_reviewed, autotask_enabled=autotask_enabled, q=q, + sort_mode=sort_mode, + selected_status_filters=selected_status_filters, + has_ticket=has_ticket, + has_remark=has_remark, ) +@main_bp.route("/run-checks/preferences", methods=["POST"]) +@login_required +@roles_required("admin", "operator") +def run_checks_save_preferences(): + q = (request.form.get("q") or "").strip()[:255] + sort_mode = _normalize_run_checks_sort_mode(request.form.get("sort")) + selected_status_filters = _normalize_run_checks_status_filters(request.form.getlist("status")) + has_ticket = _parse_bool_flag(request.form.get("has_ticket"), default=False) + has_remark = _parse_bool_flag(request.form.get("has_remark"), default=False) + + current_user.run_checks_sort_mode = sort_mode + current_user.run_checks_filter_statuses = ",".join(selected_status_filters) + current_user.run_checks_filter_has_ticket = has_ticket + current_user.run_checks_filter_has_remark = has_remark + current_user.run_checks_filter_q = q or None + + db.session.commit() + flash("Run Checks preferences saved.", "success") + + include_reviewed = False + if get_active_role() == "admin": + include_reviewed = _parse_bool_flag(request.form.get("include_reviewed"), default=False) + + params: list[tuple[str, str]] = [("sort", sort_mode)] + if q: + params.append(("q", q)) + for status in selected_status_filters: + params.append(("status", status)) + if has_ticket: + params.append(("has_ticket", "1")) + if has_remark: + params.append(("has_remark", "1")) + if include_reviewed: + params.append(("include_reviewed", "1")) + + target = url_for("main.run_checks_page") + if params: + target = f"{target}?{urlencode(params, doseq=True)}" + return redirect(target) + + @main_bp.route("/api/run-checks/details") @login_required @roles_required("admin", "operator") diff --git a/containers/backupchecks/src/backend/app/main/routes_user_settings.py b/containers/backupchecks/src/backend/app/main/routes_user_settings.py index 3ff1b92..7fc672f 100644 --- a/containers/backupchecks/src/backend/app/main/routes_user_settings.py +++ b/containers/backupchecks/src/backend/app/main/routes_user_settings.py @@ -10,29 +10,74 @@ from .routes_shared import main_bp def user_settings(): """User self-service settings. - Currently allows the logged-in user to change their own password. + Allows the logged-in user to manage password and Run Checks preferences. """ + def _parse_bool_flag(raw: str | None, default: bool = False) -> bool: + if raw is None: + return bool(default) + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + def _normalize_sort_mode(raw: str | None) -> str: + v = (raw or "").strip().lower() + return v if v in ("customer", "status") else "customer" + + def _normalize_status_filters(values: list[str] | None) -> list[str]: + allowed = ("critical", "missed", "warning", "success_override", "success") + selected = {(x or "").strip().lower() for x in (values or []) if (x or "").strip()} + return [k for k in allowed if k in selected] + + def _prefs_payload(): + selected = [x.strip().lower() for x in (current_user.run_checks_filter_statuses or "").split(",") if x.strip()] + return { + "run_checks_sort_mode": _normalize_sort_mode(current_user.run_checks_sort_mode), + "run_checks_selected_statuses": _normalize_status_filters(selected), + "run_checks_filter_has_ticket": bool(current_user.run_checks_filter_has_ticket), + "run_checks_filter_has_remark": bool(current_user.run_checks_filter_has_remark), + "run_checks_filter_q": (current_user.run_checks_filter_q or ""), + } + if request.method == "POST": + form_name = (request.form.get("form_name") or "password").strip().lower() + + if form_name == "run_checks_preferences": + current_user.run_checks_sort_mode = _normalize_sort_mode(request.form.get("run_checks_sort_mode")) + current_user.run_checks_filter_statuses = ",".join( + _normalize_status_filters(request.form.getlist("run_checks_status")) + ) + current_user.run_checks_filter_has_ticket = _parse_bool_flag( + request.form.get("run_checks_filter_has_ticket"), + default=False, + ) + current_user.run_checks_filter_has_remark = _parse_bool_flag( + request.form.get("run_checks_filter_has_remark"), + default=False, + ) + q = (request.form.get("run_checks_filter_q") or "").strip()[:255] + current_user.run_checks_filter_q = q or None + db.session.commit() + flash("Run Checks preferences updated.", "success") + return redirect(url_for("main.user_settings")) + current_password = request.form.get("current_password") or "" new_password = (request.form.get("new_password") or "").strip() confirm_password = (request.form.get("confirm_password") or "").strip() if not current_user.check_password(current_password): flash("Current password is incorrect.", "danger") - return render_template("main/user_settings.html") + return render_template("main/user_settings.html", **_prefs_payload()) if not new_password: flash("New password is required.", "danger") - return render_template("main/user_settings.html") + return render_template("main/user_settings.html", **_prefs_payload()) if new_password != confirm_password: flash("Passwords do not match.", "danger") - return render_template("main/user_settings.html") + return render_template("main/user_settings.html", **_prefs_payload()) current_user.set_password(new_password) db.session.commit() flash("Password updated.", "success") return redirect(url_for("main.user_settings")) - return render_template("main/user_settings.html") + return render_template("main/user_settings.html", **_prefs_payload()) diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 174bc48..bf939e1 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -715,6 +715,71 @@ def migrate_users_theme_preference() -> None: print("[migrations] migrate_users_theme_preference completed.") +def migrate_users_run_checks_preferences() -> None: + """Add Run Checks preference columns to users if missing.""" + table = "users" + columns = [ + ("run_checks_sort_mode", "VARCHAR(32) NOT NULL DEFAULT 'customer'"), + ("run_checks_filter_statuses", "TEXT NOT NULL DEFAULT ''"), + ("run_checks_filter_has_ticket", "BOOLEAN NOT NULL DEFAULT FALSE"), + ("run_checks_filter_has_remark", "BOOLEAN NOT NULL DEFAULT FALSE"), + ("run_checks_filter_q", "VARCHAR(255) NULL"), + ] + + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for users Run Checks preferences migration: {exc}") + return + + try: + with engine.begin() as conn: + for column, ddl in columns: + if _column_exists_on_conn(conn, table, column): + continue + conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} {ddl}')) + + conn.execute( + text( + """ + UPDATE "users" + SET run_checks_sort_mode = 'customer' + WHERE run_checks_sort_mode IS NULL OR run_checks_sort_mode = ''; + """ + ) + ) + conn.execute( + text( + """ + UPDATE "users" + SET run_checks_filter_statuses = '' + WHERE run_checks_filter_statuses IS NULL; + """ + ) + ) + conn.execute( + text( + """ + UPDATE "users" + SET run_checks_filter_has_ticket = FALSE + WHERE run_checks_filter_has_ticket IS NULL; + """ + ) + ) + conn.execute( + text( + """ + UPDATE "users" + SET run_checks_filter_has_remark = FALSE + WHERE run_checks_filter_has_remark IS NULL; + """ + ) + ) + print("[migrations] migrate_users_run_checks_preferences completed.") + except Exception as exc: + print(f"[migrations] Failed to migrate users Run Checks preferences: {exc}") + + def migrate_system_settings_eml_retention() -> None: """Add ingest_eml_retention_days to system_settings if missing. @@ -1210,6 +1275,7 @@ def run_migrations() -> None: migrate_add_username_to_users() migrate_make_email_nullable() migrate_users_theme_preference() + migrate_users_run_checks_preferences() migrate_system_settings_eml_retention() migrate_system_settings_auto_import_cutoff_date() migrate_system_settings_daily_jobs_start_date() diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 78ea23b..05b388d 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -21,6 +21,12 @@ class User(db.Model, UserMixin): role = db.Column(db.String(50), nullable=False, default="viewer") # UI theme preference: 'auto' (follow OS), 'light', 'dark' theme_preference = db.Column(db.String(16), nullable=False, default="auto") + # Run Checks user preferences + run_checks_sort_mode = db.Column(db.String(32), nullable=False, default="customer") + run_checks_filter_statuses = db.Column(db.Text, nullable=False, default="") + run_checks_filter_has_ticket = db.Column(db.Boolean, nullable=False, default=False) + run_checks_filter_has_remark = db.Column(db.Boolean, nullable=False, default=False) + run_checks_filter_q = db.Column(db.String(255), nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) def set_password(self, password: str) -> None: diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index 98655e6..3db2e15 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -41,6 +41,81 @@ +
+