From fc0246b0f02ce07b9e8de751b1a86183bed3c8a0 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 29 Jul 2026 14:16:29 +0200 Subject: [PATCH] perf: bound and batch visible poll listings --- src/govoplan_poll/backend/router.py | 2 + src/govoplan_poll/backend/service.py | 66 ++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/govoplan_poll/backend/router.py b/src/govoplan_poll/backend/router.py index e827e75..f0cf95e 100644 --- a/src/govoplan_poll/backend/router.py +++ b/src/govoplan_poll/backend/router.py @@ -176,6 +176,7 @@ def _require_sensitive_poll_data_scope(principal: ApiPrincipal) -> None: def api_list_polls( status_filter: str | None = Query(default=None, alias="status"), kind: str | None = None, + limit: int = 100, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PollListResponse: @@ -187,6 +188,7 @@ def api_list_polls( can_manage=_can_manage_polls(principal), status=status_filter, kind=kind, + limit=limit, ) return PollListResponse(polls=[_poll_response(poll) for poll in polls]) diff --git a/src/govoplan_poll/backend/service.py b/src/govoplan_poll/backend/service.py index 8e2bc5e..88e1a2e 100644 --- a/src/govoplan_poll/backend/service.py +++ b/src/govoplan_poll/backend/service.py @@ -10,7 +10,7 @@ from typing import Any, Callable from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from govoplan_core.db.base import utcnow from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse @@ -366,13 +366,28 @@ def create_poll( return poll -def list_polls(session: Session, *, tenant_id: str, status: str | None = None, kind: str | None = None) -> list[Poll]: - query = session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None)) +def list_polls( + session: Session, + *, + tenant_id: str, + status: str | None = None, + kind: str | None = None, + limit: int = 100, +) -> list[Poll]: + query = ( + session.query(Poll) + .options(selectinload(Poll.options)) + .filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None)) + ) if status: query = query.filter(Poll.status == status) if kind: query = query.filter(Poll.kind == kind) - return query.order_by(Poll.created_at.desc(), Poll.title.asc()).all() + return ( + query.order_by(Poll.created_at.desc(), Poll.title.asc()) + .limit(max(1, min(limit, 200))) + .all() + ) def get_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll: @@ -432,12 +447,45 @@ def list_visible_polls( can_manage: bool = False, status: str | None = None, kind: str | None = None, + limit: int = 100, ) -> list[Poll]: - return [ - poll - for poll in list_polls(session, tenant_id=tenant_id, status=status, kind=kind) - if poll_is_visible(session, poll=poll, actor_ids=actor_ids, can_manage=can_manage) - ] + query = ( + session.query(Poll) + .options(selectinload(Poll.options)) + .filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None)) + ) + if status: + query = query.filter(Poll.status == status) + if kind: + query = query.filter(Poll.kind == kind) + if not can_manage: + ids = _actor_ids(actor_ids) + invitation_exists = ( + session.query(PollInvitation.id) + .filter( + PollInvitation.tenant_id == tenant_id, + PollInvitation.poll_id == Poll.id, + PollInvitation.respondent_id.in_(ids or ("",)), + PollInvitation.revoked_at.is_(None), + or_( + PollInvitation.expires_at.is_(None), + PollInvitation.expires_at > _now(), + ), + ) + .exists() + ) + query = query.filter( + or_( + Poll.created_by_user_id.in_(ids or ("",)), + Poll.visibility.in_(("tenant", "public")), + invitation_exists, + ) + ) + return ( + query.order_by(Poll.created_at.desc(), Poll.title.asc()) + .limit(max(1, min(limit, 200))) + .all() + ) def get_visible_poll(