Django-based inventory application.
The primary user-facing interface is a simple "My items" page that shows the equipment currently assigned to the logged-in user and allows viewing the item history.
- Root page:
GET /shows My items (requires authentication). - Previously my items:
GET /previous/shows items that used to be assigned to the logged-in user (requires authentication). - Item history:
GET /items/<id>/shows the item's operations history (only for items currently assigned to the logged-in user, items the user had in the past, or items that have an active incoming transfer offer for the logged-in user). - Change location:
POST /items/<id>/change-location/records a location change for an item the logged-in user currently owns. - Create / update transfer:
GET /items/<id>/transfer/shows the transfer form;POSTcreates a new offer or updates an existing one initiated by the current user. - Accept transfer:
POST /transfers/<id>/accept/accepts an incoming transfer offer; requiresjournal_head_operation_idin the POST body (set by the UI) to guard against stale state. - Cancel / decline transfer:
POST /transfers/<id>/cancel/cancels the offer (sender) or declines it (receiver). - Profile:
GET /profile/shows email and password change forms (requires authentication). - Login:
GET /login/ - Logout:
POST /logout/ - Password change:
GET|POST /password/change/(requires authentication). - Password reset:
GET /password/reset/→/password/reset/done/→GET|POST /password/reset/<uidb64>/<token>/→/password/reset/complete/ - Email change confirmation:
GET /email/change/confirm/<uidb64>/<token>/<new_email>/ - Language switch:
POST /i18n/setlang/ - Admin UI:
GET /admin/. Reference data ("catalogs") management is performed in the admin UI and is limited by the authenticated user's permissions. - Health:
GET /health/liveness/,GET /health/readiness/
The user-facing inventory pages (/, /previous/, /items/<id>/) are scoped by
the Responsible profile linked to the authenticated User
(catalogs.Responsible.user).
Responsible is a domain entity and can exist without any Django User. Linking
to a User is optional and is only required for the user-facing pages. If the
current User is not linked, the UI will show a message and no items.
To link an account, open the Django admin, edit the desired Responsible record
and set its user field to the corresponding Django User.
Two Django auth groups are defined and kept in sync by code (common.application_groups):
Staff: permissions are view-only on all first-party app models plusadmin.add_logentry. Membership is tied toUser.is_staffwhen the user is not a superuser: after each successfulUsersave, membership is reconciled ontransaction.on_commitso it still applies after the admin runsform.save_m2m()for thegroupsfield (which would otherwise overwrite an immediatepost_saveassignment).Editor: full CRUD on first-party app models only (not on Django contrib models such asLogEntry). Additionallyadmin.add_logentryandadmin.view_logentryfor the global admin log. Group membership is assigned manually. Permissions on the group row are still enforced by code.
When group permissions are refreshed (enforce_application_groups()): on
post_migrate for each installed app (once per app per migrate run; the
enforcer is idempotent and the last passes converge after
create_permissions / bulk_create for later apps — avoids querying the DB from
AppConfig.ready()), and on post_save / post_delete for Group or Permission.
New default permissions created via bulk_create (as in
django.contrib.auth.management.create_permissions) do not emit per-row signals;
run migrate (which re-runs the enforcer on later post_migrate hooks), touch a
Group or Permission, or call the enforcer from a management command if you need
an immediate refresh outside migrate.
In the admin, the Staff and Editor group records cannot be changed or deleted
(even for superusers); only membership (for non–application-defined groups) and
the Editor assignment remain user-controlled.
uvpackage manager- Python version: see
.python-version
make installRun locally:
make runThen open:
http://localhost:8000/(will redirect to login if not authenticated)http://localhost:8000/admin/http://localhost:8000/health/
Run using Docker Compose:
docker compose up -dNotes:
docker composein this repository is intended to run PostgreSQL and a local Mailpit SMTP catcher (UI athttp://localhost:8025, SMTP on host port1026→ container port1025) for local development.- Run Django locally with
make runand point it to the Postgres instance via env vars (seeenv.example). - The Docker image itself starts the application with Gunicorn (see
Dockerfile) and serves static files via WhiteNoise. - If you run the image directly (without Docker Compose), you must run migrations yourself.
- If you want to run the Docker image against the local Postgres started via
docker compose up -d, useenv.docker(it pointsDATABASE_HOSTtohost.docker.internal). On Linux,make docker-runincludes the required host mapping (--add-host=host.docker.internal:host-gateway).
-
Append-only operations: an item's state is tracked via
Operationrecords. Older operations cannot be edited; only the latest operation for an item may be corrected. -
Correction window (
INVENTORY_CORRECTION_WINDOW_MINUTES, default 0 — disabled):- Operations: only the latest
Operationper item may be corrected, and only while itscreated_atis still inside the window (inventory.Operation) for non-superusers. Django superusers bypass that time cap in the admin on the head row only (same repair idea asItem); older operations stay immutable. - Items: once the item has at least one operation (an accountable party in the
journal), edits to core item fields are limited by the same minute cap, anchored
on the row's
created_at(inventory.Item). - Catalog / device definitions: locations, statuses, responsible records, device
taxonomy rows, and
Devicerows use the same cap oncreated_atwhen the row is referenced by live inventory data; unreferenced rows stay editable. Enforcement is shared viacommon.catalog_correction_window.CatalogCorrectionWindowMixin. Django superusers bypass these windows in the admin only (trusted repair path). - Immutable anchor: The correction window is anchored on
created_at(immutable timestamp), notupdated_at, so the window does not reset on each save. This ensures consistent behavior and prevents accidental window extension. - Trusted admin repair: bypass flags (
_bypass_item_correction_window,_bypass_operation_correction_window,_bypass_catalog_correction_window) are set only fromModelAdmin.get_form()in this codebase — never from request data. They let a Django superuser fix mistakes after the time cap in the admin; the append-only rule for non-latestOperationrows is not bypassed.
- Operations: only the latest
-
Item history visibility: item history is only accessible to the current owner, to the receiver of an active incoming transfer offer, and to former owners. Former owners can only see the history up to the last time the item was assigned to them, plus one subsequent handoff operation. This is a privacy/security invariant: it provides enough context for a handoff dispute ("when and to whom it was transferred") without exposing the item's subsequent history to former owners.
-
Transfer offer expiry: offers created from the user UI get an
expires_attimestamp whenINVENTORY_PENDING_TRANSFER_EXPIRATION_HOURSis positive (default: one week). After that moment the offer is no longer active (same rule asPendingTransfer.is_active): the inventory UI and list views treat it like an absent offer for cards and actions, even though the row may still exist in the database until cleaned up elsewhere. -
Automatic transfer acceptance when the receiver has no linked user: if the receiver
Responsiblehas noUser, they cannot press "Accept" in the web UI. In that case the application accepts the offer immediately (appends the ownershipOperationand setsaccepted_at) so the item never remains in a pending state that cannot be cleared from the user-facing flows. If you need a real pending confirmation, link a DjangoUserto the receiverResponsiblebefore creating the offer.
The application is configured via environment variables (loaded using
django-environ).
See env.example for a complete list of supported variables.
- Django
DEBUG: enable debug mode (default:0)SECRET_KEY: required whenDEBUG=0ALLOWED_HOSTS: comma-separated listCSRF_TRUSTED_ORIGINS: comma-separated list
- Database (PostgreSQL)
DATABASE_HOST(default:127.0.0.1)DATABASE_PORT(default:5432)DATABASE_NAME(default:database)DATABASE_USER(default:user)DATABASE_PASSWORD(default:password)
- Logging
LOG_LEVEL(default:DEBUGwhenDEBUG=1, elseINFO)
- Internationalization
TIME_ZONE(default:UTC)
- Inventory
INVENTORY_CORRECTION_WINDOW_MINUTES(default:0— disabled; set to a positive integer to enable the correction window)INVENTORY_PENDING_TRANSFER_EXPIRATION_HOURS(default:168— one week; offers created from the user UI getexpires_atat creation. Set to0to disable automatic expiry unless set manually in the admin)
- Email
EMAIL_BACKEND(default:common.email_backends.AsyncEmailBackend; usedjango.core.mail.backends.console.EmailBackendfor local development)EMAIL_HOSTEMAIL_PORT(default:587)EMAIL_USE_TLS(default:1)EMAIL_USE_SSL(default:0)EMAIL_HOST_USEREMAIL_HOST_PASSWORDEMAIL_TIMEOUT(default:10seconds)DEFAULT_FROM_EMAIL(default:noreply@example.com)SERVER_EMAIL(default:noreply@example.com)EMAIL_SEND_ASYNC(default:1; set to0to send synchronously)EMAIL_RETRY_MAX_RETRIES(default:2)EMAIL_RETRY_BASE_DELAY_SECONDS(default:60)EMAIL_RETRY_BACKOFF_FACTOR(default:2)SITE_URL: base URL used for links in emails (e.g.http://localhost:8000); must be set explicitly — no default; without it, email change confirmation links will be relative and broken
The application sends transactional emails on the following events:
- Operation saved (
inventory.signals): when a newOperationis created or an existing head operation is updated, emails are sent to the newly assignedResponsible("assigned") and, if the responsible changed, to the previously assigned one ("unassigned" / "updated"). - Transfer offer created (
inventory.signals): the receiver is notified. - Transfer offer updated (
inventory.signals): when the receiver changes, the old receiver receives a cancellation email and the new receiver a creation email. - Transfer offer accepted (
inventory.signals): both sender and receiver receive a notification. - Transfer offer cancelled (
inventory.signals): both sender and receiver receive a notification. - Responsible linked (
catalogs.signals): when aUseris linked to aResponsible, the user receives a “linked” email. - Responsible unlinked (
catalogs.signals): when aUseris removed from aResponsible, the former user receives an “unlinked” email. - Responsible updated (
catalogs.signals): when aResponsiblerecord changes without altering the linkedUser, the user receives an “updated” email. - Email change (
common.views): confirmation link emailed to the new address, then a notification sent to the old address. - Password reset (
django.contrib.auth): reset link emailed to the user.
All emails are queued asynchronously by default (EMAIL_SEND_ASYNC=1) using
django-q2 with a PostgreSQL ORM broker.
Jobs are persisted in the database and survive process restarts, unlike daemon
threads.
The default CMD ["start"] runs both the web server and the worker in the same
process under a common supervisor, so each replica handles HTTP requests and
task processing without a separate container.
To send synchronously — useful in tests or simple single-process deployments —
set EMAIL_SEND_ASYNC=0.
Two languages are supported: English (en) and Russian (ru). The active
language is stored in the django_language cookie and can be switched via
POST /i18n/setlang/.
Translations are stored in src/*/locale/*/LC_MESSAGES/django.po and are
compiled into .mo files.
To compile translations locally:
PYTHONPATH=src uv run python src/manage.py compilemessagesmake allNote: use make targets for checks (make all, make test, make lint). They
set a tooling-only SECRET_KEY for pytest and mypy so local runs do not
depend on developer environment variables.
To print EXPLAIN (ANALYZE, BUFFERS) for the "My items" / "Previously held"
querysets after changing ORM fragments or indexes, see
docs/inventory-list-query-profiling.md
and run python src/manage.py profile_inventory_list_queries.
All tests run against PostgreSQL via pytest-django. A running Postgres instance
is required — use docker compose up -d to start one locally.
The correction window is enabled during tests (INVENTORY_CORRECTION_WINDOW_MINUTES=10)
to exercise that code path.
The Makefile includes env.example (or .env if present) to provide default
DATABASE_* credentials. No SQLite variant is used.
Some tests validate PostgreSQL-specific behavior (e.g. row-level locking,
transaction isolation). See inventory/tests/test_concurrency.py for examples.
Note: src/conftest.py is test infrastructure (not application code) and is
excluded from coverage.
Formatting is intentionally not part of make all (so checks do not mutate the
working tree). To auto-format code, use:
make formatRun tests (an HTML coverage report is produced in htmlcov/):
make test- Coverage 100%: we exclude typing-only lines (e.g.
@overload,if TYPE_CHECKING:,...) via coverage config inpyproject.toml. - Concurrency tests:
inventory/tests/test_concurrency.pyvalidates row-level locking semantics and is intended to run on PostgreSQL (it is skipped on SQLite). CI runs PostgreSQL-only tests in a dedicated workflow (Tests Postgres). - Testing entrypoint:
manage.py testis intentionally disabled; usemake testormake allinstead.