⚡ Django Admin, supercharged

SnapAdmin

Automatic, beautiful, production-ready Django Admin — zero boilerplate. REST & GraphQL, Elasticsearch, offline mode and GDPR retention, all from your model definitions.

What's New in v0.1.0a5 Error Monitoring & Email Alerts — one optional middleware records every unhandled exception / 5xx as a browsable ErrorEvent, emails a spike alert when the 15-minute error threshold is crossed and a daily grouped digest at a configurable time (Celery Beat or manage.py send_error_digest). Thresholds, window, recipients and retention are all settings; no emails are sent until you add recipients. Plus 3-2-1 database backups: scheduled gzip dumps shipped to a local directory, a network share and an offsite FTP/FTPS server — each destination with its own frequency and retention. Also: a backward-compatibility contract test suite now pins the public API surface for future releases. See Error Monitoring and 3-2-1 Backups.
🎯

Declarative Admin

Embed admin config in model fields. No ModelAdmin classes needed.

🌐

Auto REST API

Full CRUD API generated for every SnapModel automatically.

⚛️

Dynamic GraphQL

Unified GraphQL schema generation for all managed models.

🔑

Token Auth

Named tokens with expiry, model restrictions, and Django perms.

📊

Swagger UI

Interactive OpenAPI 3 documentation via drf-spectacular.

🔍

Elasticsearch

Integrated full-text search with automatic DB fallback.

🪵

Structured Logs

Colourised structlog for dev, JSON for production.

🐳

Docker Ready

One-command stack: Django + PostgreSQL + Redis + ES.

📦 Installation

SnapAdmin can be installed via PyPI or directly from source.

From PyPI

pip install django-snapadmin

From GitHub

pip install git+https://github.com/drofji/django-snapadmin.git

🌟 Running the Demo

The repository includes a complete sandbox project to explore all features.

Via Docker (Recommended)

git clone https://github.com/drofji/django-snapadmin.git
cd django-snapadmin
cp dist.env .env
docker compose up --build

With Traefik — Local Development (HTTP)

Access the app at http://snapadmin.localhost/ with a BasicAuth-protected dashboard:

docker compose -f docker-compose.yml -f docker-compose.traefik.local.yml up --build

# App:              http://snapadmin.localhost/admin/
# Traefik dashboard: http://traefik.localhost/  (admin / changeme)

On Windows, add to C:\Windows\System32\drivers\etc\hosts:

127.0.0.1 snapadmin.localhost traefik.localhost

With Traefik — Production (HTTPS + Let's Encrypt)

Automatic TLS certificates for your custom domain. Set in .env:

TRAEFIK_DOMAIN=admin.mycompany.com
TRAEFIK_ACME_EMAIL=your@email.com
TRAEFIK_DASHBOARD_CREDENTIALS=admin:$$apr1$$...   # see dist.env for generation
ALLOWED_HOSTS=admin.mycompany.com
DEBUG=False

Then start:

docker compose -f docker-compose.yml -f docker-compose.traefik.prod.yml up -d

# App:               https://admin.mycompany.com/admin/
# Traefik dashboard:  https://traefik.admin.mycompany.com/  (BasicAuth)
# HTTP → HTTPS redirect is automatic for all routes

Generating Dashboard Credentials

# Requires apache2-utils (apt) or httpd-tools (yum)
echo $(htpasswd -nb admin yourpassword) | sed -e 's/\$/\$\$/g'
# Paste result into TRAEFIK_DASHBOARD_CREDENTIALS in .env

Manual / Local Setup

git clone https://github.com/drofji/django-snapadmin.git
cd django-snapadmin
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pip install -e .

python manage.py migrate
python manage.py seed_demo
python manage.py runserver

Building images with automatic retention

For the test/demo image, scripts/docker_build.sh builds, tags by build-day, and self-prunes so old images never pile up:

scripts/docker_build.sh                              # image=snapadmin-test, keep 3 build-days
IMAGE=myimg scripts/docker_build.sh                  # custom image name
SNAPADMIN_IMAGE_KEEP_DAYS=5 scripts/docker_build.sh  # widen the window

Retention policyone build per day, keep the last N build-days (N defaults to 3, override via SNAPADMIN_IMAGE_KEEP_DAYS):

Worked example. Builds a month ago, a week ago, yesterday, and today leave exactly three images after today's build — one each for a week ago, yesterday, and today; the month-ago image and all superseded same-day builds are gone.

The pruner can also run standalone (e.g. in CI), with a dry-run mode:

python -m scripts.docker_retention prune --image snapadmin-test --dry-run

🏗 SnapModel

Inherit from SnapModel to get automatic admin registration and a smart __str__.

from snapadmin import models as snap_models, fields as snap

class Product(snap_models.SnapModel):
    name = snap.SnapCharField(max_length=200, searchable=True)
    price = snap.SnapDecimalField(max_digits=10, decimal_places=2, filterable=True)
    available = snap.SnapBooleanField(default=True)

📋 Snap Fields

Every Snap field is a drop-in replacement for the corresponding Django field, with extra admin control attributes.

FlagTypeDefaultEffect
show_in_listboolTrueAdds to list_display
show_in_formboolFalseShows field in change form
searchableboolFalseAdds to search_fields
filterableboolFalseSmart sidebar filter (type-aware)
editableboolTrueAlways read-only when False
updatableboolTrueRead-only after first save when False
rowstrNoneGroup fields into a horizontal row
tabstrNonePlace field into a specific Unfold tab
wysiwygboolFalseEnable CKEditor 5 for TextFields
autocompleteboolFalseSearchable autocomplete widget (relation fields)

Available field types

Every field type below maps 1:1 to a Django field (except the computed ones) and accepts all the flags above.

CategorySnap field types
TextSnapCharField, SnapTextField, SnapRichTextField, SnapSlugField, SnapEmailField, SnapURLField, SnapUUIDField, SnapGenericIPAddressField, SnapPhoneField, SnapColorField
NumbersSnapIntegerField, SnapPositiveIntegerField, SnapSmallIntegerField, SnapPositiveSmallIntegerField, SnapBigIntegerField, SnapPositiveBigIntegerField, SnapFloatField, SnapDecimalField
Date & timeSnapDateField, SnapDateTimeField, SnapTimeField, SnapDurationField
Boolean & JSONSnapBooleanField, SnapJSONField
FilesSnapFileField, SnapImageField
RelationsSnapForeignKey, SnapOneToOneField, SnapManyToManyField
Computed (no DB column)SnapFunctionField (render from a callable), SnapStatusBadgeField (coloured pill badge)
See them all live The demo app's Showcase model exercises every field type across tabbed sections, and SnapFunctionField/SnapOneToOneField appear on Showcase and CustomerProfile respectively.

📐 Advanced Layout

Control the visual arrangement of your admin forms using row and tab attributes.

class Customer(snap_models.SnapModel):
    # These two will appear on the same line
    first_name = snap.SnapCharField(max_length=100, row="name")
    last_name = snap.SnapCharField(max_length=100, row="name")

    # This field will appear in the "Contact" tab
    email = snap.SnapEmailField(tab="Contact")

🏷 Status Badges

Render coloured pill badges for any choice/status field directly in the admin list view.

from snapadmin import fields as snap

status_badge = snap.SnapStatusBadgeField(
    field_name="is_active",
    choices=[
        snap.SnapStatusBadgeFieldChoice(True, "#065F46", "#D1FAE5", "#10B981"),
        snap.SnapStatusBadgeFieldChoice(False, "#991B1B", "#FEE2E2", "#EF4444"),
    ]
)

🔧 Admin Registration

Call register_all_admins() once in your admin.py to register every SnapModel subclass with Django's admin site.

# admin.py
from snapadmin.models import SnapModel
SnapModel.register_all_admins()

To restrict registration to a specific app, pass the app_label argument:

SnapModel.register_all_admins(app_label="myapp")
Note The APIToken admin is also registered automatically when register_all_admins() is called.

🌐 REST API

CRUD endpoints are generated automatically for all managed models.

GET/api/models/schema/List all available endpoints
GET/api/models/{app}/{Model}/List objects (filters, search, pagination)
POST/api/models/{app}/{Model}/Create object
GET/api/models/{app}/{Model}/{id}/Retrieve object
PATCH/api/models/{app}/{Model}/{id}/Update object (PUT for full update)
DELETE/api/models/{app}/{Model}/{id}/Delete object

Request Examples

All requests authenticate with an API token (see Token Management):

TOKEN="your-40-char-token-key"
BASE="http://localhost:8000/api"

# Discover every available endpoint and its fields
curl -H "Authorization: Token $TOKEN" "$BASE/models/schema/"

# Plain listing — paginated, served by the database
curl -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?page=2"

# Auto-generated field filters (full per-model list visible in Swagger)
curl -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?available=true&price__gte=100"

# Create, update, delete
curl -X POST -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
     -d '{"name": "Laptop Pro", "price": "1499.00", "available": true}' \
     "$BASE/models/demo/Product/"
curl -X PATCH -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
     -d '{"available": false}' "$BASE/models/demo/Product/42/"
curl -X DELETE -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/42/"

Smart ES Query Routing — ?search=

?search= runs a full-text search over the model's searchable=True fields. For a DUAL-storage model — whose data is already mirrored in Elasticsearch — the very same request is executed on ES: fuzzy, typo-tolerant and relevance-ranked, with no change to the URL or your client code. Field filters and pagination still apply on top of the ES-ranked result:

# Product is DUAL → this search runs on Elasticsearch (typo still matches)
curl -i -H "Authorization: Token $TOKEN" "$BASE/models/demo/Product/?search=laptp"
# HTTP/1.1 200 OK
# X-Snap-Query-Backend: elasticsearch     ← the search ran on ES
# {"count": 3, "results": [{"id": 42, "name": "Laptop Pro", …}, …]}

# ES search combined with DB filters and pagination — still one request
curl -H "Authorization: Token $TOKEN" \
     "$BASE/models/demo/Product/?search=laptop&available=true&page=1"

# The same URL shape on a DB_ONLY model transparently uses SQL icontains
curl -i -H "Authorization: Token $TOKEN" "$BASE/models/demo/Customer/?search=7"
# X-Snap-Query-Backend: database          ← no ES mirror, the DB handled it

Routing decision per request:

Model mode?search= presentRouting enabledExecuted on
ES_ONLYanyElasticsearch (only source)
DUALyesyesElasticsearch (fuzzy multi_match, relevance order)
DUALyesnoDatabase (icontains over searchable fields)
DUALnoDatabase (native pagination, no ES round-trip)
DB_ONLYyesDatabase (icontains over searchable fields)

Every list response carries the X-Snap-Query-Backend: elasticsearch | database header, so you can always verify where a query ran — including the case where ES failed mid-request and the internal DB fallback answered (the header then honestly says database). Configuration:

# settings.py
SNAPADMIN_ES_QUERY_ROUTING     = True    # global switch (default True)
SNAPADMIN_ES_SEARCH_LIMIT      = 1000    # max hits fetched from ES per routed search
SNAPADMIN_QUERY_BACKEND_HEADER = True    # set False to hide the header in production

# models.py — per-model opt-out (e.g. when one model's ES mirror lags)
class Product(SnapModel):
    es_storage_mode  = EsStorageMode.DUAL
    es_query_routing = False   # this model's API searches always run on the DB

Hiding fields from the API — api_exclude_fields

Columns listed in api_exclude_fields are removed from the REST serializer (responses and writes), the GraphQL object type and /api/models/schema/ — while the admin keeps showing them. Use it for PII and internal columns:

class AuditLog(SnapModel):
    action     = snap_fields.SnapCharField(max_length=100, searchable=True)
    user_email = snap_fields.SnapEmailField()     # PII

    api_exclude_fields = ["user_email"]           # never leaves the server via API

⚛️ GraphQL API

SnapAdmin provides a dynamic GraphQL interface powered by Graphene-Django.

Request Examples

# Same API tokens as REST — anonymous callers get "Authentication required."
curl -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
     -d '{"query": "{ allDemoProducts(search: \"laptop\", first: 10) { id name price } }"}' \
     "http://localhost:8000/api/graphql/"

# Single object by id
curl -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
     -d '{"query": "{ demoProduct(id: 42) { name price available } }"}' \
     "http://localhost:8000/api/graphql/"
# settings.py
SNAPADMIN_GRAPHQL_REQUIRE_AUTH = True   # default — never disable in production
SNAPADMIN_GRAPHIQL_ENABLED     = DEBUG  # playground only during development
Upgrading from ≤ 0.1.0a3 GraphQL used to be open to anonymous callers. Since v0.1.0a4 every resolver enforces authentication and per-model permissions; clients must send the same Authorization: Token … header the REST API uses (or hold an admin session).

🔑 Token Management

SnapAdmin uses named API tokens for REST API authentication. Tokens are created and managed in the Django admin under API Tokens, or programmatically:

from snapadmin.models import APIToken

# Create a token for a user, valid for 30 days
token = APIToken.create_for_user(
    user=user,
    token_name="CI Pipeline",
    allowed_models=["myapp.Product", "myapp.Order"],
    expires_in_days=30,
)

Include the token in every API request as an HTTP header:

Authorization: Token <token_key>
Tokens are hashed at rest Only a SHA-256 digest and the 8-character token_prefix are stored — the raw key is never persisted. It is returned exactly once: in the POST /api/tokens/ response (and shown once in the admin when you create a token). Copy it then; it cannot be recovered later. On every subsequent read token_key is null and only token_prefix identifies the token.

Token REST Endpoints

GET/api/tokens/List tokens owned by the current user
POST/api/tokens/Create a new token
GET/api/tokens/{id}/Retrieve a token
DELETE/api/tokens/{id}/Revoke (delete) a token

Token Fields

FieldDescription
token_nameHuman-readable label (e.g. "Read-only dashboard")
token_key40-character secret key — treat like a password. Hashed at rest; returned only once, at creation
token_prefixFirst 8 characters of the key (not secret) — identifies a stored token in lists and the admin
allowed_modelsList of "app_label.ModelName" strings the token may target. Empty ≠ unrestricted: it means "any model the owning user already has Django permissions for". Token scope is always AND-ed with user.has_perm, so a non-empty list narrows access further
expiration_dateOptional expiry; leave blank for non-expiring tokens
is_activeDeactivate without deleting the token

🔍 Elasticsearch Integration

SnapAdmin supports three Elasticsearch storage modes via EsStorageMode. Choose the mode per model based on your use case.

Enable Elasticsearch

Add to your settings.py (or set via env vars):

ELASTICSEARCH_ENABLED = True   # False by default — ES is completely skipped when False
ELASTICSEARCH_URL = "http://localhost:9200"  # local dev or external cluster
Local dev without Docker Leave ELASTICSEARCH_ENABLED=False. All models fall back to DB_ONLY automatically — no ES process required.

Running with Docker

The default docker compose up does not start Elasticsearch (saves ~512 MB RAM). To enable it:

# Enable ES in your .env
ELASTICSEARCH_ENABLED=True

# Then start the full stack including ES
docker compose --profile es up --build

# Add Kibana for visualisation (dev only)
docker compose --profile es --profile dev up --build

External Elasticsearch (production / staging)

Point to any external ES cluster via env vars — no code change needed:

# .env (production)
ELASTICSEARCH_ENABLED=True
ELASTICSEARCH_URL=https://my-cluster.example.com:9200

Storage Modes

ModeStorageWhen to use
DB_ONLY PostgreSQL only Default. Standard Django ORM behavior. No ES dependency.
DUAL PostgreSQL + Elasticsearch Write to both DB and ES. Use es_search() for fast full-text search, DB for transactions.
ES_ONLY Elasticsearch only No DB table. Ideal for logs, events, or analytics data.

Setting the Mode on a Model

The fields you want searchable in Elasticsearch are declared in es_mapping — a dict of {field_name: ES mapping}. (There is no es_index_fields attribute.) For DUAL you may also set es_index_enabled = True.

from snapadmin.models import SnapModel, EsStorageMode
from snapadmin import fields as snap_fields

# DB_ONLY (default — no extra config needed)
class Article(SnapModel):
    title = snap_fields.SnapCharField(max_length=200, searchable=True)
    body  = snap_fields.SnapTextField()

# DUAL — save to PostgreSQL AND Elasticsearch, search via ES
class Product(SnapModel):
    name        = snap_fields.SnapCharField(max_length=200, searchable=True)
    description = snap_fields.SnapTextField()
    price       = snap_fields.SnapDecimalField(max_digits=10, decimal_places=2)

    es_index_enabled = True
    es_storage_mode  = EsStorageMode.DUAL
    es_mapping = {
        "name":        {"type": "text", "analyzer": "standard"},
        "description": {"type": "text"},
        "price":       {"type": "float"},
    }

# ES_ONLY — no DB table (managed=False), data lives only in Elasticsearch
class SearchLog(SnapModel):
    query         = snap_fields.SnapCharField(max_length=255, searchable=True)
    results_count = snap_fields.SnapIntegerField()
    timestamp     = models.DateTimeField(auto_now_add=True)

    es_storage_mode = EsStorageMode.ES_ONLY
    es_mapping = {
        "query":         {"type": "text"},
        "results_count": {"type": "integer"},
        "timestamp":     {"type": "date"},
    }

    class Meta:
        managed = False  # required for ES_ONLY — no DB table is created

Custom analyzers & index settings — es_index_settings

Index-level settings (custom analyzers under analysis, number_of_shards, number_of_replicas, …) are declared in es_index_settings and applied when the index is first created. Existing indexes are never altered — to apply a change, delete the index and run es_reindex_all():

class Product(SnapModel):
    es_storage_mode = EsStorageMode.DUAL
    es_mapping = {
        "name":  {"type": "text", "analyzer": "de_analyzer"},
        "price": {"type": "float"},
    }
    es_index_settings = {
        "analysis": {"analyzer": {"de_analyzer": {"type": "german"}}},
        "number_of_shards": 1,
    }

Automatic mapping — es_auto_mapping

Don't want to write mappings by hand? Set es_auto_mapping = True and the mapping is derived from the model's fields: CharField/TextFieldtext with a .raw keyword subfield (exact match + aggregations), Email/Slug/URL/UUID/IP/Filekeyword, integers & FK → long, Floatdouble, Decimalscaled_float, dates → date, JSONFieldobject. Entries in es_mapping override the derived ones per field:

class SearchLog(SnapModel):
    query         = snap_fields.SnapCharField(max_length=255, searchable=True)
    results_count = snap_fields.SnapIntegerField()

    es_storage_mode = EsStorageMode.ES_ONLY
    es_auto_mapping = True   # derived: query → text + .raw, results_count → long
    # es_mapping = {"query": {"type": "search_as_you_type"}}   # optional override
Searches are mapping-aware Full-text queries (es_search() and the routed REST ?search=) target only the text-capable fields of es_mapping and run with lenient: true, so a mapping that mixes numeric/date/boolean fields never breaks a search. ES failures that previously disappeared silently are logged as structlog warning events (es_ensure_index_failed, es_index_document_failed, es_search_failed, …).

es_search(query_string=None, limit=20) is the single entry point for search. It runs a fuzzy multi_match over the index when ES is enabled, and gracefully falls back to an ORM query (using the model's searchable fields) when ES is off or unreachable — so the same call works in every environment. snap_search() is a public alias with identical behavior.

# Full-text search (fuzzy, typo-tolerant) — returns a queryset-like result
results = Product.es_search("wireles headphones")     # note the typo — still matches
for product in results:
    print(product.name, product.price)

# Cap the number of hits (default 20)
top5 = Product.es_search("laptop", limit=5)

# No query string → match-all (most-recent first), handy for "browse" views
everything = Product.es_search(limit=100)

# Public alias — identical behavior, nicer name for app code
hits = Product.snap_search("4k monitor")

# ES_ONLY models: es_search() is the ONLY way to read them (no DB table)
logs = SearchLog.es_search("error 404")
recent_logs = SearchLog.es_search(limit=50)

# DB_ONLY models still answer es_search() — it falls back to an ORM
# icontains query across the model's searchable fields:
articles = Article.es_search("django")   # works even with ELASTICSEARCH_ENABLED=False
Return types For DB_ONLY/DUAL, es_search() returns a normal Django QuerySet (ES hits are mapped back to real DB rows, ES order preserved). For ES_ONLY it returns a lightweight EsQuerySet of model instances built straight from the index — iterable, sliceable, and countable, but not a DB queryset.
The REST API routes to ES automatically You rarely need to call es_search() yourself for API consumers: a plain GET /api/models/{app}/{Model}/?search=… on a DUAL model is routed to Elasticsearch automatically. See Smart ES Query Routing for examples, the routing matrix and the X-Snap-Query-Backend header.

Re-indexing (DUAL mode)

# Manually trigger a full re-index of every row into ES.
# Streams the table through the bulk API — one round-trip per 500 docs
# (tune with chunk_size=), flat memory even on million-row tables:
Product.es_reindex_all()      # {"indexed": N} — or {"skipped": True} if ES is off

# Per-instance indexing happens automatically on save() in DUAL/ES_ONLY mode.
# To force a single object back into the index:
product.index_in_es()
product.delete_from_es()      # remove just this object's ES document

# Via Celery task (recommended for large datasets):
from demo.tasks import reindex_products_to_elasticsearch
reindex_products_to_elasticsearch.delay()

⚙️ Celery & Periodic Tasks

SnapAdmin ships built-in Celery tasks for background indexing, token cleanup, and GDPR data retention. Wire them up with Celery Beat to get a fully automated backend in minutes.

Built-in Tasks

TaskDefault ScheduleDescription
api.tasks.purge_expired_tokensDaily 3amDelete all APIToken records past their expiration date
api.tasks.purge_expired_dataDaily 1amGDPR — auto-delete records older than data_retention_days on each SnapModel
demo.tasks.reindex_products_to_elasticsearchDaily midnightRe-index all products to Elasticsearch (DUAL mode demo)
demo.tasks.generate_daily_statsDaily 2amCompute and log daily business metrics (demo app)

Celery Setup

# myproject/celery.py
from celery import Celery

app = Celery("myproject")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

Schedule All Tasks (Celery Beat)

Add the following to settings.py to activate all background tasks. The dashboard will display them automatically.

from celery.schedules import crontab

CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND = "redis://localhost:6379/0"

CELERY_BEAT_SCHEDULE = {
    "reindex-products-to-es": {
        "task": "demo.tasks.reindex_products_to_elasticsearch",
        "schedule": crontab(hour=0, minute=0),   # daily midnight
        "description": "Sync Product records (DUAL mode) to Elasticsearch",
    },
    "purge-expired-data": {
        "task": "api.tasks.purge_expired_data",
        "schedule": crontab(hour=1, minute=0),   # daily 1am
        "description": "GDPR — delete records older than data_retention_days",
    },
    "generate-daily-stats": {
        "task": "demo.tasks.generate_daily_stats",
        "schedule": crontab(hour=2, minute=0),   # daily 2am
        "description": "Daily business stats",
    },
    "purge-expired-tokens": {
        "task": "api.tasks.purge_expired_tokens",
        "schedule": crontab(hour=3, minute=0),   # daily 3am
        "description": "Remove expired API tokens",
    },
}
Dashboard integration Every entry in CELERY_BEAT_SCHEDULE that includes a "description" key is automatically shown in the SnapAdmin Dashboard under Scheduled Cron Jobs.

Running Celery in Docker

# docker-compose.yml services:
worker:
  command: celery -A sandbox worker -l INFO
beat:
  command: celery -A sandbox beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler

🪵 Structured Logging

SnapAdmin provides a structlog-based logging setup with colourised output for development and JSON output for production.

Activate in settings.py

from snapadmin.logging_config import configure_logging

# Human-readable coloured output (development)
configure_logging(log_level="INFO", json_logs=False)

# JSON lines (production / Docker)
configure_logging(log_level="WARNING", json_logs=True)

Using the logger in your code

from snapadmin.logging_config import get_logger

logger = get_logger(__name__)

logger.info("order_created", order_id=42, total=199.90)
logger.warning("es_unavailable", model="Product")
logger.error("payment_failed", reason="card_declined")
Log levels Supported values for log_level: DEBUG, INFO, WARNING, ERROR, CRITICAL. Noisy third-party loggers (django.db.backends, elasticsearch, urllib3) are silenced to WARNING automatically.

🔒 GDPR Data Retention

SnapAdmin provides one-line automatic record cleanup to help comply with EU data retention laws (GDPR). Add two class attributes to any SnapModel:

class AuditLog(SnapModel):
    action     = SnapCharField(max_length=100)
    created_at = SnapDateTimeField(auto_now_add=True)

    # Auto-delete records older than 90 days
    data_retention_days  = 90
    data_retention_field = "created_at"  # default — any DateTimeField works

How it works

AttributeTypeDefaultDescription
data_retention_daysint | NoneNoneMax age of records in days. Set to a positive integer to enable auto-deletion.
data_retention_fieldstr"created_at"Name of the DateTimeField to measure record age against.

Running the cleanup

# Via Celery Beat (recommended — add to CELERY_BEAT_SCHEDULE):
"purge-expired-data": {
    "task": "api.tasks.purge_expired_data",
    "schedule": crontab(hour=1, minute=0),
}

# Manually via management command:
python manage.py purge_expired_data           # live run — deletes records
python manage.py purge_expired_data --dry-run  # preview only — no deletes

# Programmatically, per model (returns the number purged):
AuditLog.purge_expired()              # delete now
AuditLog.purge_expired(dry_run=True)  # count what *would* be deleted, delete nothing

Purging across every storage layer

The purge is storage-aware — it removes expired records from wherever the model actually keeps them, so personal data never lingers in a secondary store:

ModeWhat gets purged
DB_ONLYBulk delete from the database.
DUALBulk delete from the database and the mirrored Elasticsearch documents (the pks are collected before the DB delete, then cleared from the index).
ES_ONLYA range delete_by_query against the index on data_retention_field (which must be mapped as a date in es_mapping).
Why this matters for GDPR A plain QuerySet.delete() never calls each model's delete(), so a naïve bulk purge would leave the Elasticsearch copy behind. SnapAdmin's purge_expired() closes that gap for both DUAL and ES_ONLY models. ES operations are best-effort and require ELASTICSEARCH_ENABLED=True.

🚨 Error Monitoring & Email Alerts

Optional email notifications about server errors, built on one middleware. Every unhandled exception and 5xx response is stored as an ErrorEvent — browsable in the admin under Error Events — and two email channels keep the team informed:

Prerequisite: working SMTP Delivery uses Django's standard email machinery — configure EMAIL_BACKEND, EMAIL_HOST, credentials and DEFAULT_FROM_EMAIL. While the recipient lists are empty no email is ever sent, so the feature is inert until you opt in.

Setup

# settings.py
MIDDLEWARE = [
    # ... Django middleware ...
    "snapadmin.middleware.SnapErrorMonitorMiddleware",
]

# Spike alert (defaults shown)
SNAPADMIN_ERROR_ALERT_THRESHOLD = 20          # errors ...
SNAPADMIN_ERROR_ALERT_WINDOW_MINUTES = 15     # ... within this window → email
SNAPADMIN_ERROR_ALERT_EMAILS = ["ops@example.com"]

# Daily digest
SNAPADMIN_ERROR_DIGEST_EMAILS = ["team@example.com"]  # falls back to ALERT_EMAILS
SNAPADMIN_ERROR_DIGEST_MAX_GROUPS = 20
SNAPADMIN_ERROR_RETENTION_DAYS = 30           # ErrorEvents older than this are purged

Scheduling the digest

# Celery Beat — the send time is entirely yours:
"send-error-digest": {
    "task": "api.tasks.send_error_digest",
    "schedule": crontab(hour=8, minute=0),
}

# ...or plain cron, without Celery:
0 8 * * *  python manage.py send_error_digest
python manage.py send_error_digest --hours 12   # custom window

Settings reference

SettingDefaultDescription
SNAPADMIN_ERROR_MONITOR_ENABLEDTrueMaster kill-switch — disable recording without touching MIDDLEWARE.
SNAPADMIN_ERROR_ALERT_ENABLEDTrueEnable the spike alert channel.
SNAPADMIN_ERROR_ALERT_THRESHOLD20Errors within the window that trigger the alert.
SNAPADMIN_ERROR_ALERT_WINDOW_MINUTES15Rolling window for the spike alert.
SNAPADMIN_ERROR_ALERT_COOLDOWN_MINUTES= windowMinimum gap between two alert emails.
SNAPADMIN_ERROR_ALERT_EMAILS[]Alert recipients. Empty = channel off.
SNAPADMIN_ERROR_DIGEST_ENABLEDTrueEnable the daily digest channel.
SNAPADMIN_ERROR_DIGEST_EMAILS[]Digest recipients; falls back to the alert list.
SNAPADMIN_ERROR_DIGEST_MAX_GROUPS20Cap on distinct error groups per digest email.
SNAPADMIN_ERROR_RETENTION_DAYS30ErrorEvent rows older than this are purged by the digest task.
Fail-safe by design Storage or SMTP failures are logged (error_monitor_record_failed, error_monitor_alert_failed) and swallowed — a broken mail server never breaks a page. Try it in the demo: hit /demo/error/ a few times (DEBUG only) and watch Error Events fill up; with the default DEBUG console email backend the alert lands in your terminal.

💾 3-2-1 Database Backups

Built-in scheduled backups following the classic 3-2-1 rule3 copies of your data, on 2 different machines, 1 of them offsite. Dumps are gzip-compressed (file copy for SQLite, pg_dump for PostgreSQL) and shipped to up to three destinations, each on its own schedule:

CopyDestinationWhere it livesDefault frequency
1localDirectory on the same server (SNAPADMIN_BACKUP_LOCAL_DIR)every 24 h
2networkDirectory on another server on your network — a mounted NFS/SMB share (SNAPADMIN_BACKUP_NETWORK_DIR; empty = off)every 24 h
3remoteOffsite server anywhere in the world, via FTP/FTPS (SNAPADMIN_BACKUP_FTP_*; empty host = off)every 168 h (weekly)

Configuration

SNAPADMIN_BACKUP_ENABLED = True               # strictly opt-in (default: False)
SNAPADMIN_BACKUP_KEEP = 7                     # dumps kept per destination (oldest pruned)

# Copy 1 — same server
SNAPADMIN_BACKUP_LOCAL_DIR = "/var/backups/snapadmin"
SNAPADMIN_BACKUP_LOCAL_EVERY_HOURS = 24       # daily

# Copy 2 — server on the same network (mounted share)
SNAPADMIN_BACKUP_NETWORK_DIR = "/mnt/backup-server/snapadmin"
SNAPADMIN_BACKUP_NETWORK_EVERY_HOURS = 24     # daily

# Copy 3 — offsite FTP/FTPS
SNAPADMIN_BACKUP_FTP_HOST = "backup.example.com"
SNAPADMIN_BACKUP_FTP_PORT = 21
SNAPADMIN_BACKUP_FTP_USER = "backup"
SNAPADMIN_BACKUP_FTP_PASSWORD = "secret"
SNAPADMIN_BACKUP_FTP_DIR = "/snapadmin"
SNAPADMIN_BACKUP_FTP_TLS = True               # FTPS — recommended offsite
SNAPADMIN_BACKUP_REMOTE_EVERY_HOURS = 168     # weekly

Running the backups

Backups run as a separate process from your web workers — via Celery Beat or cron. The hourly Beat task is only a due-check: each destination actually fires when its own *_EVERY_HOURS interval has elapsed (last-run times persist in a state file, surviving restarts):

# Celery Beat:
"run-db-backups": {
    "task": "api.tasks.run_db_backups",
    "schedule": crontab(minute=30),   # hourly due-check
}

# ...or plain cron, without Celery:
30 * * * *  python manage.py db_backup            # ships only what is due
python manage.py db_backup --force                # all configured destinations, now
python manage.py db_backup --destination remote   # one destination, now
Failure isolation & retention A failed destination (unreachable share, FTP down) is logged (db_backup_store_failed) and reported, but never cancels the other copies — and it stays due, so it is retried on the next pass. Every destination, including the FTP server, keeps only the newest SNAPADMIN_BACKUP_KEEP dumps.

📴 Offline Mode

Flip one class attribute to make a model's admin list view survive a dropped connection. SnapAdmin injects snapadmin/js/offline.js into that model's admin pages only — models without the flag ship no extra JavaScript.

class Customer(SnapModel):
    first_name = SnapCharField(max_length=100, show_in_form=True)
    last_name  = SnapCharField(max_length=100, show_in_form=True)

    # Cache this model's list view client-side and enable offline support
    offline_mode = True
    # Prefetch only the 50 most-recent rows for offline view (default: 100)
    offline_cache_limit = 50

What it does (offline-capable models)

BehaviorDetail
Prefetch & cachePulls the most-recent offline_cache_limit rows (default 100) from GET /api/offline-data/<app>/<model>/ into the browser's IndexedDB on every visit. The rendered list is kept as a fallback snapshot.
Saved-objects panelWhen the backend is unreachable, repaints the list from cache and shows a panel: how many objects are cached (out of the limit), when they were cached, and how many changes are queued.
Reconnect syncQueues mutations made while offline and replays them when the backend returns, then refreshes the cache and shows a "synced N changes" toast.

Real backend health checks

Connectivity is decided by whether the Django backend actually answers, not by the OS network flag — a laptop can hold a Wi-Fi link while the server is down or the VPN dropped. A lightweight connectivity.js loads on all SnapModel admin pages:

BehaviorDetail
Health pollingPolls GET /api/health/ every 15s by default (override via window.SNAPADMIN_HEALTH_INTERVAL) with a short timeout, and re-checks immediately on online/offline events and tab refocus. The backend is "up" only when it responds.
Shared statePublishes one resolved state as a snapadmin:connectivity DOM event, so the connectivity layer and the per-model engine always agree.
Dynamic toastsBackend-lost / restored, "objects can't be shown right now" (non-cached pages), and "synced N changes" surface as auto-dismissing toasts — no static banners.
Save guardOn a non-offline model, an unreachable backend blocks form submission and disables the Save buttons until it returns, while leaving the already-rendered page intact.
Sidebar badgesEach model link gets a green sync icon (spins while the backend is down) if it is offline-capable, or a muted no-offline icon otherwise — so you can see at a glance which records survive a disconnect.

The badge list and per-model cache limits come from GET /api/offline-models/ (authenticated), with a localStorage fallback so badges still render while offline.

Zero configuration No settings, migrations, or extra dependencies. The offline_mode flag is purely client-side and gated per model; non-offline models still get the connectivity warnings but ship no caching JS.

🎨 Theming & Styles

SnapAdmin ships its admin styling as two layers, so an install that doesn't use Unfold is never forced to adopt Unfold's assumptions.

StylesheetScopeWhen it loads
snapadmin/css/admin.css Theme-agnostic core — field sizing, Select2 widgets, action bar, and the shared :root design tokens Always, on every SnapModel admin page
snapadmin/css/admin-unfold.css Unfold-specific overrides — all .unfold-scoped rules, dark-mode borders, and the Add-button visibility fix Only when django-unfold is installed

The Unfold layer is opt-in. SnapAdmin appends it automatically — after the core sheet, so its .unfold rules win the cascade — only when Unfold is detected. A plain Django admin install gets the core sheet alone. The shared design tokens (--primary-color, --radius, …) are defined once in the core sheet and referenced by both layers.

No flag to set The split keys off whether Unfold is importable, so there is nothing to configure — drop Unfold from INSTALLED_APPS and the Unfold overrides simply stop loading.

⚡ Large-Dataset Performance

SnapAdmin keeps the admin and API responsive as tables grow. Most tuning is automatic; the rest is a few per-model knobs.

Automatic — no admin N+1

When a model is registered, SnapAdmin inspects the columns shown in the list view and auto-derives list_select_related from the ForeignKey columns among them. A list view that renders a related column — or a __str__ that walks a relation — issues one joined query instead of one query per row. Only the FKs you actually display are joined; relations you don't show are never fetched.

The auto-generated REST API applies the same treatment to its querysets — select_related() for ForeignKeys and prefetch_related() for many-to-many fields — with the field lists cached per model to keep introspection out of the request hot path.

Per-model knobs

Override these class attributes on any SnapModel to tune the admin list view:

class AuditLog(snap_models.SnapModel):
    action = snap.SnapCharField(max_length=100, searchable=True)

    list_per_page = 50              # rows per page (default 100)
    list_max_show_all = 200         # cap on the "Show all" link
    show_full_result_count = False  # skip the unfiltered COUNT(*) on huge tables
AttributeDefaultWhen to change it
list_per_page 100 Lower it for wide rows or heavy list templates.
list_max_show_all 200 Guards against a "Show all" rendering a million-row table.
show_full_result_count True Set False on very large tables — the admin then skips the second, unfiltered COUNT(*) it runs to display the grand total, which is often the single most expensive query on the page.
REST pagination is on by default The REST API paginates with PageNumberPagination (PAGE_SIZE = 25), so large collections are never serialized in one response. Tune it via the REST_FRAMEWORK setting.

Offloading search to Elasticsearch

For DUAL and ES_ONLY models the REST list endpoint serves results directly from Elasticsearch (es_search) rather than the database, moving full-text search and large-result pagination off the primary database. See Elasticsearch Storage Modes.

Benchmarking at scale

Two demo management commands let you reproduce these numbers on your own hardware:

# Bulk-seed 100k customers + orders (batched bulk_create, flat memory)
python manage.py seed_large --count 100000

# Time the Order changelist queryset with vs without list_select_related
python manage.py benchmark_list_view --model order

benchmark_list_view iterates the changelist queryset and touches each row's ForeignKey, so the unoptimized run pays the full N+1 cost while the optimized run issues one joined query. Representative output on a seeded table (5,000 orders, SQLite):

📊  Result
   WITHOUT :    5,001 queries       584.5 ms
   WITH    :        1 queries        37.8 ms

   Query reduction : 5,001 → 1  (5001× fewer)
   Speedup         : 15.5× faster wall time

The unoptimized query count scales linearly with row count (N + 1); the optimized path stays flat at 1 — exactly the N+1 elimination list_select_related provides. See the broader Optimizations guide for the underlying data-access patterns.

🚀 Optimizations Guide

The Large-Dataset Performance section above is the reference for SnapAdmin's specific knobs. This guide is the broader picture: the data-access patterns every developer working with large tables should understand, whether or not they use SnapAdmin. Most of it is plain Django/SQL; the SnapAdmin-specific automation is called out where it applies.

1. Query optimization for large tables

The cheapest query is the one you never run, and the cheapest row is the one you never fetch. A few tools cover most cases:

ToolUse it whenWhat it does
select_related(...) Following a ForeignKey / OneToOne (to-one) relation. Pulls the related row in the same query via a SQL JOIN. One query total.
prefetch_related(...) Following a ManyToMany or reverse FK (to-many) relation. A second query for the related set, joined in Python. Two queries total, not N+1.
only(...) / defer(...) Rows are wide (big text/JSON columns) but you render few columns. Fetches/skips specific columns. Caution: touching a deferred column triggers a fresh per-row query — an N+1 in disguise.
values() / values_list() You need raw data (export, aggregation), not model instances. Returns dicts/tuples, skipping model construction overhead entirely.
exists() You only need to know whether rows match. Emits SELECT 1 ... LIMIT 1 — far cheaper than count() or truthiness on the queryset.
count() You need the number, not the rows. A single COUNT(*) — but on huge tables even this is expensive (see below).

Indexing. Any column you filter or sort on at scale should have a database index (db_index=True, or Meta.indexes for composite/partial indexes). Without one, the database scans the whole table for every query. The flip side: indexes cost write time and storage, so index the columns you actually query, not every column.

Pagination. Offset pagination (LIMIT 50 OFFSET 1000000) makes the database walk and discard every skipped row — page 20,000 is slow even with an index. Keyset (cursor) paginationWHERE id > :last_seen_id ORDER BY id LIMIT 50 — stays constant-time at any depth because it seeks straight to the next page via the index. Prefer it for deep, infinite-scroll, or API pagination over very large tables.

Skip the grand-total COUNT. Django's admin runs a second, unfiltered COUNT(*) just to show "X total" — frequently the most expensive query on the page. Set show_full_result_count = False on a SnapModel to drop it on huge tables.

2. The N+1 problem

The classic performance trap: you fetch a list of N objects in 1 query, then access a related object on each one, firing N more queries — N + 1 total. At 50 rows it's invisible in dev; at 50,000 rows it melts the database.

# N+1: one query for orders, then one per order for the customer
for order in Order.objects.all():        # 1 query
    print(order.customer.first_name)     # +1 query EACH iteration

# Fixed: a single JOIN pulls customers alongside orders
for order in Order.objects.select_related("customer"):   # 1 query, total
    print(order.customer.first_name)

How to spot it: count queries. In tests, django.test.utils.CaptureQueriesContext (or assertNumQueries); in development, django-debug-toolbar; in code, len(connection.queries) with DEBUG=True.

SnapAdmin eliminates the admin N+1 automatically. register_admin() inspects the FK columns shown in the list view and auto-derives list_select_related, so changelist pages issue one joined query instead of one per row. The benchmark above shows it: 5,001 queries → 1. Run python manage.py benchmark_list_view to see it on your own data.

3. SQL vs NoSQL — when to offload to Elasticsearch

A relational table is the right home for most data: it gives you transactions, joins, foreign-key integrity, and ad-hoc queries. Reach for a search engine like Elasticsearch when the access pattern outgrows what SQL does cheaply:

Keep it in PostgreSQL when…Offload to Elasticsearch when…
You need ACID transactions and referential integrity. You need fast full-text / fuzzy / relevance-ranked search across large text.
Queries are structured (filter/sort on indexed columns, joins). Faceted search, aggregations, and autocomplete over millions of rows.
The table is the system of record. Read volume and query complexity would overwhelm the primary DB.

SnapAdmin makes this a per-model setting via es_storage_mode — see Elasticsearch Storage Modes:

4. Denormalization & data duplication

Normalization removes redundancy so each fact lives in exactly one place — great for write integrity, but it pushes joins onto every read. Denormalization deliberately duplicates data to make reads cheap, trading storage and write complexity for read speed.

DUAL mode is the worked example: PostgreSQL stays the normalized source of truth, while a denormalized, query-optimized copy lives in Elasticsearch for fast search. The cost is consistency — the two stores can drift, so the copy must be kept in sync (SnapAdmin re-indexes on save and via a nightly Celery task; see Celery & Periodic Tasks).

Related patterns:

The rule of thumb: normalize until reads hurt, then denormalize the specific hot path — and own the cache-invalidation/consistency cost you just took on. Don't denormalize speculatively.

5. Practical checklist & anti-patterns

Before shipping a view over a large table, check:

Common anti-patterns to avoid:

Related reading: Large-Dataset Performance (SnapAdmin's knobs) and Elasticsearch Storage Modes (the SQL/NoSQL split).

📦 Demo App — Model Overview

The demo/ app contains pre-built models that showcase every SnapAdmin feature. Each model uses a different Elasticsearch storage mode.

ModelES ModeDemonstrates
Category, Tag DB_ONLY Simple lookup tables, ForeignKey and M2M relations
Product DUAL Nightly Celery re-index, full-text ES search with DB fallback, status badges
Customer, Order DB_ONLY Relational data, ForeignKey with autocomplete, range filters
SearchLog ES_ONLY ES-only model (managed=False), high-frequency write pattern
AuditLog DB_ONLY GDPR retention: data_retention_days=90
Showcase DB_ONLY All 30 field types in one model, tabs, rows, WYSIWYG, phone, color

🌱 Seed Command

The seed_demo management command populates the database with realistic demo data in seconds.

python manage.py seed_demo          # default: 20 products, 10 customers, 5 orders
python manage.py seed_demo --no-index  # skip ES indexing (no ES cluster needed)

The command is idempotent — running it multiple times adds more data without duplicating existing records.

🔄 Migration Guide: drofji-automatically-django-admin → SnapAdmin

The legacy package drofji-automatically-django-admin (import root drofji_autoadmin, last tag v1.1.0) is being retired and its repository removed. SnapAdmin is its direct successor — same declarative, field-driven admin, now renamed and extended with a REST API, GraphQL, the Unfold theme, Elasticsearch, GDPR retention and offline mode. The underlying Django field types are unchanged, so this is a rename + settings swap, not a data migration.

No data migration SnapAdmin does not alter your existing model tables. The only new table is snapadmin_apitoken (token auth). Your data stays exactly where it is.

1. Swap the package

pip uninstall drofji-automatically-django-admin
pip install drofji-snapadmin
# or pin the repo:
pip install git+https://github.com/drofji/django-snapadmin.git

If you pinned the old GitHub URL in requirements.txt, replace that line with drofji-snapadmin.

2. Rename the import root (drofji_autoadminsnapadmin)

# Before
from drofji_autoadmin import models as drofji_models, fields as drofji_fields
from drofji_autoadmin import validators

# After
from snapadmin import models as snap_models, fields as snap_fields
from snapadmin import validators

One-shot, repo-wide:

grep -rl drofji_autoadmin . | xargs sed -i '' 's/drofji_autoadmin/snapadmin/g'

3. Rename the base class & fields (AutoAdmin*Snap*)

# Before
class Product(drofji_models.AutoAdminModel):
    name = drofji_fields.AutoAdminCharField(max_length=200, searchable=True)

# After
class Product(snap_models.SnapModel):
    name = snap_fields.SnapCharField(max_length=200, searchable=True)

All field flags (show_in_list, searchable, filterable, row, tab, …) and the per-model admin_overrides dict keep the same names and behaviour. Mechanical replace:

grep -rl AutoAdmin . | xargs sed -i '' 's/AutoAdmin/Snap/g'
# AutoAdminModel→SnapModel, AutoAdminCharField→SnapCharField, AutoAdminFunctionField→SnapFunctionField, …

4. Swap the theme in INSTALLED_APPS

The old package themed the admin with admin_interface + colorfield. SnapAdmin uses Unfold. Remove the old theme apps (leaving them alongside Unfold causes conflicting admin overrides) and add the new stack — order matters, Unfold and its contrib apps must precede django.contrib.admin:

INSTALLED_APPS = [
-   "admin_interface",
-   "colorfield",
+   "unfold",
+   "unfold.contrib.filters",
+   "unfold.contrib.forms",
+   "unfold.contrib.inlines",
+   "django_ckeditor_5",
    "django.contrib.admin",
    "django.contrib.auth",
    # … other django.contrib.* …
    "rangefilter",            # keep — SnapAdmin still uses it
-   "drofji_autoadmin",
+   "rest_framework",
+   "drf_spectacular",
+   "django_filters",
+   "graphene_django",
+   "snapadmin",
    # your apps …
]

5. Register the admin explicitly (new requirement)

The old package auto-registered models purely by inheritance. SnapAdmin needs one explicit call — add it to your app's admin.py, or your models won't appear in the admin:

# admin.py
from snapadmin.models import SnapModel
SnapModel.register_all_admins()

6. Replace color fields

If you used ColorField from django-colorfield, switch to snap_fields.SnapColorField (validates #RRGGBB / #RGB).

7. (Optional) Wire up the new APIs

SnapAdmin ships a REST API, GraphQL endpoint and Swagger UI — features the old package did not have. Include the routes only if you want them:

# urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("snapadmin.urls")),   # /api/, /api/docs/, /graphql/
]

# settings.py — all default True
SNAPADMIN_REST_API_ENABLED = True
SNAPADMIN_GRAPHQL_ENABLED  = True
SNAPADMIN_SWAGGER_ENABLED  = True
ELASTICSEARCH_ENABLED      = False   # opt-in; needs the [elasticsearch] extra
SNAPADMIN_ES_QUERY_ROUTING = True    # route ?search= on DUAL models to ES
SNAPADMIN_ES_SEARCH_LIMIT  = 1000    # max ES hits per routed search
SNAPADMIN_GRAPHQL_REQUIRE_AUTH = True  # auth + perms on every GraphQL resolver
SNAPADMIN_GRAPHIQL_ENABLED = DEBUG     # GraphiQL playground — dev only

8. Migrate & collect static

python manage.py migrate          # creates only snapadmin_apitoken
python manage.py collectstatic    # if you serve static yourself
Don't run both packages at once Keeping drofji_autoadmin installed and in INSTALLED_APPS alongside SnapAdmin makes both try to register the admin → AlreadyRegistered. Fully uninstall the old package and remove it from INSTALLED_APPS before enabling SnapAdmin.

🔄 Migration Guide: v0.0.x → v0.1.x

Version 0.1.0 is a significant rewrite. If you are upgrading from an older version, follow these steps.

1. Update the package

pip install --upgrade django-snapadmin

2. Replace model field imports

# Before (v0.0.x)
from snapadmin.fields import CharField, IntegerField  # bare re-exports

# After (v0.1.x)
from snapadmin import fields as snap
name = snap.SnapCharField(max_length=200, searchable=True, show_in_list=True)

3. Update SnapModel usage

# Before (v0.0.x)
from snapadmin.models import BaseModel
class Product(BaseModel): ...

# After (v0.1.x)
from snapadmin.models import SnapModel
class Product(SnapModel): ...

4. Admin registration

# After (v0.1.x) — add to your app's admin.py:
from snapadmin.models import SnapModel
SnapModel.register_all_admins()  # registers all SnapModel subclasses

5. New settings

# settings.py additions for v0.1.x:
SNAPADMIN_REST_API_ENABLED = True    # default True
SNAPADMIN_GRAPHQL_ENABLED  = True    # default True
SNAPADMIN_SWAGGER_ENABLED  = True    # default True
ELASTICSEARCH_ENABLED      = False   # default False — enable only with ES cluster
SNAPADMIN_ES_QUERY_ROUTING = True    # default True — ?search= on DUAL models → ES
SNAPADMIN_ES_SEARCH_LIMIT  = 1000    # default 1000 — max ES hits per routed search
No data migration needed SnapAdmin does not add columns or constraints to your existing model tables. The only new table is snapadmin_apitoken (for the token auth feature). Run python manage.py migrate after upgrading.
SnapAdmin v0.1.0a5 — MIT License — GitHub