Metadata-Version: 2.4
Name: ftw-django-features
Version: 2026.5.0.dev0
Summary: A collection of features used in our Django-based web applications.
License-File: LICENSE
Author: 4teamwork AG
Requires-Python: >=3.12,<4
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Dist: django (>=4.2,<6)
Requires-Dist: django-click (>=2,<3)
Requires-Dist: django-configurations (>=2,<3)
Requires-Dist: django-constance (>=4,<5)
Requires-Dist: django-extensions (>=4,<5)
Requires-Dist: django-filter (>=25,<26)
Requires-Dist: django-linear-migrations (>=2,<3)
Requires-Dist: django-modeltranslation (>=0.19,<0.20)
Requires-Dist: djangorestframework (>=3,<4)
Requires-Dist: pluck (>=0.2,<0.3)
Requires-Dist: python-dotenv (>=1,<2)
Requires-Dist: pytz (>=2025.2,<2026.0)
Description-Content-Type: text/markdown

# django-features
A collection of fearures used in our Django-based web applications

[Changelog](CHANGELOG.md)

# Installation

``` bash
pip install ftw-django-features
```

# Usage

Add desired app to `INSTALLED_APPS` in your Django project.

Available apps:
```
django_features.system_message
django_features.custom_fields
```

# Configuration

If you want to use `django_features`, your base configuration class should inherit from `django_features.settings.BaseConfiguration`.

```
from django_features.settings import BaseConfiguration


class Base(BaseConfiguration):
    ...
```

## Custom Fields

To use all features of the `django_features.custom_fields` app, the following steps are required:

Add the `django_features.custom_fields.routers.custom_field_router` to your `ROOT_URLCONF`. For example:

```
path("api/", include(custom_field_router.urls)),
```

### Create your own custom field and value models

1. You need to create a custom field model and a custom value model.
2. Your custom field model should inherit from `django_features.custom_fields.models.field.AbstractBaseCustomField`.
3. Your custom value model should inherit from `django_features.custom_fields.models.value.AbstractBaseCustomValue`.

### Configuration

- You can configure the models used by the `django_features.custom_fields` app by setting the `CUSTOM_FIELD_MODEL` or `CUSTOM_FIELD_VALUE_MODEL` setting.
- The swapped models should inherit from `django_features.custom_fields.models.field.AbstractBaseCustomField` or `django_features.custom_fields.models.value.AbstractBaseCustomValue`.

### Models with custom values

1. Your models with custom values should inherit from `django_features.custom_fields.models.CustomFieldBaseModel`.
2. Your models should have a relation to the custom value model. For example:
    - `custom_values = models.ManyToManyField(blank=True, to=CustomValue, verbose_name=_("Benutzerdefinierte Werte"))`

#### Querysets

Your querysets for the models with custom values should inherit from `django_features.custom_fields.models.CustomFieldModelBaseManager`.

#### Serializers

Your serializers for the models with custom values should inherit from `django_features.custom_fields.serializers.CustomFieldBaseModelSerializer`.

## System Message

If you want to use `django_features.system_message`, your base configuration class should inherit from `django_features.system_message.settings.SystemMessageConfigurationMixin`.

Then call the super property:

```
@property
def CONSTANCE_CONFIG(self) -> dict:
    config = super().CONSTANCE_CONFIG
    return {**config, ...}

@property
def CONSTANCE_CONFIG_FIELDSETS(self) -> dict:
    config = super().CONSTANCE_CONFIG_FIELDSETS
    return {
        **config,
        ...
    }
```

Add the `django_features.system_message.routers.system_message_router` to your `ROOT_URLCONF`. For example:

```
path("api/", include(system_message_router.urls)),
```

# Development

Installing dependencies, assuming you have poetry installed:

``` bash
poetry install
```

# Release

This package uses towncrier to manage the changelog, and to introduce new changes, a file with a concise title and a brief explanation of what the change accomplishes should be created in the `changes` directory, with a suffix indicating whether the change is a feature, bugfix, or other.

To make a release and publish it to PyPI, the following command can be executed:

``` bash
./bin/release
```

This script utilizes zest.releaser and towncrier to create the release, build the wheel, and publish it to PyPI.

Before running the release command, it is necessary to configure poetry with an access token for PyPI by executing the following command and inserting the token stored in 1password:

``` bash
poetry config pypi-token.pypi <token>
```

The `version` attribute in the `pyproject.toml` file should be updated to the new version before running the release command, because this version will be published to PyPI.

## Custom-field validation and matching contracts

Field metadata now includes `required`, `allow_null`, `allow_blank`, and `default`.
Ordinary choices remain exactly `{id, label, value}`; import metadata is not added
to that representation. `allow_blank` also controls whether multiple fields accept
an empty list. `allow_null` controls the container and scalar-list items. Required
fields must be supplied; configured defaults apply only to optional fields. A
stored `default=None` means no default (the model cannot distinguish an explicit
null default from an absent one). Falsy defaults such as `False`, `0`, `""`, and
`[]` are retained. Model `clean()` / `full_clean()` and admin forms reject invalid
defaults with a validation error on `default`. Django `save()` alone does not call
`clean()`; callers saving configuration directly must validate it first. The shared
`validate_default(choices=None, validate_choices=True)` validator also accepts the
final surviving choices of an inline formset. An admin managing pending choices
can defer their validation with `clean(validate_choices=False)` and validate them
after the formset has been cleaned. Scalar defaults remain strictly validated.
Unsaved inline additions do not participate in canonical-ID default matching,
but must still be included in mapping and duplicate validation.

At runtime, invalid scalar/list or canonical choice defaults are omitted using
DRF's missing-field mechanism, preserving existing values on updates. Metadata
exposes no usable default for an invalid configuration. The named
`django_features.custom_fields` logger emits a warning with safe model/PK context
and a stable code, without default values or exception details; the application
owns logging configuration. A reused serializer field warns at most once, rather
than once per row. Explicit submitted values remain strictly validated. Choice
defaults are validated into choice objects when an omitted field uses them. Choice
defaults always contain canonical IDs, independently of a mapping client's
`unique_choice_field`; explicit client input still uses that lookup key. Mutable
defaults are copied for each application, including each item of a bulk serializer.
Defaults initialize creates only. Both full updates (PUT) and partial updates
(PATCH) preserve omitted custom fields. Pass the existing instance to the
serializer **before validation**; validating as a create and attaching an instance
afterwards cannot distinguish generated defaults from explicit input. Nested
update callers must likewise bind the nested object's instance before validation;
a new nested object still receives defaults, even when its parent is updated.
DRF's standard list serializer does not implement bulk updates; custom list
update implementations must bind the appropriate child instance for each row.
Mapping clients should resolve identity from mapped, validated identity fields,
without running mapping or formatters twice. Mapping `default_*` functions and
ordinary serializer defaults retain their existing behavior.

On creates, valid scalar defaults create `CustomValue` rows, including allowed
empty lists. This storage cost is intentional: absence and an explicit empty
value remain distinct. Scalar rows are inserted together with `bulk_create`;
choice defaults attach existing choice rows.

`ChoiceIdField(field, unique_field="id", choices=None)` accepts an ID or an
`{"id": ID}` dictionary for single fields and a list of those forms for multiple
fields. Mixed scalar/dictionary lists are supported. IDs expressed as integer
strings remain valid. Selections must belong to the configured field, be unique,
and match unambiguously. Invalid shapes and lookup values raise DRF
`ValidationError`, with codes including `shape`, `invalid`, `missing`, `duplicate`,
`ambiguous`, `null`, `blank`, and `empty`.
Configuring `unique_field="pk"` selects the concrete primary key; UUID primary keys also accept
native UUID objects. Integer primary keys reject booleans and floats. Alternative
concrete lookup fields accept native values supported by that model field, such
as dates, datetimes, decimals, and UUIDs, as well as their accepted string forms.

A configured `unique_field` must be a concrete non-relation choice-model field.
Dictionary inputs use that key when present, otherwise `id`, for both single and
multiple choices. The configured key wins when both occur; extra display metadata
is ignored. `pk` and concrete field-name keys require explicit configuration.
Lookup values must be hashable and accepted by the configured model field;
lists and dictionaries return `invalid`.
Model-field conversion precedes typed indexing, so JSON values `1`, `true`, and
`"1"` remain distinct. `BaseMappingSerializer` continues
to default to `unique_choice_field="value"`. Single validation returns a choice
object; multiple validation returns a fresh plain list in model ordering (or
supplied collection ordering), independent of selection order. Allowed empty
selections return an empty list. Adding, removing, or reordering items in the
returned list does not change the cached catalog or later result lists. The
choice objects themselves remain shared.

### Explicit matching and bounded queries

```python
from django_features.custom_fields.helpers import get_custom_field_model
from django_features.custom_fields.matching import ChoiceMatcher

fields = list(get_custom_field_model().objects.for_model(MyModel).with_choices())
for field in fields:
    if field.choice_field:
        matcher = ChoiceMatcher(
            field.choices,
            attribute="label",
            language="de",
            expected_type=str,
        )
        choice = matcher.resolve("Exact German label")
```

`with_choices()` uses the configured value model's actual reverse relationship:
it fetches fields and their choices in two queries. `field.choices` also reuses
ordinary reverse-relation prefetch caches. Pass the same materialized fields as
`MyCustomFieldSerializer(custom_fields=fields)` to reuse them for validation;
the caller must select the correct model and filters when supplying fields.
The model serializer uses this prefetch when input data is supplied to its
constructor or root serializer, including nested and list validation. Without
input data, or for a read-only serializer, it leaves choice catalogs lazy; model
queries for selected output values are separate. After ContentType warmup,
automatically loading nonempty field definitions and choices costs two queries
for validation, while definitions alone cost one query for reads. This excludes
excluded or caller-supplied fields. Direct `run_validation` calls without
constructor/root input retain lazy choice lookup without a constant query
guarantee. Each serializer instance performs its own queries; this does not cache
catalogs across instances. The metadata viewset always prefetches choices because
its output includes the catalog.
`ChoiceIdField(..., choices=field.choices)` also accepts an explicit collection.
These collections and indexes are request-local snapshots: recreate them after
configuration writes. Multiple-choice results are plain lists and do not support
QuerySet operations.

`ChoiceMatcher(choices, *, attribute, language=None, expected_type=None,
normalize=None)` supports `label`, `value`, and `external_label`. Its input must
contain choices from only one field, with the matching columns already loaded.
The `field_id` column must also be loaded; deferred scope metadata is rejected
without querying each choice.
Label matching requires an explicitly supported translation language and reads
that exact column with no fallback. Other attributes are untranslated. Matching
is exact, case-sensitive and type-aware; scalar string, integer, float, and
boolean keys are distinct. Null/structured keys are rejected by this matching
API. Pass `expected_type=str` to require string-valued configuration and input.
Callers may explicitly supply a `normalize` function to prepare keys and tokens;
normalization collisions are rejected. No normalization is implicit.
Only finite numbers can be keys; non-finite floats, including normalization
output, raise `type_mismatch`. Normalization type, value, overflow, and recursion
failures also become `type_mismatch`.

An optional/unset `external_label` is still a literal key in a supplied collection:
one blank label matches `""`, and two blank labels are ambiguous. The matcher does
not silently ignore blanks. Callers must explicitly decide whether a field's
mapping must be complete or whether to filter out choices before building its index.

`resolve(token)` returns the original object and `resolve_many(tokens)` returns
a list in token order (including repeated tokens). Once constructed, resolving
additional tokens runs no queries. `ChoiceMatchError` subclasses DRF
`ValidationError`: stable codes are `attribute`, `language`, `field_scope`,
`type_mismatch`, `ambiguous`, and `missing`; error text excludes choice values.
Duplicates and invalid configuration are rejected while building the index.

### Mandatory consumer migration and deployment

`AbstractBaseCustomValue` now includes an optional untranslated
`external_label = CharField(max_length=255, blank=True, default="")`. Every
concrete application model inheriting this abstract model needs its **own**
`AddField` migration, including swapped/custom value models. Do not redeclare the
field locally and do not add it to modeltranslation fields. Existing choice IDs,
labels, and JSON values are unchanged; existing rows receive an empty label.

The abstract model has no field foreign key, so it cannot impose field-scoped
SQL constraints. Consumers that require unique external labels should add this
to their concrete value model's `Meta.constraints` (adjust the constraint name):

```python
models.UniqueConstraint(
    fields=["field", "external_label"],
    condition=~models.Q(external_label=""),
    name="custom_value_field_external_label_unique",
)
```

The test consumer demonstrates the inherited field, admin, and constraint in
`app/custom_field`, with migration `0002_add_external_label_constraint` and its
updated `max_migration.txt`. In **each** consuming application, generate a
meaningfully named migration, reconcile its migration leaf tracker, review any
local `Meta` overrides, and run migration tests. Deploy the upgraded dependency
and consumer migrations together, applying migrations before application workers
query the new column. Upgrading the package alone does not update consumer tables.

### Candidate and release preparation

`pyproject.toml` follows the existing `version.txt` development version
`2026.5.0.dev0`. It is an unreleased candidate, not a published integration pin.
Install GNU gettext (`msgfmt` on `PATH`; e.g. `apt-get install gettext` on Debian/
Ubuntu or `brew install gettext` on macOS). Compile the translations with
`python manage.py compilemessages --ignore .venv` before building
locally with `poetry build`; compiled catalogs are included in the wheel. Run the focused contracts, the full suite,
`pytest --migrations app/custom_field/tests`, and
`DJANGO_CONFIGURATION=Testing python manage.py makemigrations --check --dry-run`.
Use `DJANGO_CONFIGURATION=Testing` for all tests, plus the isort/flake8/Black
commands in `.github/workflows/tests.yml`. The maintainer must choose the final
release version and follow the existing release process above separately; record
the actual published version and source revision before pinning it in consumers.

Shared catalogs live in `django_features/locale`. Installing only the nested
`django_features.custom_fields` app does not make Django discover that directory.
Applications must include the shared directory in `LOCALE_PATHS` or provide the
messages in their own discovered catalogs; shipping the compiled catalog alone
does not register its location with Django.

