diff --git a/containers/backupchecks/src/backend/app/main/routes_search.py b/containers/backupchecks/src/backend/app/main/routes_search.py index 5a0aad8..fad8192 100644 --- a/containers/backupchecks/src/backend/app/main/routes_search.py +++ b/containers/backupchecks/src/backend/app/main/routes_search.py @@ -2,9 +2,20 @@ from .routes_shared import * # noqa: F401,F403 from .routes_shared import _format_datetime from sqlalchemy import and_, cast, func, or_, String +import math SEARCH_LIMIT_PER_SECTION = 10 +SEARCH_SECTION_KEYS = [ + "inbox", + "customers", + "jobs", + "daily_jobs", + "run_checks", + "tickets", + "overrides", + "reports", +] def _is_section_allowed(section: str) -> bool: @@ -47,13 +58,50 @@ def _contains_all_terms(columns: list, patterns: list[str]): return and_(*term_filters) -def _build_inbox_results(patterns: list[str]) -> dict: +def _parse_page(value: str | None) -> int: + try: + page = int((value or "").strip()) + except Exception: + page = 1 + return page if page > 0 else 1 + + +def _paginate_query(query, page: int, order_by_cols: list): + total = query.count() + total_pages = max(1, math.ceil(total / SEARCH_LIMIT_PER_SECTION)) if total else 1 + current_page = min(max(page, 1), total_pages) + rows = ( + query.order_by(*order_by_cols) + .offset((current_page - 1) * SEARCH_LIMIT_PER_SECTION) + .limit(SEARCH_LIMIT_PER_SECTION) + .all() + ) + return total, current_page, total_pages, rows + + +def _enrich_paging(section: dict, total: int, current_page: int, total_pages: int) -> None: + section["total"] = int(total or 0) + section["current_page"] = int(current_page or 1) + section["total_pages"] = int(total_pages or 1) + section["has_prev"] = section["current_page"] > 1 + section["has_next"] = section["current_page"] < section["total_pages"] + section["prev_url"] = "" + section["next_url"] = "" + + +def _build_inbox_results(patterns: list[str], page: int) -> dict: section = { "key": "inbox", "title": "Inbox", "view_all_url": url_for("main.inbox"), "total": 0, "items": [], + "current_page": 1, + "total_pages": 1, + "has_prev": False, + "has_next": False, + "prev_url": "", + "next_url": "", } if not _is_section_allowed("inbox"): return section @@ -78,12 +126,12 @@ def _build_inbox_results(patterns: list[str]) -> dict: if match_expr is not None: query = query.filter(match_expr) - section["total"] = query.count() - rows = ( - query.order_by(MailMessage.received_at.desc().nullslast(), MailMessage.id.desc()) - .limit(SEARCH_LIMIT_PER_SECTION) - .all() + total, current_page, total_pages, rows = _paginate_query( + query, + page, + [MailMessage.received_at.desc().nullslast(), MailMessage.id.desc()], ) + _enrich_paging(section, total, current_page, total_pages) for msg in rows: parsed_flag = bool(getattr(msg, "parsed_at", None) or (msg.parse_result or "")) @@ -99,13 +147,19 @@ def _build_inbox_results(patterns: list[str]) -> dict: return section -def _build_customers_results(patterns: list[str]) -> dict: +def _build_customers_results(patterns: list[str], page: int) -> dict: section = { "key": "customers", "title": "Customers", "view_all_url": url_for("main.customers"), "total": 0, "items": [], + "current_page": 1, + "total_pages": 1, + "has_prev": False, + "has_next": False, + "prev_url": "", + "next_url": "", } if not _is_section_allowed("customers"): return section @@ -115,8 +169,12 @@ def _build_customers_results(patterns: list[str]) -> dict: if match_expr is not None: query = query.filter(match_expr) - section["total"] = query.count() - rows = query.order_by(Customer.name.asc()).limit(SEARCH_LIMIT_PER_SECTION).all() + total, current_page, total_pages, rows = _paginate_query( + query, + page, + [Customer.name.asc()], + ) + _enrich_paging(section, total, current_page, total_pages) for c in rows: try: job_count = c.jobs.count() @@ -134,13 +192,19 @@ def _build_customers_results(patterns: list[str]) -> dict: return section -def _build_jobs_results(patterns: list[str]) -> dict: +def _build_jobs_results(patterns: list[str], page: int) -> dict: section = { "key": "jobs", "title": "Jobs", "view_all_url": url_for("main.jobs"), "total": 0, "items": [], + "current_page": 1, + "total_pages": 1, + "has_prev": False, + "has_next": False, + "prev_url": "", + "next_url": "", } if not _is_section_allowed("jobs"): return section @@ -171,17 +235,17 @@ def _build_jobs_results(patterns: list[str]) -> dict: if match_expr is not None: query = query.filter(match_expr) - section["total"] = query.count() - rows = ( - query.order_by( + total, current_page, total_pages, rows = _paginate_query( + query, + page, + [ Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc(), - ) - .limit(SEARCH_LIMIT_PER_SECTION) - .all() + ], ) + _enrich_paging(section, total, current_page, total_pages) for row in rows: section["items"].append( { @@ -195,13 +259,19 @@ def _build_jobs_results(patterns: list[str]) -> dict: return section -def _build_daily_jobs_results(patterns: list[str]) -> dict: +def _build_daily_jobs_results(patterns: list[str], page: int) -> dict: section = { "key": "daily_jobs", "title": "Daily Jobs", "view_all_url": url_for("main.daily_jobs"), "total": 0, "items": [], + "current_page": 1, + "total_pages": 1, + "has_prev": False, + "has_next": False, + "prev_url": "", + "next_url": "", } if not _is_section_allowed("daily_jobs"): return section @@ -232,17 +302,17 @@ def _build_daily_jobs_results(patterns: list[str]) -> dict: if match_expr is not None: query = query.filter(match_expr) - section["total"] = query.count() - rows = ( - query.order_by( + total, current_page, total_pages, rows = _paginate_query( + query, + page, + [ Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc(), - ) - .limit(SEARCH_LIMIT_PER_SECTION) - .all() + ], ) + _enrich_paging(section, total, current_page, total_pages) for row in rows: section["items"].append( { @@ -256,13 +326,19 @@ def _build_daily_jobs_results(patterns: list[str]) -> dict: return section -def _build_run_checks_results(patterns: list[str]) -> dict: +def _build_run_checks_results(patterns: list[str], page: int) -> dict: section = { "key": "run_checks", "title": "Run Checks", "view_all_url": url_for("main.run_checks_page"), "total": 0, "items": [], + "current_page": 1, + "total_pages": 1, + "has_prev": False, + "has_next": False, + "prev_url": "", + "next_url": "", } if not _is_section_allowed("run_checks"): return section @@ -304,17 +380,17 @@ def _build_run_checks_results(patterns: list[str]) -> dict: if match_expr is not None: query = query.filter(match_expr) - section["total"] = query.count() - rows = ( - query.order_by( + total, current_page, total_pages, rows = _paginate_query( + query, + page, + [ Customer.name.asc().nullslast(), Job.backup_software.asc().nullslast(), Job.backup_type.asc().nullslast(), Job.job_name.asc().nullslast(), - ) - .limit(SEARCH_LIMIT_PER_SECTION) - .all() + ], ) + _enrich_paging(section, total, current_page, total_pages) for row in rows: section["items"].append( { @@ -328,13 +404,19 @@ def _build_run_checks_results(patterns: list[str]) -> dict: return section -def _build_tickets_results(patterns: list[str]) -> dict: +def _build_tickets_results(patterns: list[str], page: int) -> dict: section = { "key": "tickets", "title": "Tickets", "view_all_url": url_for("main.tickets_page"), "total": 0, "items": [], + "current_page": 1, + "total_pages": 1, + "has_prev": False, + "has_next": False, + "prev_url": "", + "next_url": "", } if not _is_section_allowed("tickets"): return section @@ -363,8 +445,12 @@ def _build_tickets_results(patterns: list[str]) -> dict: query = query.filter(match_expr) query = query.distinct() - section["total"] = query.count() - rows = query.order_by(Ticket.start_date.desc().nullslast()).limit(SEARCH_LIMIT_PER_SECTION).all() + total, current_page, total_pages, rows = _paginate_query( + query, + page, + [Ticket.start_date.desc().nullslast()], + ) + _enrich_paging(section, total, current_page, total_pages) for t in rows: customer_display = "-" @@ -418,13 +504,19 @@ def _build_tickets_results(patterns: list[str]) -> dict: return section -def _build_overrides_results(patterns: list[str]) -> dict: +def _build_overrides_results(patterns: list[str], page: int) -> dict: section = { "key": "overrides", "title": "Existing overrides", "view_all_url": url_for("main.overrides"), "total": 0, "items": [], + "current_page": 1, + "total_pages": 1, + "has_prev": False, + "has_next": False, + "prev_url": "", + "next_url": "", } if not _is_section_allowed("overrides"): return section @@ -464,8 +556,12 @@ def _build_overrides_results(patterns: list[str]) -> dict: if match_expr is not None: query = query.filter(match_expr) - section["total"] = query.count() - rows = query.order_by(Override.level.asc(), Override.start_at.desc()).limit(SEARCH_LIMIT_PER_SECTION).all() + total, current_page, total_pages, rows = _paginate_query( + query, + page, + [Override.level.asc(), Override.start_at.desc()], + ) + _enrich_paging(section, total, current_page, total_pages) for row in rows: scope_bits = [] if row.customer_name: @@ -492,13 +588,19 @@ def _build_overrides_results(patterns: list[str]) -> dict: return section -def _build_reports_results(patterns: list[str]) -> dict: +def _build_reports_results(patterns: list[str], page: int) -> dict: section = { "key": "reports", "title": "Reports", "view_all_url": url_for("main.reports"), "total": 0, "items": [], + "current_page": 1, + "total_pages": 1, + "has_prev": False, + "has_next": False, + "prev_url": "", + "next_url": "", } if not _is_section_allowed("reports"): return section @@ -517,8 +619,12 @@ def _build_reports_results(patterns: list[str]) -> dict: if match_expr is not None: query = query.filter(match_expr) - section["total"] = query.count() - rows = query.order_by(ReportDefinition.created_at.desc()).limit(SEARCH_LIMIT_PER_SECTION).all() + total, current_page, total_pages, rows = _paginate_query( + query, + page, + [ReportDefinition.created_at.desc()], + ) + _enrich_paging(section, total, current_page, total_pages) can_edit = get_active_role() in ("admin", "operator", "reporter") for r in rows: @@ -540,29 +646,57 @@ def search_page(): query = (request.args.get("q") or "").strip() patterns = _build_patterns(query) + requested_pages = { + key: _parse_page(request.args.get(f"p_{key}")) + for key in SEARCH_SECTION_KEYS + } + sections = [] if patterns: - sections.append(_build_inbox_results(patterns)) - sections.append(_build_customers_results(patterns)) - sections.append(_build_jobs_results(patterns)) - sections.append(_build_daily_jobs_results(patterns)) - sections.append(_build_run_checks_results(patterns)) - sections.append(_build_tickets_results(patterns)) - sections.append(_build_overrides_results(patterns)) - sections.append(_build_reports_results(patterns)) + sections.append(_build_inbox_results(patterns, requested_pages["inbox"])) + sections.append(_build_customers_results(patterns, requested_pages["customers"])) + sections.append(_build_jobs_results(patterns, requested_pages["jobs"])) + sections.append(_build_daily_jobs_results(patterns, requested_pages["daily_jobs"])) + sections.append(_build_run_checks_results(patterns, requested_pages["run_checks"])) + sections.append(_build_tickets_results(patterns, requested_pages["tickets"])) + sections.append(_build_overrides_results(patterns, requested_pages["overrides"])) + sections.append(_build_reports_results(patterns, requested_pages["reports"])) else: sections = [ - {"key": "inbox", "title": "Inbox", "view_all_url": url_for("main.inbox"), "total": 0, "items": []}, - {"key": "customers", "title": "Customers", "view_all_url": url_for("main.customers"), "total": 0, "items": []}, - {"key": "jobs", "title": "Jobs", "view_all_url": url_for("main.jobs"), "total": 0, "items": []}, - {"key": "daily_jobs", "title": "Daily Jobs", "view_all_url": url_for("main.daily_jobs"), "total": 0, "items": []}, - {"key": "run_checks", "title": "Run Checks", "view_all_url": url_for("main.run_checks_page"), "total": 0, "items": []}, - {"key": "tickets", "title": "Tickets", "view_all_url": url_for("main.tickets_page"), "total": 0, "items": []}, - {"key": "overrides", "title": "Existing overrides", "view_all_url": url_for("main.overrides"), "total": 0, "items": []}, - {"key": "reports", "title": "Reports", "view_all_url": url_for("main.reports"), "total": 0, "items": []}, + {"key": "inbox", "title": "Inbox", "view_all_url": url_for("main.inbox"), "total": 0, "items": [], "current_page": 1, "total_pages": 1, "has_prev": False, "has_next": False, "prev_url": "", "next_url": ""}, + {"key": "customers", "title": "Customers", "view_all_url": url_for("main.customers"), "total": 0, "items": [], "current_page": 1, "total_pages": 1, "has_prev": False, "has_next": False, "prev_url": "", "next_url": ""}, + {"key": "jobs", "title": "Jobs", "view_all_url": url_for("main.jobs"), "total": 0, "items": [], "current_page": 1, "total_pages": 1, "has_prev": False, "has_next": False, "prev_url": "", "next_url": ""}, + {"key": "daily_jobs", "title": "Daily Jobs", "view_all_url": url_for("main.daily_jobs"), "total": 0, "items": [], "current_page": 1, "total_pages": 1, "has_prev": False, "has_next": False, "prev_url": "", "next_url": ""}, + {"key": "run_checks", "title": "Run Checks", "view_all_url": url_for("main.run_checks_page"), "total": 0, "items": [], "current_page": 1, "total_pages": 1, "has_prev": False, "has_next": False, "prev_url": "", "next_url": ""}, + {"key": "tickets", "title": "Tickets", "view_all_url": url_for("main.tickets_page"), "total": 0, "items": [], "current_page": 1, "total_pages": 1, "has_prev": False, "has_next": False, "prev_url": "", "next_url": ""}, + {"key": "overrides", "title": "Existing overrides", "view_all_url": url_for("main.overrides"), "total": 0, "items": [], "current_page": 1, "total_pages": 1, "has_prev": False, "has_next": False, "prev_url": "", "next_url": ""}, + {"key": "reports", "title": "Reports", "view_all_url": url_for("main.reports"), "total": 0, "items": [], "current_page": 1, "total_pages": 1, "has_prev": False, "has_next": False, "prev_url": "", "next_url": ""}, ] visible_sections = [s for s in sections if _is_section_allowed(s["key"])] + current_pages = { + s["key"]: int(s.get("current_page", 1) or 1) + for s in sections + } + + def _build_search_url(page_overrides: dict[str, int]) -> str: + args = {"q": query} + for key in SEARCH_SECTION_KEYS: + args[f"p_{key}"] = int(page_overrides.get(key, current_pages.get(key, 1))) + return url_for("main.search_page", **args) + + for s in visible_sections: + key = s["key"] + cur = int(s.get("current_page", 1) or 1) + if s.get("has_prev"): + prev_pages = dict(current_pages) + prev_pages[key] = cur - 1 + s["prev_url"] = _build_search_url(prev_pages) + if s.get("has_next"): + next_pages = dict(current_pages) + next_pages[key] = cur + 1 + s["next_url"] = _build_search_url(next_pages) + total_hits = sum(int(s.get("total", 0) or 0) for s in visible_sections) return render_template( diff --git a/containers/backupchecks/src/templates/main/search.html b/containers/backupchecks/src/templates/main/search.html index 87fda89..f07284e 100644 --- a/containers/backupchecks/src/templates/main/search.html +++ b/containers/backupchecks/src/templates/main/search.html @@ -15,8 +15,8 @@ {% for section in sections %}
- {{ section.title }} ({{ section.total }}) - Open {{ section.title }} + {{ section['title'] }} ({{ section['total'] }}) + Open {{ section['title'] }}
{% if section['items'] %} @@ -50,9 +50,19 @@
No results in this section.
{% endif %}
- {% if section.total > limit_per_section %} - diff --git a/docs/changelog-claude.md b/docs/changelog-claude.md index 8dc5a48..4a9a9bc 100644 --- a/docs/changelog-claude.md +++ b/docs/changelog-claude.md @@ -17,6 +17,7 @@ This file documents all changes made to this project via Claude Code. - Changed search matching to be case-insensitive with wildcard support (`*`) and automatic contains behavior (`*term*`) per search term - Changed global search visibility to only include sections accessible to the currently active role - Changed `docs/technical-notes-codex.md` with a dedicated Global Grouped Search section (route/UI/behavior/access rules) and latest test build digest for `v20260216-02-global-search` +- Changed global search to support per-section pagination (previous/next) so results beyond the first 10 can be browsed per section while preserving the current query/state ### Fixed - Fixed `/search` page crash (`TypeError: 'builtin_function_or_method' object is not iterable`) by replacing Jinja dict access from `section.items` to `section['items']` in `templates/main/search.html`