Nobody has started this yet — be first.
Business impact
GET /api/contacts currently returns every contact in one response, always. That's fine for a demo with a handful of rows, but for any real account with hundreds or thousands of contacts, every list request pulls the entire table and serializes all of it -- slow responses, wasted bandwidth, and a client (mobile app, web UI) that has to hold the whole dataset in memory just to show the first screen. This is the kind of gap that's invisible in a small dev database and becomes a P1 "the contacts page is timing out" incident the day a customer imports a few thousand contacts.
Problem
list_contacts() in app/blueprints/contacts/routes.py already reads ?page= and ?per_page= from the query string and calls paginate(query, page, per_page) (imported from the new app/pagination.py module) -- but paginate() is currently just a stub that raises NotImplementedError. Calling GET /api/contacts without page/per_page is unaffected; calling it with page/per_page currently 500s.
Current behavior
GET /api/contacts?page=1&per_page=2 returns 500 Internal Server Error instead of a paginated page of results.
Expected behavior
GET /api/contacts?page=<n>&per_page=<m> (both 1-indexed, per_page is items per page) should return {"items": [...], "page": 1, "per_page": 2, "total": 5}, where total is the total count of contacts matching any search/group filters also present on the request (not just the count on this page), and items has at most per_page entries.
Steps to reproduce
for i in 1 2 3 4 5; do curl -s -X POST http://127.0.0.1:5000/api/contacts -H 'Content-Type: application/json' -d "{"name":"Person $i"}" > /dev/null done curl -s "http://127.0.0.1:5000/api/contacts?page=1&per_page=2"
Why this matters
Pagination has to interact correctly with the already-filtered query -- list_contacts() builds up query with optional search/group filters before your code runs, so paginate() needs to slice that query (ordering already applied), not re-query contacts from scratch. Getting total right also means counting the filtered result set, not just len(items) on the current page -- a common pagination mistake.
Suggested approach
Look at app/pagination.py::paginate(query, page, per_page) and how it's invoked from list_contacts(). SQLAlchemy's Query object supports .count(), .limit(), and .offset() -- think about what offset corresponds to page page with per_page items per page (page 1 should NOT skip any rows).
Acceptance criteria
Verification
SECRET_KEY=dev-secret pytest practice_tickets/tests/test_ticket_05_pagination.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
$git checkout -b feat/contacts-list-paginationFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/contacts-list-paginationSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inBy default DevelopmentConfig uses a local SQLite file, no Postgres server required -- point DATABASE_URL at a real Postgres instance instead if you want one, or use Docker Compose instead (cp .env.example .env then docker compose up --build, which starts a healthchecked Postgres 16 container plus the app served by gunicorn behind entrypoint.sh, which waits for Postgres, runs flask db upgrade, then starts gunicorn -- migrations are always applied on boot). The API is then available at http://localhost:5000 either way.
The existing suite runs under TestingConfig (an in-memory SQLite database created fresh per test, so it never touches your dev database):
source .venv/bin/activate
export SECRET_KEY=dev-secret
pytest -v
Work the tickets in practice_tickets/tickets/ (TICKET-01 through TICKET-07); each names one dedicated test in practice_tickets/tests/ -- a separate pytest package from the project's normal tests/, which plain pytest runs by default:
SECRET_KEY=dev-secret pytest practice_tickets/tests/test_ticket_01_name_length_boundary.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 7, clean pass/fail summary
All 7 dedicated tests fail out of the box -- that is the starting point, not a setup mistake. Fixing TICKET-02 (search operator flip) and TICKET-04 (CSV row-number off-by-one) correctly also turns 4 currently-failing tests in the project's own tests/ suite back to green.
Level 2
Implement a feature
Extend the system within its own patterns.