From 46df12c025dc3e9c9fd813e2e578be62b2fe07f7 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 30 Jul 2026 01:15:38 +0200 Subject: [PATCH] perf(campaign): batch tenant summaries --- src/govoplan_campaign/backend/manifest.py | 21 +++++++ tests/test_tenant_summary_batch.py | 77 +++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/test_tenant_summary_batch.py diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 5061d2b..5c3bcba 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -161,6 +161,26 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]: return {"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count()} +def _tenant_summary_batch(session, tenant_ids) -> dict[str, dict[str, int]]: + from sqlalchemy import func + + from govoplan_campaign.backend.db.models import Campaign + + ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id)) + if not ids: + return {} + rows = ( + session.query(Campaign.tenant_id, func.count(Campaign.id)) + .filter(Campaign.tenant_id.in_(ids)) + .group_by(Campaign.tenant_id) + .all() + ) + return { + tenant_id: {"campaigns": int(count)} + for tenant_id, count in rows + } + + def _campaigns_router(context: ModuleContext): from govoplan_campaign.backend.runtime import configure_runtime @@ -236,6 +256,7 @@ manifest = ModuleManifest( route_factory=_campaigns_router, role_templates=ROLE_TEMPLATES, tenant_summary_providers=(_tenant_summary,), + tenant_summary_batch_providers=(_tenant_summary_batch,), nav_items=( NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20), ), diff --git a/tests/test_tenant_summary_batch.py b/tests/test_tenant_summary_batch.py new file mode 100644 index 0000000..405ba32 --- /dev/null +++ b/tests/test_tenant_summary_batch.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine, event +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import Account, Group, User +from govoplan_campaign.backend.db.models import Campaign +from govoplan_campaign.backend.manifest import _tenant_summary_batch +from govoplan_core.core.change_sequence import ChangeSequenceEntry +from govoplan_core.db.base import Base + + +class CampaignTenantSummaryBatchTests(unittest.TestCase): + def test_batch_summary_groups_all_tenants_in_one_query(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all( + engine, + tables=[ + Account.__table__, + User.__table__, + Group.__table__, + Campaign.__table__, + ChangeSequenceEntry.__table__, + ], + ) + try: + with Session(engine) as session: + session.add_all( + [ + Campaign( + id="campaign-1", + tenant_id="tenant-1", + external_id="one", + name="One", + ), + Campaign( + id="campaign-2", + tenant_id="tenant-1", + external_id="two", + name="Two", + ), + Campaign( + id="campaign-3", + tenant_id="tenant-2", + external_id="three", + name="Three", + ), + ] + ) + session.commit() + query_count = 0 + + def count_query(*_args: object) -> None: + nonlocal query_count + query_count += 1 + + event.listen(engine, "before_cursor_execute", count_query) + try: + counts = _tenant_summary_batch( + session, + ["tenant-1", "tenant-2", "tenant-empty"], + ) + finally: + event.remove(engine, "before_cursor_execute", count_query) + + self.assertEqual(1, query_count) + self.assertEqual({"campaigns": 2}, counts["tenant-1"]) + self.assertEqual({"campaigns": 1}, counts["tenant-2"]) + self.assertNotIn("tenant-empty", counts) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main()