Add per-user Run Checks sort and filter preferences

This commit is contained in:
Ivo Oskamp 2026-02-27 10:15:04 +01:00
parent e7ccff89ee
commit 2e6ff18878
7 changed files with 465 additions and 9 deletions

View File

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

View File

@ -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())

View File

@ -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()

View File

@ -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:

View File

@ -41,6 +41,81 @@
</div>
</div>
<form method="get" class="border rounded p-3 mb-3">
<div class="row g-2 align-items-end">
<div class="col-12 col-lg-4">
<label class="form-label mb-1" for="rc_filter_q">Search</label>
<input
class="form-control form-control-sm"
id="rc_filter_q"
type="text"
name="q"
value="{{ q or '' }}"
placeholder="Customer, backup software, type, job name"
/>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<label class="form-label mb-1" for="rc_filter_sort">Sort order</label>
<select class="form-select form-select-sm" id="rc_filter_sort" name="sort">
<option value="customer" {% if sort_mode == 'customer' %}selected{% endif %}>Customer &gt; Backup &gt; Type &gt; Job</option>
<option value="status" {% if sort_mode == 'status' %}selected{% endif %}>Critical &gt; Missed &gt; Warning &gt; Success (override) &gt; Success</option>
</select>
</div>
<div class="col-12 col-sm-6 col-lg-5">
<label class="form-label mb-1 d-block">Status filter</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="rc_status_critical" name="status" value="critical" {% if 'critical' in selected_status_filters %}checked{% endif %}>
<label class="form-check-label" for="rc_status_critical">Critical</label>
</div>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="rc_status_missed" name="status" value="missed" {% if 'missed' in selected_status_filters %}checked{% endif %}>
<label class="form-check-label" for="rc_status_missed">Missed</label>
</div>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="rc_status_warning" name="status" value="warning" {% if 'warning' in selected_status_filters %}checked{% endif %}>
<label class="form-check-label" for="rc_status_warning">Warning</label>
</div>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="rc_status_success_override" name="status" value="success_override" {% if 'success_override' in selected_status_filters %}checked{% endif %}>
<label class="form-check-label" for="rc_status_success_override">Success (override)</label>
</div>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="rc_status_success" name="status" value="success" {% if 'success' in selected_status_filters %}checked{% endif %}>
<label class="form-check-label" for="rc_status_success">Success</label>
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rc_has_ticket" name="has_ticket" value="1" {% if has_ticket %}checked{% endif %}>
<label class="form-check-label" for="rc_has_ticket">Only jobs with active ticket</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rc_has_remark" name="has_remark" value="1" {% if has_remark %}checked{% endif %}>
<label class="form-check-label" for="rc_has_remark">Only jobs with active remark</label>
</div>
</div>
</div>
<div class="col-12 col-lg-5 d-flex gap-2 justify-content-lg-end">
{% if is_admin and include_reviewed %}
<input type="hidden" name="include_reviewed" value="1" />
{% endif %}
<button type="submit" class="btn btn-sm btn-primary">Apply</button>
<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('main.run_checks_page') }}">Reset</a>
<button
type="submit"
class="btn btn-sm btn-outline-primary"
formmethod="post"
formaction="{{ url_for('main.run_checks_save_preferences') }}"
>
Save as my default
</button>
</div>
</div>
</form>
<div class="small text-muted mb-2" id="rc_status"></div>
<div class="table-responsive">
@ -1619,4 +1694,4 @@ table.addEventListener('change', function (e) {
updateButtons();
})();
</script>
{% endblock %}
{% endblock %}

View File

@ -9,6 +9,7 @@
<div class="card-body">
<h2 class="h6">Change password</h2>
<form method="post" class="row g-3">
<input type="hidden" name="form_name" value="password" />
<div class="col-12">
<label class="form-label" for="current_password">Current password</label>
<input class="form-control" type="password" id="current_password" name="current_password" autocomplete="current-password" required />
@ -30,4 +31,77 @@
</form>
</div>
</div>
<div class="card mt-3" style="max-width: 50rem;">
<div class="card-body">
<h2 class="h6">Run Checks preferences</h2>
<form method="post" class="row g-3">
<input type="hidden" name="form_name" value="run_checks_preferences" />
<div class="col-12">
<label class="form-label" for="run_checks_filter_q">Default search filter</label>
<input
class="form-control"
type="text"
id="run_checks_filter_q"
name="run_checks_filter_q"
value="{{ run_checks_filter_q or '' }}"
maxlength="255"
placeholder="Customer, backup software, type, job name"
/>
</div>
<div class="col-12 col-md-8">
<label class="form-label" for="run_checks_sort_mode">Default sort order</label>
<select class="form-select" id="run_checks_sort_mode" name="run_checks_sort_mode">
<option value="customer" {% if run_checks_sort_mode == 'customer' %}selected{% endif %}>Customer &gt; Backup &gt; Type &gt; Job</option>
<option value="status" {% if run_checks_sort_mode == 'status' %}selected{% endif %}>Critical &gt; Missed &gt; Warning &gt; Success (override) &gt; Success</option>
</select>
</div>
<div class="col-12">
<label class="form-label d-block">Default status filter</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="run_checks_status_critical" name="run_checks_status" value="critical" {% if 'critical' in run_checks_selected_statuses %}checked{% endif %}>
<label class="form-check-label" for="run_checks_status_critical">Critical</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="run_checks_status_missed" name="run_checks_status" value="missed" {% if 'missed' in run_checks_selected_statuses %}checked{% endif %}>
<label class="form-check-label" for="run_checks_status_missed">Missed</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="run_checks_status_warning" name="run_checks_status" value="warning" {% if 'warning' in run_checks_selected_statuses %}checked{% endif %}>
<label class="form-check-label" for="run_checks_status_warning">Warning</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="run_checks_status_success_override" name="run_checks_status" value="success_override" {% if 'success_override' in run_checks_selected_statuses %}checked{% endif %}>
<label class="form-check-label" for="run_checks_status_success_override">Success (override)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="run_checks_status_success" name="run_checks_status" value="success" {% if 'success' in run_checks_selected_statuses %}checked{% endif %}>
<label class="form-check-label" for="run_checks_status_success">Success</label>
</div>
</div>
</div>
<div class="col-12">
<div class="d-flex flex-wrap gap-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="run_checks_filter_has_ticket" name="run_checks_filter_has_ticket" value="1" {% if run_checks_filter_has_ticket %}checked{% endif %}>
<label class="form-check-label" for="run_checks_filter_has_ticket">Only jobs with active ticket</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="run_checks_filter_has_remark" name="run_checks_filter_has_remark" value="1" {% if run_checks_filter_has_remark %}checked{% endif %}>
<label class="form-check-label" for="run_checks_filter_has_remark">Only jobs with active remark</label>
</div>
</div>
</div>
<div class="col-12">
<button class="btn btn-primary" type="submit">Save Run Checks preferences</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -2,6 +2,30 @@
This file documents all changes made to this project via Claude Code.
## [2026-02-27]
### Added
- Run Checks user preferences (per user) stored on `users`:
- `run_checks_sort_mode`
- `run_checks_filter_statuses`
- `run_checks_filter_has_ticket`
- `run_checks_filter_has_remark`
- `run_checks_filter_q`
- DB migration `migrate_users_run_checks_preferences()` for the new user preference columns.
- New route `POST /run-checks/preferences` to save current Run Checks filter/sort controls as user defaults.
- User Settings page now includes a dedicated "Run Checks preferences" section (next to password self-service).
### Changed
- Run Checks now supports user-configurable sorting:
- default: Customer > Backup > Type > Job (existing behavior)
- optional: `Critical > Missed > Warning > Success (override) > Success`
- Run Checks now supports user-configurable filtering:
- status filter (`Critical`, `Missed`, `Warning`, `Success (override)`, `Success`)
- only jobs with active ticket
- only jobs with active remark
- search text
- Run Checks page loads with the logged-in user's saved defaults when no explicit query parameters are provided.
## [2026-02-23]
### Added