diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py index 67eb82f..0f36819 100644 --- a/src/govoplan_tenancy/backend/api/v1/routes.py +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -82,6 +82,7 @@ TENANT_NON_STATUS_UPDATE_FIELDS = { "allow_api_keys", } TENANT_GOVERNANCE_OVERRIDE_FIELDS = ("allow_custom_groups", "allow_custom_roles", "allow_api_keys") +TENANT_FULL_CURSOR_PREFIX = "full:tenants:" def _tenant_access_provisioner() -> TenantAccessProvisioner: @@ -141,7 +142,11 @@ def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem: "allow_api_keys": governance.allow_api_keys, }, is_active=tenant.is_active, - counts=tenant_counts(session, tenant.id), + counts=tenant_counts( + session, + tenant.id, + module_ids=("campaigns", "files"), + ), created_at=tenant.created_at, updated_at=tenant.updated_at, ) @@ -317,6 +322,50 @@ def _tenant_list_response_watermark(session: Session, *, entries, has_more: bool return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_list_watermark(session) +def _tenant_page(query, *, page: int, page_size: int): + total = query.order_by(None).count() + pages = max(1, (total + page_size - 1) // page_size) + items = query.offset((page - 1) * page_size).limit(page_size).all() + return items, { + "total": total, + "page": page, + "page_size": page_size, + "pages": pages, + } + + +def _tenant_full_cursor( + *, + page: int, + snapshot_sequence: int, +) -> str: + return f"{TENANT_FULL_CURSOR_PREFIX}{int(page)}:{int(snapshot_sequence)}" + + +def _decode_tenant_full_cursor(value: str | None) -> tuple[int, int] | None: + if not value or not value.startswith(TENANT_FULL_CURSOR_PREFIX): + return None + parts = value[len(TENANT_FULL_CURSOR_PREFIX):].split(":", 1) + if len(parts) != 2: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant full snapshot cursor", + ) + try: + page, snapshot_sequence = (int(item) for item in parts) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant full snapshot cursor", + ) from exc + if page < 1 or snapshot_sequence < 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant full snapshot cursor", + ) + return page, snapshot_sequence + + def _tenant_list_deleted_entries(entries: list[ChangeSequenceEntry], visible_tenant_ids: set[str]): return [ {"id": entry.resource_id, "resource_type": entry.resource_type or TENANT_LIST_RESOURCE} @@ -397,21 +446,54 @@ def switch_tenant_context( @router.get("/tenants", response_model=TenantListResponse) def list_tenants( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=100, ge=1, le=500), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:tenants:read")), ): - tenants = session.query(Tenant).order_by(Tenant.name.asc()).all() - return TenantListResponse(tenants=[_tenant_item(session, tenant) for tenant in tenants]) + tenants, pagination = _tenant_page( + session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()), + page=page, + page_size=page_size, + ) + return TenantListResponse( + tenants=[_tenant_item(session, tenant) for tenant in tenants], + **pagination, + ) -def _full_tenant_list_delta_response(session: Session) -> TenantListDeltaResponse: - tenants = session.query(Tenant).order_by(Tenant.name.asc()).all() +def _full_tenant_list_delta_response( + session: Session, + *, + cursor: tuple[int, int] | None = None, + limit: int = 100, +) -> TenantListDeltaResponse: + page = cursor[0] if cursor is not None else 1 + snapshot_sequence = ( + cursor[1] + if cursor is not None + else decode_sequence_watermark(_tenant_list_watermark(session)) + ) + tenants, pagination = _tenant_page( + session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()), + page=page, + page_size=limit, + ) + has_more = page < pagination["pages"] return TenantListDeltaResponse( tenants=[_tenant_item(session, tenant) for tenant in tenants], deleted=[], - watermark=_tenant_list_watermark(session), - has_more=False, + watermark=( + _tenant_full_cursor( + page=page + 1, + snapshot_sequence=snapshot_sequence, + ) + if has_more + else encode_sequence_watermark(snapshot_sequence) + ), + has_more=has_more, full=True, + **pagination, ) @@ -423,11 +505,16 @@ def list_tenants_delta( principal: ApiPrincipal = Depends(require_scope("system:tenants:read")), ): del principal - if since is None: - return _full_tenant_list_delta_response(session) + full_cursor = _decode_tenant_full_cursor(since) + if since is None or full_cursor is not None: + return _full_tenant_list_delta_response( + session, + cursor=full_cursor, + limit=limit, + ) entries, has_more = _tenant_list_delta_entries(session, since=since, limit=limit) if entries is None: - return _full_tenant_list_delta_response(session) + return _full_tenant_list_delta_response(session, limit=limit) changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"] tenants = [] if changed_ids: @@ -439,6 +526,10 @@ def list_tenants_delta( watermark=_tenant_list_response_watermark(session, entries=entries, has_more=has_more), has_more=has_more, full=False, + total=len(tenants), + page=1, + page_size=limit, + pages=1, ) diff --git a/src/govoplan_tenancy/backend/api/v1/schemas.py b/src/govoplan_tenancy/backend/api/v1/schemas.py index 0c635b9..7778134 100644 --- a/src/govoplan_tenancy/backend/api/v1/schemas.py +++ b/src/govoplan_tenancy/backend/api/v1/schemas.py @@ -27,9 +27,13 @@ class TenantAdminItem(BaseModel): class TenantListResponse(BaseModel): tenants: list[TenantAdminItem] + total: int = 0 + page: int = 1 + page_size: int = 100 + pages: int = 1 -class TenantListDeltaResponse(BaseModel): +class TenantListDeltaResponse(TenantListResponse): tenants: list[TenantAdminItem] = Field(default_factory=list) deleted: list[DeltaDeletedItem] = Field(default_factory=list) watermark: str | None = None