Models & fields

Base model mixins for collision-free slugs and consistent __str__/__repr__ behaviour, metadata-carrying choices with a real-Enum escape hatch, Fernet-encrypted fields for at-rest secrets (with their non-goals stated loudly), and a native PostgreSQL enum field with hand-written migration operations.

Base models & slugs

django_utils.base_models gives Django models a more readable table naming convention and three small, composable behaviours: automatic __str__/__unicode__/__repr__ derived from a name field (NameMixin), automatic collision-free slugs derived from that same name (SlugMixin), and automatic created_at/updated_at timestamps (CreatedAtModelBase). ModelBase (via its metaclass) also renames the default table from Django's app_foobarobject to the more readable app_foo_bar_object, unless Meta.db_table is set explicitly. Combine what you need through the ready-made bases: NameModelBase, SlugModelBase, NameCreatedAtModelBase, and SlugCreatedAtModelBase.

from django_utils.base_models import SlugCreatedAtModelBase


class Article(SlugCreatedAtModelBase):
    pass


article = Article.objects.create(name='My First Post')
article.slug          # 'my-first-post'
str(article)           # 'My First Post'
repr(article)          # '<Article[1]: My First Post>'
Caveat: slugs are not unique at the database level by default

SlugMixin does not add a unique constraint on slug. Its nested Meta.unique_together is inert: SlugMixin is not itself a Model, and a concrete subclass such as SlugModelBase declares its own Meta, which does not inherit from this one. Models that need the slug enforced unique at the database level should declare unique=True on their own slug field.

get_unique_slug() probes via _base_manager, Django's documented unfiltered manager, rather than _default_manager. A model with a filtered default manager (soft-delete style: objects = ActiveManager()) still gets checked against every row in the table, not just the ones its default manager exposes. Otherwise two rows hidden from each other by the filter could silently collide onto the same slug.

The uniqueness check and the eventual insert are not atomic, so two concurrent saves can still race each other onto the same slug. Callers with high write concurrency on the same name should declare unique=True on their own slug field and handle the resulting IntegrityError.

API reference: SlugMixin, SlugCreatedAtModelBase, full module at django_utils package.

Choices

Django 3.0+ ships native models.TextChoices and models.IntegerChoices enums. django_utils.choices remains for the extra Choice metadata and dict-like access it provides.

from django_utils import choices


# For manually specifying the value (automatically detects `str`, `int` and `float`):
class Human(models.Model):
    class Gender(choices.Choices):
        MALE = 'm'
        FEMALE = 'f'
        OTHER = 'o'

    gender = models.CharField(max_length=1, choices=Gender)


# To define the values as `male` implicitly:
class Human(models.Model):
    class Gender(choices.Choices):
        MALE = choices.Choice()
        FEMALE = choices.Choice()
        OTHER = choices.Choice()

    gender = models.CharField(max_length=1, choices=Gender)


# Or explicitly define them
class Human(models.Model):
    class Gender(choices.Choices):
        MALE = choices.Choice('m', 'male')
        FEMALE = choices.Choice('f', 'female')
        OTHER = choices.Choice('o', 'other')

    gender = models.CharField(max_length=1, choices=Gender)

Choice also accepts arbitrary keyword metadata, reachable as attributes on the resolved choice. Give a choice a group and grouped() hands Django the nested structure it renders as <optgroup>:

class Status(choices.Choices):
    Active = choices.Choice('a', 'Active', color='green')
    Inactive = choices.Choice('i', 'Inactive', color='red')


Status.choices['a'].color  # 'green'


class Product(choices.Choices):
    Apple = choices.Choice('ap', 'Apple', group='Fruit')
    Carrot = choices.Choice('ca', 'Carrot', group='Vegetable')


field = models.CharField(max_length=2, choices=Product.choices.grouped())

Choices.as_enum() builds a real enum.Enum from the class without touching the original, so model fields keep using the raw-value class while application code gets isinstance/match support:

StatusEnum = Status.as_enum()
StatusEnum('a') is StatusEnum.Active  # True
Caveat: enum picklability and caching

as_enum() is memoised per class: repeated calls return the same enum class, so Status.as_enum() is Status.as_enum() and Status.as_enum().Active is Status.as_enum().Active both hold, which lets the enum be used as (or as part of) a dict/cache key. A subclass builds and caches its own enum rather than inheriting its parent's.

The returned class is built dynamically with its __module__ set to django_utils.choices rather than the caller's module, so its members are not picklable with the default pickle protocol. Pickling an enum member looks the class up by __module__ plus qualified name, which will not resolve back to a class that was never assigned a name in that module.

API reference: Choices, Choice.

Encrypted model fields

EncryptedCharField, EncryptedTextField and EncryptedJSONField store a Fernet-encrypted token in a plain TEXT column: encryption at rest for values you never need to query, sort or index by (an API key, a bank account number, free-text notes). All crypto goes through cryptography's Fernet/MultiFernet. Nothing is hand-rolled, and no hazmat primitives are touched directly. Requires the crypto extra: pip install "django-utils2[crypto]".

from django.db import models
from django_utils.crypto_fields import (
    EncryptedCharField,
    EncryptedJSONField,
    EncryptedTextField,
)


class Customer(models.Model):
    tax_id = EncryptedCharField(max_length=20)
    notes = EncryptedTextField(blank=True)
    payment_details = EncryptedJSONField(null=True, blank=True)

Keys live in settings.DJANGO_UTILS_FERNET_KEYS, a list of urlsafe-base64 32-byte keys (Fernet.generate_key()):

DJANGO_UTILS_FERNET_KEYS = [
    'the-current-key...',
    'the-previous-key...',  # still needed to decrypt old rows
]

Rotation story: the FIRST key encrypts, EVERY key is tried on decrypt. Rotate by prepending a new key and redeploying. Existing rows keep decrypting under the old key, now second in the list, and get re-encrypted under the new first key the next time each row is saved. There is no bulk re-encryption command here. Touch (.save()) the rows you want migrated on your own schedule, for example with ChunkedCommand. Once every row has been resaved, drop the old key from the list. Rows that were never resaved under it become undecryptable (ValidationError on read) the moment it is removed.

Importing django_utils.crypto_fields works without cryptography installed. Instantiating any of the three fields without it raises ImproperlyConfigured naming the install command. Since fields instantiate as part of executing a model's class body, a model that declares one of them fails at app-import/django.setup() time, not on first use. The whole app fails to boot, loudly and immediately, if the extra is missing.

EncryptedCharField's max_length validates the PLAINTEXT (a MaxLengthValidator, same as plain CharField). It never sizes the column, which stores the necessarily longer ciphertext instead. from_db_value decrypts eagerly, as each row is fetched. A token nothing in DJANGO_UTILS_FERNET_KEYS can decrypt raises ValidationError out of the fetch itself (.get(), .refresh_from_db(), iterating a queryset), not lazily on later attribute access.

Non-goals, loudly:

  • No queryable or searchable encryption. Fernet salts every encryption, so two rows with identical plaintext get different ciphertext, and even exact can never match at the database level. Every lookup except isnull raises NotImplementedError. Filter in Python after decrypting, or maintain a separate searchable hash column alongside the encrypted one. This includes this package's own admin features: a JSONFieldFilter or search_fields entry pointing at an encrypted field makes the changelist raise that same NotImplementedError at request time. A configuration error surfaced loudly, not a silent empty result.

  • Ordering is not blocked (Django offers no field-level hook for it), but order_by() on an encrypted field sorts by ciphertext, which is a meaningless order. Don't.

  • Encryption at rest only: anything that reads through the ORM sees plaintext. That includes this package's own ExportMixin, which streams decrypted values into the CSV/JSON download, and Django's dumpdata, which lands fixtures on disk in plaintext.

  • No per-field keys. One keyring (DJANGO_UTILS_FERNET_KEYS) for every encrypted field in the project.

  • No deterministic mode. If you need same-plaintext-same-ciphertext, this is the wrong tool. Deterministic encryption also reintroduces exactly the equality side-channel Fernet's salting exists to prevent.

API reference: EncryptedCharField, EncryptedTextField, EncryptedJSONField.

PostgreSQL ENUM field

django_utils.pg_enum.EnumField wires a Choices class straight to a CharField: choices and max_length are derived from it, and on PostgreSQL the column's real type is a native CREATE TYPE ... AS ENUM type instead of VARCHAR. The database itself then rejects a row that does not hold one of the declared values, on top of (not instead of) Django's own choice validation.

The column's real type is the native enum (CREATE TYPE ... AS ENUM). An INSERT/UPDATE with a value outside the declared set is rejected by the database itself, independent of and in addition to Django's own choices validation.

db_type() falls back to plain VARCHAR, so a model using EnumField stays portable. SQLite never sees a Postgres-specific type name, and only Django's own choices validation applies.

from django.db import models
from django_utils import choices, pg_enum


class OrderStatus(choices.Choices):
    Pending = choices.Choice('pending', 'Pending')
    Shipped = choices.Choice('shipped', 'Shipped')
    Delivered = choices.Choice('delivered', 'Delivered')


class Order(models.Model):
    status = pg_enum.EnumField(OrderStatus, default=OrderStatus.Pending)

enum_type defaults to the Choices class name in snake_case (OrderStatus -> 'order_status'). Pass it explicitly to use a different PostgreSQL type name.

Hand-written migration operations

The field never issues DDL for the enum type itself, because Django's makemigrations autodetector has no concept of "create this standalone database object first". Add explicit operations to a migration BY HAND instead. All three operation classes are DB-only (no model-state changes) and no-ops on every non-PostgreSQL vendor, so a migration using them still applies cleanly against SQLite or MySQL, just without the enum type's extra database-level integrity check.

CreateEnumType must run before the operation that adds a column using it, so it belongs in the same migration, ahead of AddField:

import myapp.models
from django.db import migrations
from django_utils import pg_enum


class Migration(migrations.Migration):
    dependencies = [...]

    operations = [
        pg_enum.CreateEnumType(
            'order_status', ['pending', 'shipped', 'delivered']
        ),
        migrations.AddField(
            model_name='order',
            name='status',
            field=pg_enum.EnumField(myapp.models.OrderStatus, default='pending'),
        ),
    ]

CreateEnumType reverses to DROP TYPE. DropEnumType is the inverse, and takes the same values so its reversal has something to recreate.

AddEnumValue sets atomic = False on itself, but that alone is not enough. Django's migration executor opens its schema editor, and with it the wrapping transaction, keyed on the Migration's atomic attribute (default True), before any operation's own atomic flag is consulted. An operation-level flag can only add extra wrapping inside an already-open transaction, never escape one. This is the same reason Django's own AddIndexConcurrently requires atomic = False on the Migration class, not just the operation. Skip it and AddEnumValue.database_forwards raises NotSupportedError instead of running somewhere it cannot safely run:

class Migration(migrations.Migration):
    dependencies = [...]
    atomic = False  # Required, see above. The operation's own
    # atomic = False cannot escape this Migration's transaction.

    operations = [
        pg_enum.AddEnumValue('order_status', 'cancelled'),
    ]

AddEnumValue is also irreversible. PostgreSQL has no DROP VALUE at all, on any version.

If you need to remove a value, recreate the type instead:

class Migration(migrations.Migration):
    dependencies = [...]

    operations = [
        pg_enum.CreateEnumType(
            'order_status_v2', ['pending', 'shipped', 'delivered']
        ),
        # Plain `migrations.AlterField` won't work here: Django's own
        # autogenerated `ALTER COLUMN ... TYPE` has no `USING` clause, and
        # PostgreSQL refuses to cast one enum type to another without one
        # ("column ... cannot be cast automatically"). `SeparateDatabaseAndState`
        # splits the state change (the ORM now expects `enum_type='order_status_v2'`)
        # from the database change (an explicit two-step cast through
        # `text`, since PostgreSQL has no direct enum-to-enum cast either).
        migrations.SeparateDatabaseAndState(
            database_operations=[
                migrations.RunSQL(
                    "ALTER TABLE myapp_order ALTER COLUMN status TYPE "
                    "order_status_v2 USING status::text::order_status_v2",
                    reverse_sql=(
                        "ALTER TABLE myapp_order ALTER COLUMN status TYPE "
                        "order_status USING status::text::order_status"
                    ),
                ),
            ],
            state_operations=[
                migrations.AlterField(
                    model_name='order',
                    name='status',
                    field=pg_enum.EnumField(
                        myapp.models.OrderStatus, enum_type='order_status_v2'
                    ),
                ),
            ],
        ),
        pg_enum.DropEnumType(
            'order_status', ['pending', 'shipped', 'delivered', 'cancelled']
        ),
    ]

API reference: EnumField, CreateEnumType, AddEnumValue, DropEnumType.