Bus shifts: model, constraints and a reproducible environment - #78
Draft
suush wants to merge 9 commits into
Draft
Conversation
The project ran on the host in a virtualenv, and the README documented a `make migrate` target that never existed. A fresh clone now needs a container runtime and nothing else. Dockerfile.dev derives from the production image through a compose build context rather than repeating its FROM, so dev and prod resolve one dependency set. Static files are collected at build time and the image carries its own healthcheck, so it serves without a second service. make ci is the single definition of green; every other target is runnable alone.
Django 4.2 left extended support on 7 April 2026 and Python 3.9 reached end-of-life on 31 October 2025. Securing what is shipped includes the runtime versions, so both move to the current LTS and a supported interpreter. The upgrade costs one line in the codebase: USE_L10N was removed in Django 5.0. unique_together is unaffected. SQLite is replaced by PostgreSQL 17 in the container, for dev/prod parity and because an overlap constraint can then be enforced by the database rather than by the application. pyproject.toml becomes the only manifest, with uv.lock as the source of truth. requirements.txt and Pipfile are removed. BREAKING CHANGE: settings are read from the environment and no longer carry defaults. DJANGO_SECRET_KEY and DATABASE_URL are required and the application refuses to boot without them. Every variable is listed in .env.example.
The README described a start-up procedure that no longer exists. It now lists the URLs reachable after `make up` and what each one serves. docs/coverage.md states what the system handles and, above all, what it does not, each with its reason: the overlap constraint, the absent tests, the deferred hexagonal layering. A scenario missing from that file cannot be told apart from one nobody thought about. docs/flows/existing-system.md is written from the code and records three fragilities found while reading it, including a non-unique username factory against a unique column. Google-style docstrings document what the signatures do not say.
A BusShift carries a bus, a driver, and time bounds derived from its stops. A BusStop is a place and a time of call, belonging to exactly one shift. The non-overlap rule is enforced by two ExclusionConstraints — one per resource, not one on the (bus, driver) pair, so a bus shared with one shift and a driver shared with another are both caught. A clean() check would not hold: two concurrent transactions each validate against a state that excludes the other, and row locking cannot lock a row that does not exist yet. The bounds are stored rather than derived on read, because an ExclusionConstraint needs indexable columns. A GeneratedField turns them into a half-open TSTZRANGE, so a shift ending at 12:00 and one starting at 12:00 chain without an artificial gap. Both constraints are partial: a stopless shift has NULL bounds, and TSTZRANGE(NULL, NULL) is the unbounded range, which would otherwise forbid every shift for its bus. The cost of storing them is a single explicit refresh_bounds(), deliberately not a signal — bulk_create, bulk_update, queryset.update() and queryset.delete() all bypass signals and are exactly the paths that would leave the columns lying. 24 tests pass. The one that fails asserts the two-stop rule, which has no implementation yet: it counts rows in another table and belongs in the admin inline formset. It is left red rather than deleted. The admin interface is NOT implemented — see docs/coverage.md.
Standard library, third party, project, then a TYPE_CHECKING block last. The order is enforced by ruff rather than agreed by convention: the TC ruleset moves type-only imports into the block, and `from __future__ import annotations` is a required import everywhere, so annotations are never evaluated at run time. Public signatures are annotated. mypy runs non-strict: strict reports 52 errors across the inherited code, and a gate that is red on every run teaches everyone to ignore the gates. Tightening it is listed in docs/coverage.md. Template tooling under .claude/ and the session transcripts are excluded from ruff: neither is this project source, and the formatter rewrites Python blocks inside markdown.
docs/coverage.md moves seven scenarios to the handled side and rewrites the gaps to name what blocks objective 2: no BusShiftAdmin, no inline formset, no recompute wired to save_related, no N+1 protection on the changelist. The decision record explains why the bounds are stored rather than derived, what that costs, and which alternative was rejected and why. quality-gate.json records, for every command, whether its output was actually seen. Nothing is called green on inference.
The body describes the branch, so committing it would date the moment it merges.
The entry point sits at the repository root, outside padam_django/, so it was missed by a path-scoped add in the previous commit.
suush
marked this pull request as draft
August 29, 2026 19:47
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Honest status first
The exercise is not finished. The models, their constraints and their tests
are in place; the Django admin interface for
BusShiftis not, which meansobjective 2 of the subject is not met. The time budget went further than the
four hours suggested, and the work stops here rather than being padded.
What is missing is listed precisely below, with what each piece would take. The
same applies to everything deliberately left out:
docs/coverage.mdstates whatthe system handles and what it does not, with the reason — an unhandled
scenario absent from that file is indistinguishable from one nobody thought
about.
make test)make lint,make format-check,make check,make docs-build)BusShiftThe red test is not deleted. It asserts the "at least two stops" rule, which has
no implementation yet, and it carries a
TODOpointing at where that rule willlive. A suite made green by removing what fails is a suite that measures
nothing.
Use of an AI assistant
An AI assistant was used for this exercise, and this section is here so that its
role can be judged rather than guessed.
The assistant was install from a personal owned project called Claude graft it's purpose
is to initialize an assistant on a project with a half
project doctrineembeded, the other half is determinedat installation time of this graft plug in claude.
For example it already embed the DDD layering, the documentation rules, the quality rules .. etc
You can access it here: https://github.com/Forge-Stack-Workshop/claude-graft
It was bounded by configuration, not by good intentions. The assistant had
read-only access to the Django code:
Edit(/padam_django/**)isdenyin.claude/settings.json, so it could not write a model, an admin class or amigration. Proposals were delivered as code blocks in the conversation and typed
by hand. Two narrow exceptions were opened explicitly, each recorded in the
configuration with an expiry, and both closed before the end:
Bus,Driver,Place,User, the seeding commands) — comments only, no logic;BusShift,BusStop, their constraints and their migration were written byhand.
git blameshows it.The configuration is committed, deliberately —
.claude/is not gitignoredin this repository. It contains the engineering doctrine the work follows
(
.claude/rules/), the scope rule that bounded the assistant(
.claude/rules/interview-scope.md), the permission set, and.claude/quality-gate.json, which records for every command whether its outputwas actually seen or not.
The full session transcript is committed:
docs/sessions/2026-08-29-d0f0905d.md.It shows the reasoning, the disagreements, and the places where the assistant's
proposals were rejected or corrected.
The assistant's role was sparring: reviewing, flagging when a project rule was
about to be broken, and doing the parts that are not code — documentation,
environment, tests. Every design decision was taken by the author.
What was done, and why
1. The stack was moved off end-of-life versions
The repository shipped Django 4.2.16 and recommended Python 3.9.
Pipfile)This is a deliberate departure from the stated stack. Choosing runtime versions
is part of shipping something safely, and delivering an image built on an
unsupported runtime while claiming production intent would be incoherent.
The upgrade cost one line of code:
USE_L10Nwas removed in Django 5.0.unique_togetherwas unaffected — it isindex_togetherthat went. Nothingelse in the deprecation list is exercised by this codebase.
It also earns two things the feature uses directly:
GeneratedField(Django 5.0)to have the database compute the shift's time range, and
UniqueConstraint.violation_error_messagenow always honoured, which turns aconstraint refusal into a readable message instead of an
IntegrityError.2. The environment was rebuilt to be reproducible
The project ran on the host in a virtualenv, and the README documented a
make migratetarget that did not exist in the Makefile.Dockerfile— production image, non-root, static filescollected at build time, its own
HEALTHCHECK, gunicorn.Dockerfile.devderives from it through a compose build context ratherthan repeating its
FROM, so dev and production resolve one dependency set.pyproject.toml+uv.lockas the single manifest;requirements.txtand
Pipfileremoved.constraint needs a database that can express it.
Makefileas the only human interface; no recipe runs a tool on the host.make upworks from a fresh clone with no manual step: it creates.envfromthe committed
.env.example.application refuses to boot without
DJANGO_SECRET_KEYorDATABASE_URL.Every command was executed and its output read. The two URLs the README hands
over were requested, along with every asset they reference.
3.
BusShiftandBusStopThe overlap constraint is enforced by the database, not by the application.
This is the central decision, and it drove the rest.
A
clean()check does not survive concurrency: two transactions each validateagainst a state that excludes the other, and both commit. Row locking does not
help either — the conflicting row does not exist yet. Only a database constraint
holds.
So
BusShiftcarries storedstarts_at/ends_atcolumns, recomputed fromits stops, and a
periodGeneratedFieldproducing aTSTZRANGE. TwoExclusionConstraints — one on the bus, one on the driver — refuse any overlap.Three points worth raising in review:
shift and its driver with another. A constraint written on
(bus, driver)would let a bus be in two places at once.
[starts_at, ends_at). A shift ending at 12:00and one starting at 12:00 do not overlap, so a driver's day chains without an
arbitrary one-minute gap.
bounds, and
TSTZRANGE(NULL, NULL)is the unbounded range, which overlapseverything — one stopless shift would forbid every shift for its bus. The
condition=keeps those rows out of the index.The cost is documented: stored bounds can drift from the stops. The
mitigation is a single explicit
refresh_bounds()— not a signal, becausebulk_create,bulk_update,queryset.update()andqueryset.delete()allbypass signals and are precisely the paths that would leave the columns lying.
An alternative was considered and rejected: making the bounds operator input
with stops required to fall inside. It removes the drift entirely, but inverts
the subject, which states that departure is determined by the first stop.
4. Documentation
docs/coverage.md— what is handled, what is not, and why not.docs/flows/bus-shift.md— the call chain, written from the code, naming themissing admin link rather than describing an interface that does not exist.
docs/flows/existing-system.md— the inherited system, which surfaced threefragilities: a non-unique username factory against a unique column, seeding
commands that create disjoint sets of users, and an N+1 in
UserAdmin.Next steps — to finish the feature
BusShiftAdminwith aBusStopinline. Objective 2. Without it a shiftcan only be created from the shell.
save_related()callingrefresh_bounds(), so the bounds are recomputedafter the inline formset has written the stops — the only moment they are
knowable.
clean()requiring at least two stops. This cannot be a tableconstraint: it counts rows in another table, and the zero-stop state is
legitimate mid-transaction. The failing test is waiting for it.
get_queryset()withselect_related('bus', 'driver__user')andannotate(Count('stops')), so the changelist costs three queries insteadof three per row.
IntegrityErrorin the admin form and render theviolation_error_messagethe constraints already carry, instead of a 500.autocomplete_fieldsandsearch_fieldson bus, driver and place —a plain select over every place does not scale.
Place, so an operator picks a location rather thantyping coordinates.
padam_django/apps/users/admin.py:is_driver.booleanand.short_descriptionare function attributes, whichare not typable.
@admin.display(boolean=True, description="Is driver")replaces both.
Next steps — to go further
make cimake cialready exists as the single definition of green; only the thin caller on the forge is missing. Left out for time.pre-commithooksmaketargets.git-cliff+ generatedCHANGELOG.mdcliff.tomlwith no release pipeline behind it serves nothing today.TransactionTestCaseplus threads is the shape.domain/application/infrastructurecosts more than the exercise is worth, and would obscure the Django idiom being evaluated.BusShiftwould become a pure entity holding the overlap rule, behind a repository port.is_staffis the only gate; no per-action role check, no denial test.factory-boyandfakership in the production imagecreate_*commands live in the application package. In a real deployment this is an architectural defect — seeding tooling must not ship with the application.src/layout and a PEP 517 build backendmanage.pyandpadam_django/costs more than it returns for an application nobody installs as a package.Reviewing it
Then http://localhost:8000/admin/.
make docsserves the documentation on:8001.
make helplists every target. Nothing runs on the host.