Skip to content

sloths-inventory

Overview

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.

User interface

  • 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; POST creates a new offer or updates an existing one initiated by the current user.
  • Accept transfer: POST /transfers/<id>/accept/ accepts an incoming transfer offer; requires journal_head_operation_id in 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/

Account linking

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.

Application auth groups (Staff, Editor)

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 plus admin.add_logentry. Membership is tied to User.is_staff when the user is not a superuser: after each successful User save, membership is reconciled on transaction.on_commit so it still applies after the admin runs form.save_m2m() for the groups field (which would otherwise overwrite an immediate post_save assignment).
  • Editor: full CRUD on first-party app models only (not on Django contrib models such as LogEntry). Additionally admin.add_logentry and admin.view_logentry for 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.

Requirements

  • uv package manager
  • Python version: see .python-version

Installation

make install

Running

Run locally:

make run

Then 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 -d

Notes:

  • docker compose in this repository is intended to run PostgreSQL and a local Mailpit SMTP catcher (UI at http://localhost:8025, SMTP on host port 1026 → container port 1025) for local development.
  • Run Django locally with make run and point it to the Postgres instance via env vars (see env.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, use env.docker (it points DATABASE_HOST to host.docker.internal). On Linux, make docker-run includes the required host mapping (--add-host=host.docker.internal:host-gateway).

Domain rules

  • Append-only operations: an item's state is tracked via Operation records. 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 Operation per item may be corrected, and only while its created_at is 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 as Item); 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 Device rows use the same cap on created_at when the row is referenced by live inventory data; unreferenced rows stay editable. Enforcement is shared via common.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), not updated_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 from ModelAdmin.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-latest Operation rows is not bypassed.
  • 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_at timestamp when INVENTORY_PENDING_TRANSFER_EXPIRATION_HOURS is positive (default: one week). After that moment the offer is no longer active (same rule as PendingTransfer.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 Responsible has no User, they cannot press "Accept" in the web UI. In that case the application accepts the offer immediately (appends the ownership Operation and sets accepted_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 Django User to the receiver Responsible before creating the offer.

Configuration

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 when DEBUG=0
    • ALLOWED_HOSTS: comma-separated list
    • CSRF_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: DEBUG when DEBUG=1, else INFO)
  • 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 get expires_at at creation. Set to 0 to disable automatic expiry unless set manually in the admin)
  • Email
    • EMAIL_BACKEND (default: common.email_backends.AsyncEmailBackend; use django.core.mail.backends.console.EmailBackend for local development)
    • EMAIL_HOST
    • EMAIL_PORT (default: 587)
    • EMAIL_USE_TLS (default: 1)
    • EMAIL_USE_SSL (default: 0)
    • EMAIL_HOST_USER
    • EMAIL_HOST_PASSWORD
    • EMAIL_TIMEOUT (default: 10 seconds)
    • DEFAULT_FROM_EMAIL (default: noreply@example.com)
    • SERVER_EMAIL (default: noreply@example.com)
    • EMAIL_SEND_ASYNC (default: 1; set to 0 to 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

Email notifications

The application sends transactional emails on the following events:

  • Operation saved (inventory.signals): when a new Operation is created or an existing head operation is updated, emails are sent to the newly assigned Responsible ("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 a User is linked to a Responsible, the user receives a “linked” email.
  • Responsible unlinked (catalogs.signals): when a User is removed from a Responsible, the former user receives an “unlinked” email.
  • Responsible updated (catalogs.signals): when a Responsible record changes without altering the linked User, 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.

Localization

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 compilemessages

Run checks

make all

Note: 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.

Inventory list query plans (PostgreSQL)

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.

Testing on PostgreSQL

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 format

Run tests (an HTML coverage report is produced in htmlcov/):

make test

Notes

  • Coverage 100%: we exclude typing-only lines (e.g. @overload, if TYPE_CHECKING:, ...) via coverage config in pyproject.toml.
  • Concurrency tests: inventory/tests/test_concurrency.py validates 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 test is intentionally disabled; use make test or make all instead.

About

Equipment inventory built on immutable operations, not mutable state. Transfer offers, row-level locking, access-controlled history.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages