Add per-section pagination to global search
This commit is contained in:
parent
8c29f527c6
commit
8a8f957c9f
@ -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(
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
{% for section in sections %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>{{ section.title }} ({{ section.total }})</span>
|
||||
<a href="{{ section.view_all_url }}" class="btn btn-sm btn-outline-secondary">Open {{ section.title }}</a>
|
||||
<span>{{ section['title'] }} ({{ section['total'] }})</span>
|
||||
<a href="{{ section['view_all_url'] }}" class="btn btn-sm btn-outline-secondary">Open {{ section['title'] }}</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if section['items'] %}
|
||||
@ -50,9 +50,19 @@
|
||||
<div class="p-3 text-muted">No results in this section.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if section.total > limit_per_section %}
|
||||
<div class="card-footer small text-muted">
|
||||
Showing first {{ limit_per_section }} of {{ section.total }} results.
|
||||
{% if section['total_pages'] > 1 %}
|
||||
<div class="card-footer d-flex justify-content-between align-items-center small">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -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`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user