Skip to content

feat: add pagination to change requests list with envelope response#5733

Merged
rajohnson90 merged 12 commits into
mainfrom
OPS-5725/change-request-paging
May 29, 2026
Merged

feat: add pagination to change requests list with envelope response#5733
rajohnson90 merged 12 commits into
mainfrom
OPS-5725/change-request-paging

Conversation

@rajohnson90
Copy link
Copy Markdown
Contributor

@rajohnson90 rajohnson90 commented May 27, 2026

What changed

The /change-requests/ GET endpoint now returns a paginated envelope {data, count, limit, offset} instead of a plain array. count reflects the total number of change requests after permission filtering but before the pagination slice, so a division director always sees their true total even when results span multiple pages.

backend/ops_api/ops/utils/change_requests_helpers.py

  • find_in_review_requests_by_user now returns a (page, total_count) tuple. Pagination is applied in Python after permission filtering (not at SQL level) so no reviewable CRs are silently dropped. N+1 get_division_directors_for_agreement calls replaced by a single SQL join via get_reviewable_agreement_ids_for_user.

backend/ops_api/ops/services/change_requests.py

  • get_list unpacks the tuple and forwards count/limit/offset in metadata.

backend/ops_api/ops/resources/change_requests.py

  • Returns {data, count, limit, offset} envelope. limit and offset are now read from query params and forwarded through the service.

backend/openapi.yml

  • /change-requests/ 200 response updated from array to the paginated envelope schema.

frontend/src/api/opsAPI.js

  • getChangeRequestsList query now accepts and forwards limit and offset query params.

frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestsList.jsx

  • Adds currentPage state and PaginationNav (page size 10, server-side). Reads changeRequestsResponse.data from envelope; derives totalPages from count.

frontend/src/hooks/useChangeRequests.hooks.js

  • useChangeRequestTotal passes limit: 10, offset: 0 to share the RTK Query cache with ChangeRequestsList, and reads count from the envelope instead of data.length.

Tests updated: ChangeRequestList.test.jsx, AgreementsList.test.jsx, useChangeRequests.hooks.test.js, test_change_requests_helpers.py, test_change_requests.py (workflow).

frontend/cypress/e2e/changeRequestsPagination.cy.js

  • New E2E test: budget-team creates an agreement with 11 DRAFT BLIs and patches each to PLANNED (creating 11 status-change CRs). Division director logs in, verifies page 1 returns 10 items, clicks to page 2 and verifies 1 item.

Issue

#5725

How to test

Backend unit/integration tests:

cd backend/ops_api
pipenv run pytest tests/ops/utils/test_change_requests_helpers.py tests/ops/workflows/test_change_requests.py -x -q

Frontend unit tests:

cd frontend
npx vitest run src/components/ChangeRequests/ChangeRequestsList/ChangeRequestList.test.jsx src/hooks/useChangeRequests.hooks.test.js src/pages/agreements/list/AgreementsList.test.jsx

Cypress E2E test:

cd frontend
npx cypress run --spec "cypress/e2e/changeRequestsPagination.cy.js"

Manual verification:

  1. Log in as Budget Team Member via FakeAuth.
  2. Create a contract agreement (project 1000, awarding entity 2, CAN 501) with 11 budget lines in DRAFT status.
  3. PATCH each BLI to PLANNED to create 11 status-change change requests.
  4. Log out and log in as Division Director (Dave Director, division 4).
  5. Navigate to /agreements?filter=change-requests ("For Review" tab).
  6. Verify 10 change request cards are shown and a pagination nav appears.
  7. Click "Page 2" — verify 1 change request card is shown.

A11y impact

  • No accessibility-impacting changes in this PR
  • Accessibility changes included and validated against WCAG 2.1 AA intent
  • Any temporary suppression includes A11Y-SUPPRESSION metadata (owner, expires, rationale)

Screenshots

Page 1
Screenshot 2026-05-27 at 3 51 36 PM
Screenshot 2026-05-27 at 3 51 43 PM

Page 2
Screenshot 2026-05-27 at 4 02 34 PM

Definition of Done Checklist

  • OESA: Code refactored for clarity
  • OESA: Dependency rules followed
  • Automated unit tests updated and passed
  • Automated integration tests updated and passed
  • Automated quality tests updated and passed
  • Automated load tests updated and passed
  • Automated a11y tests updated and passed
  • Automated security tests updated and passed
  • 90%+ Code coverage achieved
  • Form validations updated

Links

N/A

Copy link
Copy Markdown
Contributor

@josbell josbell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Decision: REQUEST CHANGES

After thorough investigation, I've identified two blocking issues that must be addressed before approval. While the overall approach is solid and the N+1 query fix is excellent work, these issues will cause problems at scale.


🚨 BLOCKING ISSUE #1: Critical Performance Problem

Location: backend/ops_api/ops/utils/change_requests_helpers.py:141-159

Problem: The function loads ALL in-review change requests into memory before filtering and pagination:

stmt = select(cr_poly).where(cr_poly.status == ChangeRequestStatus.IN_REVIEW).order_by(ChangeRequest.id)
results = current_app.db_session.execute(stmt).scalars().all()  # ← Loads everything
# ... filter in Python ...
return filtered_results[offset : offset + limit], total_count

Impact:

  • With 1,000 in-review change requests, every pagination request (page 1, page 2, etc.) loads all 1,000 records into memory
  • Polymorphic loading eagerly loads related Agreement objects
  • Python-side filtering and pagination defeats the entire purpose of pagination
  • This creates a memory bomb at scale

Evidence: The old version had .limit(limit).offset(offset) on the SQL query - this was removed in favor of Python list slicing at line 159.

Recommendation: Move pagination to SQL level after permission filtering. Two approaches:

Option A (Preferred): Push permission filtering into SQL:

# Build SQL filters for reviewable CRs
bli_filter = and_(
    cr_poly.type == 'budget_line_item_change_request',
    BudgetLineItemChangeRequest.managing_division_id.in_(reviewable_division_ids)
)
agr_filter = and_(
    cr_poly.type == 'agreement_change_request',
    AgreementChangeRequest.agreement_id.in_(reviewable_agreement_ids)
)

stmt = (
    select(cr_poly)
    .where(cr_poly.status == ChangeRequestStatus.IN_REVIEW)
    .where(or_(bli_filter, agr_filter))
    .order_by(ChangeRequest.id)
)

# Count and paginate at SQL level
count_stmt = select(func.count()).select_from(stmt.subquery())
total_count = current_app.db_session.execute(count_stmt).scalar()
results = current_app.db_session.execute(stmt.limit(limit).offset(offset)).scalars().all()
return results, total_count

Option B (Acceptable for MVP): Add safeguards and document the limitation:

if len(results) > 1000:  # Configurable threshold
    current_app.logger.warning(f"Large change request result set: {len(results)} items")
# Proceed with Python filtering

🚨 BLOCKING ISSUE #2: API Contract Violation

Location: backend/ops_api/ops/services/change_requests.py:79-80

Problem: When query params are missing, the response violates the OpenAPI schema:

page, total_count = find_in_review_requests_by_user(
    int(reviewer_user_id),
    data.get("limit"),      # ← Returns None if missing
    data.get("offset"),     # ← Returns None if missing
)

return page, {"count": total_count, "limit": data.get("limit"), "offset": data.get("offset")}

Impact:

  • OpenAPI schema declares limit and offset as required integers (lines 27-31 in openapi.yml)
  • Response can contain {"data": [...], "count": 10, "limit": null, "offset": null}
  • This violates the API contract and may break clients that rely on schema validation

Note: While the function signature has defaults (limit: int = 10, offset: int = 0), those only protect the pagination logic - the metadata dictionary still contains None values.

Recommendation: Add explicit defaults in the service layer:

page, total_count = find_in_review_requests_by_user(
    int(reviewer_user_id),
    data.get("limit", 10),      # ← Explicit default
    data.get("offset", 0),      # ← Explicit default
)

return page, {
    "count": total_count,
    "limit": data.get("limit", 10),
    "offset": data.get("offset", 0)
}

⚠️ Non-Blocking: API Breaking Change

Location: backend/openapi.yml and backend/ops_api/ops/resources/change_requests.py

Observation: The response format changes from a plain array to an envelope object:

  • Before: [{...}, {...}]
  • After: {"data": [...], "count": 10, "limit": 10, "offset": 0}

This is a breaking change for any API consumers. Existing code expecting response[0] will break (should be response.data[0]).

Impact Assessment:

  • ✅ Frontend is updated in the same PR, so internal consumers are safe
  • ⚠️ External consumers (if any) will break
  • ⚠️ Requires atomic deployment (backend + frontend together)

Recommendation: If this is internal-only API, acceptable with atomic deployment. Otherwise, consider API versioning or a transition period.


📝 Minor Observations

Count Edge Case (Standard Behavior)

If offset >= total_count, the response returns empty data: [] but count still shows the total. This is correct per REST pagination conventions (e.g., GitHub API, Stripe API) - count should remain stable across pages.

Database Index

The query filters by change_request.status - verify that an index exists on this column to avoid table scans at scale.


✅ What's Good

  1. Excellent N+1 Fix: The new get_reviewable_agreement_ids_for_user function eliminates N+1 queries with a single upfront JOIN - textbook SQL optimization
  2. Clean Envelope Pattern: The {data, count, limit, offset} structure follows REST pagination best practices
  3. Comprehensive Test Coverage: Tests updated correctly, including new E2E test for pagination
  4. Deterministic Ordering: Adding .order_by(ChangeRequest.id) ensures consistent pagination
  5. Well-Documented OpenAPI Schema: Clear descriptions for each envelope field

Summary

Please address the two blocking issues:

  1. Critical: Fix the load-all-then-paginate pattern (performance/memory issue)
  2. High: Add explicit defaults to prevent null in required integer fields (API contract violation)

Once these are resolved, I'll be happy to re-review. The core architecture is sound - these are implementation details that need refinement for production readiness.

@rajohnson90
Copy link
Copy Markdown
Contributor Author

I've moved the filter to be in the SQL query. I provided default values for the limit and offset in the service. I verified there is already an existing index on status so no need to worry on that.

@jonnalley jonnalley requested a review from josbell May 29, 2026 05:16
Copy link
Copy Markdown
Contributor

@jonnalley jonnalley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed and looks pretty decent to me. I also had a couple different sets of sub-agents review under separate contexts and using different methodologies and only found nit-pick and pre-existing issues. LGTM, thank you!

@rajohnson90 rajohnson90 merged commit 30ce583 into main May 29, 2026
67 checks passed
@rajohnson90 rajohnson90 deleted the OPS-5725/change-request-paging branch May 29, 2026 17:21
@github-actions
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.405.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants