All jobs

Make a Database Schema Change in a Django App as a Team

Yes. An agent writes a Device soft-delete migration, waits for approval, then updates the model, queries and tests in one pull request.

Run this on RushIn a Project that has your repository.

What the agent does

The agent reads the model before it writes a migration. In an app shaped like netbox-community/netbox, requirements.txt pins Django==6.1.2 and pyproject.toml requires Python >=3.12. CI on .github/workflows/ci.yml tests 3.12, 3.13 and 3.14 with python netbox/manage.py test netbox/ --parallel --keepdb --timing. Device is in netbox/dcim/models/devices.py. It subclasses ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, TrackingModelMixin and PrimaryModel. There is no deleted_at column. status is a CharField of DeviceStatusChoices, default STATUS_ACTIVE, which is an operational state, not a delete flag. Meta orders by name, pk, and adds unique constraints on (Lower name, site, tenant), on (rack, position, face), and on (virtual_chassis, vc_position). The latest dcim migration file is 0251_fix_ltree_cascade_triggers.py. objects on the base model is RestrictedQuerySet.as_manager(), and RestrictedQuerySet.restrict filters by the user's object permissions.

The first turn writes only the migration. The agent adds the next dcim migration, an AddField of deleted_at as a nullable DateTimeField on dcim.Device, and does not yet change managers, filtersets or views. It runs python netbox/manage.py sqlmigrate dcim for that migration number and puts the SQL in the turn's answer, along with the migration file. It does not open the pull request yet. It stops. The sandbox parks with the tree still checked out. A teammate reads the SQL from the session, or from GET /o/{org}/sessions/{id}/events as it was produced. Approval is a follow-up: POST /o/{org}/sessions/{id}/messages with an input that says the migration is accepted, sent only after status leaves running. A message during the turn is 409 TURN_ACTIVE.

After that follow-up the agent edits the model. deleted_at is the field the migration added. The default manager has to keep RestrictedQuerySet, because replacing objects with a plain Manager would drop restrict(). The shape that fits is a QuerySet subclass of RestrictedQuerySet that filters to deleted_at__isnull=True, plus an explicit manager for rows that still have a timestamp, so a restore and a uniqueness check can see them. The unique constraint on name, site and tenant does not mention deleted_at, so a soft-deleted device still holds its name unless the constraint's condition changes. The pull request says so. Queries that list devices pick up the default manager: DeviceViewSet in netbox/dcim/api/views.py, DeviceSerializer in netbox/dcim/api/serializers_/devices.py, DeviceFilterSet in netbox/dcim/filtersets.py, and the GraphQL DeviceType in netbox/dcim/graphql/types.py with DeviceFilter in netbox/dcim/graphql/filters.py.

Tests and the pull request are the last step of the same sandbox. The agent adds a test that a device with deleted_at set disappears from Device.objects and remains visible on the unfiltered manager, and that a user without permission still gets an empty restrict() result. It runs python netbox/manage.py test against the dcim tests that cover Device, not a made-up subset, and quotes the output. Then it opens one pull request containing the migration, the model, the manager, the query updates and the tests. The description includes the sqlmigrate text from the first turn so a reviewer can see the SQL that was approved.

Start it with one API call

POST https://api.prix.dev/o/{org}/sessions with this body:

{
  "agent": "codex",
  "input": "Make this schema change in NetBox: add a nullable deleted_at timestamp to dcim.Device and exclude deleted rows from the default manager. Device is netbox/dcim/models/devices.py, objects is RestrictedQuerySet, and the latest dcim migration is 0251_fix_ltree_cascade_triggers. First write only the migration and post it with the output of sqlmigrate. Do not open a pull request yet. Wait for a follow-up that approves the SQL. After approval, update the model, keep RestrictedQuerySet.restrict, update DeviceViewSet, DeviceFilterSet and the GraphQL Device type, add tests, run python netbox/manage.py test for the affected dcim tests, and open one pull request. Do not treat Device.status as the soft-delete flag.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
  1. Get an API key. In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
  2. Send the request. POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
  3. Read what came back. Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

What comes back

What comes back is one pull request, opened only after the follow-up. The first turn's answer is the migration source and the sqlmigrate SQL, with no pull request yet. The session read after the second turn names the pull request. The branch contains the new migration after 0251, the deleted_at field, the manager change, the view and filter updates, and the tests. The description repeats the SQL that was approved and says whether the unique name constraint still includes soft-deleted rows.

A reviewer checks the SQL against the migration file, checks that objects is still a RestrictedQuerySet so permission filters keep working, and checks that a test covers both the hidden row and the unfiltered manager. The transcript has two turns: the migration commands, then the test run. GET /o/{org}/p/{project}/sessions/{id}/turns lists each prompt and its result when the session was started in a Project. The test output in the second turn is the command that ran, and a failure stays a failure in that result.

Run it on every schema change request

Each schema change is its own session. POST this body with the repository in resources, or POST /o/{org}/p/{project}/sessions when the work belongs to a Project. Idempotency-Key on the create stops a double submit from starting two migrations. The approval is not a second create. It is POST /o/{org}/sessions/{id}/messages on the parked session, so the follow-up sees the migration file already written.

The key's webhook fires once per turn, type run.completed, because a parked run sends one event when each turn finishes. The first event is the SQL, the second is the pull request. Use webhook-id to drop a retry. Past max_concurrent, default 4, another schema session answers 429, which matters when several apps want a migration at once.

The use case this job follows, with its automation and trigger: Steer a run as a team.

Why run it on Rush?

A soft-delete on Device touches a table the rest of NetBox joins, and the SQL should be read before models and queries change. The first turn ends, the sandbox parks with the migration on disk, and the follow-up is the approval. The checkout and the migration file are still there when the second turn starts.

The proof is the SQL in the first turn and the pull request in the second, with manage.py test in the transcript. A reviewer can see that status was left as DeviceStatusChoices and that restrict() was not replaced. Other schema changes run in other sandboxes, so two Django trees do not share a test database on one machine, and nothing merges until a person merges the pull request.

Frequently asked questions

Why not set status to a deleted value instead of a timestamp?

status is already DeviceStatusChoices and defaults to STATUS_ACTIVE. It describes how the device is operating. A timestamp is a separate nullable column, which is what the migration adds. Queries that filter on status keep working. The default manager adds the deleted_at condition on top.

Will the first turn open a pull request I have to close?

No. The request says to post the migration and the SQL, then wait. The pull request is opened after the follow-up that approves the SQL. If you reject the SQL, send a follow-up that says so. The agent revises the migration in the same sandbox and posts sqlmigrate again, still without a pull request, until an approval says to continue.

Does excluding deleted rows break RestrictedQuerySet?

Not if the new queryset subclasses RestrictedQuerySet and still calls restrict(). BaseModel sets objects = RestrictedQuerySet.as_manager() for every NetBox model. Swapping in models.Manager would stop permission filtering. The test should cover a user who fails restrict() and a soft-deleted row that Device.objects hides.

The unique constraint will still block a name reuse. Is that intentional?

The current constraint is unique on lower(name), site and tenant, with a condition only that name is not null. A soft-deleted row still matches it. The pull request says that plainly. Changing the condition to ignore deleted rows is a second schema decision, and it belongs in the SQL you approve, not in a silent follow-up edit.

Which test command does the second turn run?

python netbox/manage.py test for the dcim tests that cover Device. CI's full line is python netbox/manage.py test netbox/ --parallel --keepdb --timing on Python 3.12, 3.13 and 3.14. The sandbox run quotes the command it actually executed. It does not claim the whole matrix passed unless that matrix ran.