Nobody has started this yet — be first.
Business impact
Deploy this app on any server not configured for UTC (a very normal thing to do -- a laptop, a regional VM, a container base image with a local timezone baked in) and every single expires_in_days link expires early or late by exactly that server's UTC offset. A "expires in 7 days" link promised to a customer could actually expire 4-5 hours sooner or later than advertised depending on where the process happens to run -- and worse, if the app is ever redeployed to a different region/timezone, previously consistent behavior silently shifts, with no error, no log, nothing to point at.
Problem
expires_at = datetime.now() + timedelta(days=expires_in_days). datetime.now() returns local wall-clock time (whatever TZ the process happens to be running under), not UTC.
Current behavior
ShortURL.created_at's column default correctly uses datetime.utcnow(), and every other timestamp in this project (ClickEvent.timestamp, is_expired(), cache.py's expiry math) is naive UTC by explicit design -- expires_at is the one value in the whole system computed in the wrong clock, so expires_at - created_at is off by the server's UTC offset instead of matching expires_in_days.
Expected behavior
expires_at = datetime.utcnow() + timedelta(days=expires_in_days), so expires_at - created_at is ~expires_in_days days regardless of the server's local timezone configuration.
Steps to reproduce
Why this matters
datetime.now() vs datetime.utcnow() is one of the most common timezone bugs in any codebase that mixes the two -- both return a naive datetime (no tzinfo), so nothing about the type system or a casual code review flags that they represent different moments in time. The bug only becomes visible once you compare a datetime.now()-derived value against a datetime.utcnow()-derived value from elsewhere in the same system, which is exactly what expires_at - created_at does here.
Suggested approach
Find every other place in this codebase that computes "now" (app/models.py, app/utils/cache.py, app/blueprints/urls/routes.py::resolve_short_url, _record_click) and confirm they all use datetime.utcnow(). Match create_short_url's expires_at computation to that same convention.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_14_expires_at_utc.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 fix/expires-at-local-timeFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/expires-at-local-timeSubmit 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 1
Fix a bug
Read existing behaviour, correct it.