Add per-section pagination to global search

This commit is contained in:
Ivo Oskamp 2026-02-16 16:19:26 +01:00
parent 8c29f527c6
commit 8a8f957c9f
3 changed files with 205 additions and 60 deletions

View File

@ -2,9 +2,20 @@ from .routes_shared import * # noqa: F401,F403
from .routes_shared import _format_datetime from .routes_shared import _format_datetime
from sqlalchemy import and_, cast, func, or_, String from sqlalchemy import and_, cast, func, or_, String
import math
SEARCH_LIMIT_PER_SECTION = 10 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: def _is_section_allowed(section: str) -> bool:
@ -47,13 +58,50 @@ def _contains_all_terms(columns: list, patterns: list[str]):
return and_(*term_filters) 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 = { section = {
"key": "inbox", "key": "inbox",
"title": "Inbox", "title": "Inbox",
"view_all_url": url_for("main.inbox"), "view_all_url": url_for("main.inbox"),
"total": 0, "total": 0,
"items": [], "items": [],
"current_page": 1,
"total_pages": 1,
"has_prev": False,
"has_next": False,
"prev_url": "",
"next_url": "",
} }
if not _is_section_allowed("inbox"): if not _is_section_allowed("inbox"):
return section return section
@ -78,12 +126,12 @@ def _build_inbox_results(patterns: list[str]) -> dict:
if match_expr is not None: if match_expr is not None:
query = query.filter(match_expr) query = query.filter(match_expr)
section["total"] = query.count() total, current_page, total_pages, rows = _paginate_query(
rows = ( query,
query.order_by(MailMessage.received_at.desc().nullslast(), MailMessage.id.desc()) page,
.limit(SEARCH_LIMIT_PER_SECTION) [MailMessage.received_at.desc().nullslast(), MailMessage.id.desc()],
.all()
) )
_enrich_paging(section, total, current_page, total_pages)
for msg in rows: for msg in rows:
parsed_flag = bool(getattr(msg, "parsed_at", None) or (msg.parse_result or "")) 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 return section
def _build_customers_results(patterns: list[str]) -> dict: def _build_customers_results(patterns: list[str], page: int) -> dict:
section = { section = {
"key": "customers", "key": "customers",
"title": "Customers", "title": "Customers",
"view_all_url": url_for("main.customers"), "view_all_url": url_for("main.customers"),
"total": 0, "total": 0,
"items": [], "items": [],
"current_page": 1,
"total_pages": 1,
"has_prev": False,
"has_next": False,
"prev_url": "",
"next_url": "",
} }
if not _is_section_allowed("customers"): if not _is_section_allowed("customers"):
return section return section
@ -115,8 +169,12 @@ def _build_customers_results(patterns: list[str]) -> dict:
if match_expr is not None: if match_expr is not None:
query = query.filter(match_expr) query = query.filter(match_expr)
section["total"] = query.count() total, current_page, total_pages, rows = _paginate_query(
rows = query.order_by(Customer.name.asc()).limit(SEARCH_LIMIT_PER_SECTION).all() query,
page,
[Customer.name.asc()],
)
_enrich_paging(section, total, current_page, total_pages)
for c in rows: for c in rows:
try: try:
job_count = c.jobs.count() job_count = c.jobs.count()
@ -134,13 +192,19 @@ def _build_customers_results(patterns: list[str]) -> dict:
return section return section
def _build_jobs_results(patterns: list[str]) -> dict: def _build_jobs_results(patterns: list[str], page: int) -> dict:
section = { section = {
"key": "jobs", "key": "jobs",
"title": "Jobs", "title": "Jobs",
"view_all_url": url_for("main.jobs"), "view_all_url": url_for("main.jobs"),
"total": 0, "total": 0,
"items": [], "items": [],
"current_page": 1,
"total_pages": 1,
"has_prev": False,
"has_next": False,
"prev_url": "",
"next_url": "",
} }
if not _is_section_allowed("jobs"): if not _is_section_allowed("jobs"):
return section return section
@ -171,17 +235,17 @@ def _build_jobs_results(patterns: list[str]) -> dict:
if match_expr is not None: if match_expr is not None:
query = query.filter(match_expr) query = query.filter(match_expr)
section["total"] = query.count() total, current_page, total_pages, rows = _paginate_query(
rows = ( query,
query.order_by( page,
[
Customer.name.asc().nullslast(), Customer.name.asc().nullslast(),
Job.backup_software.asc(), Job.backup_software.asc(),
Job.backup_type.asc(), Job.backup_type.asc(),
Job.job_name.asc(), Job.job_name.asc(),
) ],
.limit(SEARCH_LIMIT_PER_SECTION)
.all()
) )
_enrich_paging(section, total, current_page, total_pages)
for row in rows: for row in rows:
section["items"].append( section["items"].append(
{ {
@ -195,13 +259,19 @@ def _build_jobs_results(patterns: list[str]) -> dict:
return section return section
def _build_daily_jobs_results(patterns: list[str]) -> dict: def _build_daily_jobs_results(patterns: list[str], page: int) -> dict:
section = { section = {
"key": "daily_jobs", "key": "daily_jobs",
"title": "Daily Jobs", "title": "Daily Jobs",
"view_all_url": url_for("main.daily_jobs"), "view_all_url": url_for("main.daily_jobs"),
"total": 0, "total": 0,
"items": [], "items": [],
"current_page": 1,
"total_pages": 1,
"has_prev": False,
"has_next": False,
"prev_url": "",
"next_url": "",
} }
if not _is_section_allowed("daily_jobs"): if not _is_section_allowed("daily_jobs"):
return section return section
@ -232,17 +302,17 @@ def _build_daily_jobs_results(patterns: list[str]) -> dict:
if match_expr is not None: if match_expr is not None:
query = query.filter(match_expr) query = query.filter(match_expr)
section["total"] = query.count() total, current_page, total_pages, rows = _paginate_query(
rows = ( query,
query.order_by( page,
[
Customer.name.asc().nullslast(), Customer.name.asc().nullslast(),
Job.backup_software.asc(), Job.backup_software.asc(),
Job.backup_type.asc(), Job.backup_type.asc(),
Job.job_name.asc(), Job.job_name.asc(),
) ],
.limit(SEARCH_LIMIT_PER_SECTION)
.all()
) )
_enrich_paging(section, total, current_page, total_pages)
for row in rows: for row in rows:
section["items"].append( section["items"].append(
{ {
@ -256,13 +326,19 @@ def _build_daily_jobs_results(patterns: list[str]) -> dict:
return section return section
def _build_run_checks_results(patterns: list[str]) -> dict: def _build_run_checks_results(patterns: list[str], page: int) -> dict:
section = { section = {
"key": "run_checks", "key": "run_checks",
"title": "Run Checks", "title": "Run Checks",
"view_all_url": url_for("main.run_checks_page"), "view_all_url": url_for("main.run_checks_page"),
"total": 0, "total": 0,
"items": [], "items": [],
"current_page": 1,
"total_pages": 1,
"has_prev": False,
"has_next": False,
"prev_url": "",
"next_url": "",
} }
if not _is_section_allowed("run_checks"): if not _is_section_allowed("run_checks"):
return section return section
@ -304,17 +380,17 @@ def _build_run_checks_results(patterns: list[str]) -> dict:
if match_expr is not None: if match_expr is not None:
query = query.filter(match_expr) query = query.filter(match_expr)
section["total"] = query.count() total, current_page, total_pages, rows = _paginate_query(
rows = ( query,
query.order_by( page,
[
Customer.name.asc().nullslast(), Customer.name.asc().nullslast(),
Job.backup_software.asc().nullslast(), Job.backup_software.asc().nullslast(),
Job.backup_type.asc().nullslast(), Job.backup_type.asc().nullslast(),
Job.job_name.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: for row in rows:
section["items"].append( section["items"].append(
{ {
@ -328,13 +404,19 @@ def _build_run_checks_results(patterns: list[str]) -> dict:
return section return section
def _build_tickets_results(patterns: list[str]) -> dict: def _build_tickets_results(patterns: list[str], page: int) -> dict:
section = { section = {
"key": "tickets", "key": "tickets",
"title": "Tickets", "title": "Tickets",
"view_all_url": url_for("main.tickets_page"), "view_all_url": url_for("main.tickets_page"),
"total": 0, "total": 0,
"items": [], "items": [],
"current_page": 1,
"total_pages": 1,
"has_prev": False,
"has_next": False,
"prev_url": "",
"next_url": "",
} }
if not _is_section_allowed("tickets"): if not _is_section_allowed("tickets"):
return section return section
@ -363,8 +445,12 @@ def _build_tickets_results(patterns: list[str]) -> dict:
query = query.filter(match_expr) query = query.filter(match_expr)
query = query.distinct() query = query.distinct()
section["total"] = query.count() total, current_page, total_pages, rows = _paginate_query(
rows = query.order_by(Ticket.start_date.desc().nullslast()).limit(SEARCH_LIMIT_PER_SECTION).all() query,
page,
[Ticket.start_date.desc().nullslast()],
)
_enrich_paging(section, total, current_page, total_pages)
for t in rows: for t in rows:
customer_display = "-" customer_display = "-"
@ -418,13 +504,19 @@ def _build_tickets_results(patterns: list[str]) -> dict:
return section return section
def _build_overrides_results(patterns: list[str]) -> dict: def _build_overrides_results(patterns: list[str], page: int) -> dict:
section = { section = {
"key": "overrides", "key": "overrides",
"title": "Existing overrides", "title": "Existing overrides",
"view_all_url": url_for("main.overrides"), "view_all_url": url_for("main.overrides"),
"total": 0, "total": 0,
"items": [], "items": [],
"current_page": 1,
"total_pages": 1,
"has_prev": False,
"has_next": False,
"prev_url": "",
"next_url": "",
} }
if not _is_section_allowed("overrides"): if not _is_section_allowed("overrides"):
return section return section
@ -464,8 +556,12 @@ def _build_overrides_results(patterns: list[str]) -> dict:
if match_expr is not None: if match_expr is not None:
query = query.filter(match_expr) query = query.filter(match_expr)
section["total"] = query.count() total, current_page, total_pages, rows = _paginate_query(
rows = query.order_by(Override.level.asc(), Override.start_at.desc()).limit(SEARCH_LIMIT_PER_SECTION).all() query,
page,
[Override.level.asc(), Override.start_at.desc()],
)
_enrich_paging(section, total, current_page, total_pages)
for row in rows: for row in rows:
scope_bits = [] scope_bits = []
if row.customer_name: if row.customer_name:
@ -492,13 +588,19 @@ def _build_overrides_results(patterns: list[str]) -> dict:
return section return section
def _build_reports_results(patterns: list[str]) -> dict: def _build_reports_results(patterns: list[str], page: int) -> dict:
section = { section = {
"key": "reports", "key": "reports",
"title": "Reports", "title": "Reports",
"view_all_url": url_for("main.reports"), "view_all_url": url_for("main.reports"),
"total": 0, "total": 0,
"items": [], "items": [],
"current_page": 1,
"total_pages": 1,
"has_prev": False,
"has_next": False,
"prev_url": "",
"next_url": "",
} }
if not _is_section_allowed("reports"): if not _is_section_allowed("reports"):
return section return section
@ -517,8 +619,12 @@ def _build_reports_results(patterns: list[str]) -> dict:
if match_expr is not None: if match_expr is not None:
query = query.filter(match_expr) query = query.filter(match_expr)
section["total"] = query.count() total, current_page, total_pages, rows = _paginate_query(
rows = query.order_by(ReportDefinition.created_at.desc()).limit(SEARCH_LIMIT_PER_SECTION).all() query,
page,
[ReportDefinition.created_at.desc()],
)
_enrich_paging(section, total, current_page, total_pages)
can_edit = get_active_role() in ("admin", "operator", "reporter") can_edit = get_active_role() in ("admin", "operator", "reporter")
for r in rows: for r in rows:
@ -540,29 +646,57 @@ def search_page():
query = (request.args.get("q") or "").strip() query = (request.args.get("q") or "").strip()
patterns = _build_patterns(query) patterns = _build_patterns(query)
requested_pages = {
key: _parse_page(request.args.get(f"p_{key}"))
for key in SEARCH_SECTION_KEYS
}
sections = [] sections = []
if patterns: if patterns:
sections.append(_build_inbox_results(patterns)) sections.append(_build_inbox_results(patterns, requested_pages["inbox"]))
sections.append(_build_customers_results(patterns)) sections.append(_build_customers_results(patterns, requested_pages["customers"]))
sections.append(_build_jobs_results(patterns)) sections.append(_build_jobs_results(patterns, requested_pages["jobs"]))
sections.append(_build_daily_jobs_results(patterns)) sections.append(_build_daily_jobs_results(patterns, requested_pages["daily_jobs"]))
sections.append(_build_run_checks_results(patterns)) sections.append(_build_run_checks_results(patterns, requested_pages["run_checks"]))
sections.append(_build_tickets_results(patterns)) sections.append(_build_tickets_results(patterns, requested_pages["tickets"]))
sections.append(_build_overrides_results(patterns)) sections.append(_build_overrides_results(patterns, requested_pages["overrides"]))
sections.append(_build_reports_results(patterns)) sections.append(_build_reports_results(patterns, requested_pages["reports"]))
else: else:
sections = [ sections = [
{"key": "inbox", "title": "Inbox", "view_all_url": url_for("main.inbox"), "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": []}, {"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": []}, {"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": []}, {"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": []}, {"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": []}, {"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": []}, {"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": []}, {"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"])] 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) total_hits = sum(int(s.get("total", 0) or 0) for s in visible_sections)
return render_template( return render_template(

View File

@ -15,8 +15,8 @@
{% for section in sections %} {% for section in sections %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<span>{{ section.title }} ({{ section.total }})</span> <span>{{ section['title'] }} ({{ section['total'] }})</span>
<a href="{{ section.view_all_url }}" class="btn btn-sm btn-outline-secondary">Open {{ section.title }}</a> <a href="{{ section['view_all_url'] }}" class="btn btn-sm btn-outline-secondary">Open {{ section['title'] }}</a>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
{% if section['items'] %} {% if section['items'] %}
@ -50,9 +50,19 @@
<div class="p-3 text-muted">No results in this section.</div> <div class="p-3 text-muted">No results in this section.</div>
{% endif %} {% endif %}
</div> </div>
{% if section.total > limit_per_section %} {% if section['total_pages'] > 1 %}
<div class="card-footer small text-muted"> <div class="card-footer d-flex justify-content-between align-items-center small">
Showing first {{ limit_per_section }} of {{ section.total }} results. <span class="text-muted">
Page {{ section['current_page'] }} of {{ section['total_pages'] }} ({{ section['total'] }} results)
</span>
<div class="d-flex gap-2">
{% if section['has_prev'] %}
<a class="btn btn-sm btn-outline-secondary" href="{{ section['prev_url'] }}">Previous</a>
{% endif %}
{% if section['has_next'] %}
<a class="btn btn-sm btn-outline-secondary" href="{{ section['next_url'] }}">Next</a>
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

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