Nobody has started this yet — be first.
Business impact
Support and security both routinely need to answer "when did this user last actually log in" -- to confirm an invite was ever accepted, to spot dormant accounts during an access review, to help someone locked out figure out whether their last successful login was before or after some change. User.to_dict() already advertises a last_login_at field in its API responses, but it is wired to a constant -- every user, forever, shows last_login_at: null, whether they logged in five minutes ago or never. Anyone building against this field is building against data that does not exist yet.
Problem
User.to_dict() returns a hardcoded "last_login_at": None; there is no last_login_at column on users at all, and nothing in app/auth/routes.py:login updates anything resembling it.
Current behavior
Every to_dict() call returns last_login_at: null regardless of how many times, or how recently, that user has logged in, because the value is a literal constant, not a read of a persisted column -- one does not exist to read.
Expected behavior
users.last_login_at is a real, nullable, timezone-aware timestamp column, added via a new Alembic migration. A successful tenant-scoped login (POST /api/auth/login) sets it to the current time and persists it. User.to_dict() reflects the real, persisted value -- null for a user who has genuinely never logged in, and the real timestamp (updated on every login, not just the first) otherwise.
Steps to reproduce
Why this matters
This is the one ticket in this set that requires touching the schema, not just application logic. users is a global, non-tenant-scoped table -- it carries no RLS policy -- so this migration needs no tenant_id, no RLS policy, and no saas-role grant changes: it is the simplest possible "add one column" migration, worth getting comfortable with before ever needing the more involved tenant-scoped kind.
Suggested approach
Look at how created_at is defined on User in for the column style to match (, nullable this time). Write a new migration file in (chained with ) that adds the column via . Then update to set it on the object right around where the session is established, commit, and update to return the real value instead of the constant.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_14_last_login_at_tracking.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/last-login-at-trackingFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/last-login-at-trackingSubmit 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 inapp/models/user.pydb.DateTime(timezone=True)migrations/versions/down_revision = "0002_row_level_security"op.add_columnapp/auth/routes.py:loginUserto_dict()acmeglobexmake seed
*.localhost subdomains resolve to 127.0.0.1 out of the box in curl and every modern browser -- no /etc/hosts editing needed:
curl -s -X POST http://acme.localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
--cookie-jar /tmp/acme.cookies --cookie /tmp/acme.cookies \
-d '{"email": "owner@acme.test", "password": "owner-acme"}'
curl -s http://acme.localhost:5000/api/tenant/settings --cookie /tmp/acme.cookies
For tooling that cannot fake subdomains (some HTTP clients, CI), send X-Tenant-Slug instead -- honored only when TRUST_TENANT_HEADER=true (the Docker dev setup enables it; production does not):
curl -s http://localhost:5000/api/tenant/settings -H "X-Tenant-Slug: acme"
You do not strictly need Docker to work the tickets, but -- unlike a project that can fake its backing services -- this one genuinely needs a real Postgres and Redis, because the whole point is proving isolation against the real database, not a mock. Without Docker: cp .env.example .env, point DATABASE_URL/ADMIN_DATABASE_URL/TEST_DATABASE_URL/TEST_ADMIN_DATABASE_URL/REDIS_URL at a local Postgres/Redis (docker/init-db.sql documents the exact roles/databases the app expects -- notably the saas_admin/BYPASSRLS role migrations need and the plain saas role every request uses), then:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/flask db upgrade
.venv/bin/pytest practice_tickets/tests/test_ticket_07_webhook_signature_raw_body.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
./practice_tickets/run_tickets.sh 07 12 13 # just a few, by number
All 15 tests fail at the start; as you fix each ticket in app/ (or, for TICKET-12, in migrations/versions/0002_row_level_security.py, plus re-applying it), its test -- and only its test -- should flip to passing. make test runs the project's own main suite; several of its tests (RBAC, tenant-spoofing, and billing-webhook tests among them) fail at the start for the same underlying reasons as specific tickets -- fixing those tickets fixes those failures too, not a separate task.
Level 2
Implement a feature
Extend the system within its own patterns.