Nobody has started this yet — be first.
Business impact
Two different users (or the same user on two different days) creating a "Work" group and a "work" group end up with two separate groups that a human reader would consider the same thing. Contacts silently get split across "Work" and "work" depending on which one whoever assigned them happened to pick. Over time, in a shared team account, this produces a messy, duplicated group list that support has to manually clean up -- extra toil with no clear owner, and a confusing UI for the end user ("why are there two Work groups, and why don't they show the same people?").
Problem
POST /api/groups checks for an existing group with Group.query.filter_by(name=data["name"]) -- an exact, case-sensitive match. "Work" and "work" (and "WORK") are all accepted as distinct groups, each getting its own row and its own id.
Current behavior
Creating a group named "work" right after "Work" already exists succeeds with 201 instead of being rejected as a duplicate.
Expected behavior
Creating a group whose name matches an existing group case-insensitively should be rejected the same way an exact-case duplicate already is today: 422 with {"error": "validation_error", "details": {"name": ["a group with this name already exists"]}}. (Out of scope: renaming/merging groups that already exist as case-variant duplicates from before this fix -- this is only about preventing new ones.)
Steps to reproduce
curl -s -X POST http://127.0.0.1:5000/api/groups -H 'Content-Type: application/json' -d '{"name":"Work"}'
curl -s -X POST http://127.0.0.1:5000/api/groups -H 'Content-Type: application/json' -d '{"name":"work"}'
Why this matters
Group.name has a database-level unique=True constraint (see app/models.py), but SQLite/Postgres unique constraints are case-sensitive by default ("Work" != "work" as far as the database is concerned) -- so the DB won't save you here, the application layer has to enforce the case-insensitive rule itself, consistently, in the one place groups get created.
Suggested approach
Look at the duplicate-name check at the top of create_group() in app/blueprints/groups/routes.py. It currently does an exact match via filter_by(name=...). You'll need a comparison that's insensitive to case -- SQLAlchemy's Column.ilike() (already used elsewhere in this codebase, e.g. in contacts/routes.py's search filter) is one way to express "case-insensitive equals" without wildcards.
Acceptance criteria
Verification
SECRET_KEY=dev-secret pytest practice_tickets/tests/test_ticket_03_group_name_case_insensitive.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/group-name-case-insensitive-duplicatesFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/group-name-case-insensitive-duplicatesSubmit 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.