Nobody has started this yet — be first.
Business impact
A customer importing a spreadsheet of 500 URLs to shorten (a common onboarding flow for any link-management product) currently has to make 500 separate POST /api/urls calls, each with its own network round trip -- slow, and with no way to see a single, coherent "here is what happened to each of your 500 rows" result. Worse, any client naively looping over POST /api/urls and stopping on the first error loses no data, but a client that does not handle errors per-item carefully can misreport which specific items succeeded.
Problem
POST /api/urls/bulk has a route registered but it is a placeholder that raises NotImplementedError("POST /api/urls/bulk is not implemented yet (see TICKET-19)").
Current behavior
Calling POST /api/urls/bulk with any body currently raises NotImplementedError.
Expected behavior
POST /api/urls/bulk accepts {"items": [{...}, {...}, ...]}, where each item has the same shape as a single POST /api/urls body. It creates as many of them as it can and returns 207 Multi-Status with a per-item result array (each entry shaped like {"status": 201, "slug": "...", "short_url": "..."} on success, or {"status": 409, "error": "..."} / {"status": 400, "error": "..."} on failure) in the same order as the input items. One bad item (a collision, a missing target_url) must not abort or corrupt the other items in the same batch.
Steps to reproduce
Why this matters
The "one bad item shouldn't sink the batch" requirement is the interesting part. If every item in the batch shares one outer DB transaction and any single IntegrityError aborts that whole transaction, every other item in the batch fails too (even ones that were perfectly valid) unless each item's insert is wrapped in its own recoverable unit of work -- e.g. a SQLAlchemy SAVEPOINT (db.session.begin_nested()) per item, so a failed item can be rolled back to that savepoint alone while the surrounding session/transaction (and everything already committed within it) stays intact.
Suggested approach
Look at how a single create is validated and inserted in create_short_url -- the per-item logic should be very close to that, just looped. Look up db.session.begin_nested() for how to give each item its own rollback boundary within one request/session, so a collision on item 2 doesn't force you to redo (or lose) item 1's insert.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_19_bulk_create.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
Sign in to create it automatically, or do it yourself:
$git checkout -b feat/bulk-create-endpointFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/bulk-create-endpointSubmit 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 indocker-entrypoint.shcurl -X POST http://localhost:5000/api/urls \
-H "Content-Type: application/json" \
-d '{"target_url": "https://anthropic.com", "custom_slug": "claude"}'
# -> {"slug": "claude", "short_url": "http://localhost:5000/claude", "expires_at": null}
curl -i http://localhost:5000/claude # 302 redirect, records a click
make seed # or: docker compose exec web flask seed-db
If something is already listening on 5432/6379 locally, uncomment POSTGRES_HOST_PORT / REDIS_HOST_PORT in .env to remap the host side — the app always talks to db/redis over the compose network regardless. Run production-style (gunicorn, no dev server, no bind mount) with docker compose -f docker-compose.prod.yml up --build. GET /healthz checks both Postgres and Redis connectivity.
You do not need Docker running at all to work the tickets — the test suite uses TestingConfig (SQLite in-memory via StaticPool) and fakeredis instead of a real Postgres/Redis, so everything runs against a plain virtualenv:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/pytest practice_tickets/tests/test_ticket_01_base62_roundtrip.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 20, clean pass/fail summary
./practice_tickets/run_tickets.sh 05 13 16 # just a few, by number
All 20 tests fail at the start; as you fix each ticket in app/, its test (and only its test) should flip to passing. .venv/bin/pytest tests/ (via make test) runs the project's own main suite — some ticket bugs (TICKET-05, 07, 08, 09, 14 among others) also break assertions in that suite, since they are real bugs in shared code paths; fixing the ticket fixes those too, not a separate task.
Level 2
Implement a feature
Extend the system within its own patterns.