"""Model configuration: how a portal declares the way its model appears.
A portal writes a ``ModelConfiguration`` subclass, names the model and the fields
that matter, and the framework supplies six component classes from it. There are
three tiers of customisation, in increasing order of effort:
1. declare a field list, shared or per component;
2. declare a custom class for one component;
3. override that component's ``get_<component>_class()`` method.
Every caller inside the framework reaches a component through its accessor, so an
override at tier 3 is what the whole framework receives. Nothing is cached: an
accessor builds or resolves its class on every call, which is what Django's own
``ModelFormMixin.get_form_class()`` does.
"""
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from django.core.exceptions import ImproperlyConfigured
from django.db.models.constants import LOOKUP_SEP
from django.forms import ModelForm
from django.utils.functional import Promise
from django.utils.module_loading import import_string
from django_filters import FilterSet
from django_tables2 import Table
from import_export.resources import ModelResource
from fairdm.registry.exceptions import ConfigurationError, FieldValidationError
if TYPE_CHECKING:
from django.contrib.admin import ModelAdmin
from django.db import models
from rest_framework.serializers import ModelSerializer
[docs]
@dataclass(frozen=True, kw_only=True)
class Authority:
"""The authority that created or maintains a data model.
Attributes:
name: The full name of the authority. Required. Accepts a lazy translation
as well as a plain string, so an app config can declare its authority
with ``gettext_lazy`` before the translations load.
short_name: An abbreviated name for the authority.
website: The authority's website URL.
"""
name: str | Promise
short_name: str = ""
website: str = ""
[docs]
@dataclass(frozen=True, kw_only=True)
class Citation:
"""A citation for a data model.
Attributes:
text: The full citation text.
doi: The DOI for the citation.
"""
text: str = ""
doi: str = ""
[docs]
class Component(NamedTuple):
"""One row of the component table.
Attributes:
fields_attr: The configuration attribute holding the component's own field list.
class_attr: The configuration attribute holding a supplied class.
base: The class a supplied class must subclass.
factory: The name of the generator in ``fairdm.registry.factories``.
default_exclude: Names left out when the component is built from the framework's
own choice of fields, because they are no use there. A field list the portal
declares is never filtered by it.
"""
fields_attr: str
class_attr: str
base: type | None
factory: str
default_exclude: tuple[str, ...] = ()
COMPONENTS: dict[str, Component] = {
"form": Component("form_fields", "form_class", ModelForm, "FormFactory"),
"table": Component("table_fields", "table_class", Table, "TableFactory"),
"filterset": Component(
"filterset_fields", "filterset_class", FilterSet, "FilterFactory"
),
"serializer": Component(
"serializer_fields",
"serializer_class",
None,
"SerializerFactory",
default_exclude=("options", "tags"),
),
"resource": Component(
"resource_fields", "resource_class", ModelResource, "ResourceFactory"
),
"admin": Component("admin_list_display", "admin_class", None, "AdminFactory"),
}
"""Every component the registry produces, and where its configuration lives.
``base`` is ``None`` for the two components whose base class cannot be imported at
module scope: the serializer's base comes from Django REST Framework and the
admin's from ``django.contrib.admin``, both of which import app code.
"""
[docs]
def flatten_fields(fields: Sequence[Any] | None) -> list[str]:
"""Flatten a field list that groups names in tuples for layout.
A grouped list and a plain one produce the same fields, so grouping is free
for a portal to use and invisible to every generator.
Args:
fields: Field names, possibly grouped in tuples or nested lists.
Returns:
The flat list of field names, empty when ``fields`` is empty or ``None``.
"""
if not fields:
return []
flat: list[str] = []
for item in fields:
if isinstance(item, (tuple, list)):
flat.extend(item)
else:
flat.append(item)
return flat
def _component_base(name: str) -> type | None:
"""Resolve a component's base class, importing app code only when asked.
Args:
name: The component name, a key of ``COMPONENTS``.
Returns:
The class a supplied component class must subclass, or ``None`` when the
component has no base to enforce.
"""
declared = COMPONENTS[name].base
if declared is not None:
return declared
if name == "serializer":
from rest_framework.serializers import BaseSerializer
return BaseSerializer
if name == "admin":
from django.contrib.admin import ModelAdmin as DjangoModelAdmin
return DjangoModelAdmin
return None
[docs]
class ModelConfiguration:
"""How one model appears across every framework surface.
Written as a subclass declaring class attributes, the way ``Meta`` and
``ModelAdmin`` are written::
@fairdm.register
class RockSampleConfig(ModelConfiguration):
model = RockSample
fields = ["name", "location", "date_collected"]
Declare a component's own field list where it differs from the rest::
table_fields = ["name", "location", "contributor"]
Supply a class to replace one component outright::
table_class = RockSampleTable
Or override its accessor when a field list cannot say what you need::
def get_table_class(self):
return build_table(self.model)
Declaring both a component's field list and its class is refused at
registration, following Django's rule for ``fields`` and ``form_class``.
The configuration is validated when it is built, so a misconfigured portal stops
at import rather than serving a broken page.
Attributes:
model: The Django model this configures. A concrete Sample or Measurement
subclass. Declared non-optional because every method may rely on it:
the default of None exists only so that a subclass can supply it, and
``_validate_model`` refuses a configuration that reaches the end of
construction without one.
metadata: Structured metadata about the model.
fields: Field list inherited by every component that declares none of its
own. Names may use Django's double-underscore paths, and may be grouped
in tuples for layout. An empty list means the framework decides.
exclude: Names to leave out of every component.
search_fields: Field paths the listing's ``q`` filter matches. ``None``
(the default) resolves to ``["name"]`` via ``get_search_fields()``.
Every entry must resolve to a text field, ``CharField`` or
``TextField``, the same test ``AdminFactory._get_search_fields`` applies
when it picks the admin's own search fields. A numeric, boolean or date
field is refused at import rather than raising on a visitor's first
search.
form_fields: Field list for the form component alone.
table_fields: Field list for the table component alone.
filterset_fields: Field list for the filterset component alone.
serializer_fields: Field list for the serializer component alone.
resource_fields: Field list for the import and export resource alone.
admin_list_display: Field list for the admin component alone.
form_class: A form class, or its dotted path, replacing the generated one.
table_class: A table class, or its dotted path, replacing the generated one.
filterset_class: A filterset class, or its dotted path, replacing the
generated one.
serializer_class: A serializer class, or its dotted path, replacing the
generated one.
resource_class: A resource class, or its dotted path, replacing the
generated one.
admin_class: An admin class, or its dotted path, replacing the generated one.
display_name: Human-readable name, defaulting to the model's verbose name.
description: Description of this model type.
Args:
model: The model to configure. Passed positionally, as ``registry.register``
does, or by keyword alongside any other configuration attribute.
**overrides: Configuration attributes to set on this instance.
Raises:
TypeError: An override names an attribute that cannot be overridden.
"""
model: "type[models.Model]" = None # type: ignore[assignment]
metadata: ModelMetadata | None = None
fields: list[Any] = []
exclude: list[str] = []
search_fields: list[str] | None = None
form_fields: list[Any] | None = None
table_fields: list[Any] | None = None
filterset_fields: list[Any] | None = None
serializer_fields: list[Any] | None = None
resource_fields: list[Any] | None = None
admin_list_display: list[Any] | None = None
form_class: type[ModelForm] | str | None = None
table_class: type[Table] | str | None = None
filterset_class: type[FilterSet] | str | None = None
serializer_class: "type[ModelSerializer] | str | None" = None
resource_class: type[ModelResource] | str | None = None
admin_class: "type[ModelAdmin] | str | None" = None
display_name: str = ""
description: str = ""
#: Attributes a caller may set per instance.
_OVERRIDABLE = (
"metadata",
"fields",
"exclude",
"search_fields",
*(c.fields_attr for c in COMPONENTS.values()),
*(c.class_attr for c in COMPONENTS.values()),
"display_name",
"description",
)
def __init__(self, model: "type[models.Model] | None" = None, **overrides: Any):
if model is not None:
self.model = model
for name, value in overrides.items():
if name not in self._OVERRIDABLE:
raise TypeError(
f"{type(self).__name__}() got an unexpected keyword argument "
f"{name!r}"
)
setattr(self, name, value)
# Give each instance its own copy of the mutable declarative defaults, so
# that a caller appending to one configuration's list cannot reach another.
for name in ("fields", "exclude"):
if name not in overrides:
setattr(self, name, list(getattr(self, name)))
self._validate_model()
if not self.display_name:
self.display_name = str(self.model._meta.verbose_name).title()
if self.metadata is None:
self.metadata = ModelMetadata()
self._validate_component_conflicts()
self._validate_fields()
self._validate_search_fields()
self._validate_custom_classes()
self._validate_admin_inheritance()
def _validate_model(self) -> None:
"""Require a model. Whether it may be registered is the registry's decision."""
if self.model is None:
raise ConfigurationError("ModelConfiguration.model is required")
def _validate_component_conflicts(self) -> None:
"""Refuse a component configured two ways at once.
Django refuses the same pair on ``ModelFormMixin``. Silently preferring one
leaves a portal holding a field list that has no effect.
"""
for name, spec in COMPONENTS.items():
if (
getattr(self, spec.fields_attr) is not None
and getattr(self, spec.class_attr) is not None
):
raise ImproperlyConfigured(
f"{type(self).__name__} for {self.model.__name__} declares both "
f"{spec.fields_attr} and {spec.class_attr}. Specifying both a "
f"field list and a class for the {name} component is not "
f"permitted, because the field list would have no effect. "
f"Remove one of them."
)
def _field_lists(self) -> list[tuple[str, list[Any]]]:
"""List every declared field list, by the attribute that declared it."""
lists: list[tuple[str, list[Any]]] = [("fields", self.fields)]
lists += [
(spec.fields_attr, getattr(self, spec.fields_attr))
for spec in COMPONENTS.values()
]
return [(name, value) for name, value in lists if value]
def _validate_fields(self) -> None:
"""Require every name in every field list to resolve, path segments included."""
for attr, field_list in self._field_lists():
for name in flatten_fields(field_list):
self._validate_field_path(name, attr)
def _validate_field_path(self, path: str, attr: str) -> None:
"""Refuse a path that does not resolve, naming why.
Args:
path: The field name or double-underscore path to check.
attr: The configuration attribute that declared it.
Raises:
FieldValidationError: The path does not resolve on the model.
"""
from fairdm.utils.inspection import FieldInspector
inspector = FieldInspector(self.model)
resolves, reason = inspector.resolve_path(path)
if resolves:
return
raise FieldValidationError(
field_name=path,
model=self.model,
attribute=attr,
reason=reason,
suggestion=(
", ".join(inspector.close_matches(path.split(LOOKUP_SEP)[0]))
if reason is None
else None
),
)
def _validate_search_fields(self) -> None:
"""Require every `search_fields` entry to resolve to a text field.
Two passes: `_validate_field_path` decides whether the path exists at all,
the same test `fields` uses, then a positive type check on the resolved final
field - a `DecimalField`, `BooleanField` or `DateField` resolves cleanly and
would otherwise only raise on the first search a visitor types. `icontains`
is registered on `Field` itself, so asking whether the field *has* the lookup
would reject nothing.
"""
from django.db import models as django_models
for path in self.search_fields or []:
self._validate_field_path(path, "search_fields")
model: Any = self.model
field = None
for segment in path.split(LOOKUP_SEP):
field = model._meta.get_field(segment)
model = field.related_model
if not isinstance(
field, (django_models.CharField, django_models.TextField)
):
raise FieldValidationError(
field_name=path,
model=self.model,
attribute="search_fields",
reason=(
f"{field.__class__.__name__} is not a text field - "
"search_fields must resolve to a CharField or TextField"
),
)
def _validate_custom_classes(self) -> None:
"""Require a supplied class to subclass the base its component requires."""
for name, spec in COMPONENTS.items():
declared = getattr(self, spec.class_attr)
if declared is None:
continue
# Resolve a dotted path before the check, so a string cannot skip it.
supplied = self._get_class(declared)
base = _component_base(name)
if base is not None and not issubclass(supplied, base):
raise ConfigurationError(
f"{spec.class_attr} for {self.model.__name__} must be a subclass "
f"of {base.__module__}.{base.__qualname__}. Got "
f"{supplied.__name__} instead.",
model=self.model,
)
def _validate_admin_inheritance(self) -> None:
"""Require a supplied admin to use the child admin base for its hierarchy.
django-polymorphic's parent and child admins are not interchangeable, and a
child registered against the parent base misbehaves in ways that are hard to
trace back to the registration.
"""
if self.admin_class is None:
return
admin_cls = self._get_class(self.admin_class)
try:
from fairdm.core.measurement.admin import MeasurementChildAdmin
from fairdm.core.models import Measurement, Sample
from fairdm.core.sample.admin import SampleChildAdmin
except ImportError: # pragma: no cover - core app always present in practice
return
if issubclass(self.model, Sample) and not issubclass(
admin_cls, SampleChildAdmin
):
raise ConfigurationError(
f"Admin class for Sample subclass {self.model.__name__} must inherit "
f"from SampleChildAdmin. Got {admin_cls.__name__} instead. "
f"Change your admin class to: "
f"class {admin_cls.__name__}(SampleChildAdmin): ..."
)
if issubclass(self.model, Measurement) and not issubclass(
admin_cls, MeasurementChildAdmin
):
raise ConfigurationError(
f"Admin class for Measurement subclass {self.model.__name__} must "
f"inherit from MeasurementChildAdmin (the child admin base class). Got "
f"{admin_cls.__name__} instead. Change your admin class to: "
f"class {admin_cls.__name__}(MeasurementChildAdmin): ..."
)
[docs]
@classmethod
def get_default_fields(cls, model: "type[models.Model]") -> list[str]:
"""Return the framework's own choice of fields for a model.
Delegates to ``FieldInspector`` so the API, the admin and every generated
component agree on one default.
Args:
model: The model to choose fields for.
Returns:
The default field names.
"""
from fairdm.utils.inspection import FieldInspector
return FieldInspector(model).get_default_fields()
[docs]
def resolve_fields(self, component: str) -> list[str]:
"""Return the field list one component is built from.
A component's own list wins, then the shared list, then the framework's
defaults. Grouping tuples are flattened, and anything in ``exclude`` is
dropped.
Args:
component: The component name, a key of ``COMPONENTS``.
Returns:
The flat list of field names for that component.
"""
spec = COMPONENTS[component]
declared = getattr(self, spec.fields_attr)
if declared is not None:
chosen = declared
elif self.fields:
chosen = self.fields
else:
chosen = [
name
for name in self.get_default_fields(self.model)
if name not in spec.default_exclude
]
excluded = set(self.exclude)
return [name for name in flatten_fields(chosen) if name not in excluded]
[docs]
def get_search_fields(self) -> list[str]:
"""Return the fields the listing's `q` filter searches, `["name"]` by default."""
return self.search_fields or ["name"]
def _component_class(self, component: str, **factory_kwargs: Any) -> type:
"""Resolve or build one component's class, never cached.
Args:
component: The component name, a key of ``COMPONENTS``.
**factory_kwargs: Extra keyword arguments for the generating factory.
Returns:
The supplied class, or one generated from the resolved field list.
"""
spec = COMPONENTS[component]
declared = getattr(self, spec.class_attr)
if declared is not None:
return self._get_class(declared)
from fairdm.registry import factories
factory = getattr(factories, spec.factory)
generated = factory(
model=self.model,
fields=self.resolve_fields(component),
**factory_kwargs,
).generate()
return cast(type, generated)
[docs]
def get_table_class(self) -> type[Table]:
"""Return the django-tables2 Table for this model. Override to replace it."""
return self._component_class("table")
[docs]
def get_filterset_class(self) -> type[FilterSet]:
"""Return the django-filter FilterSet for this model, searching `get_search_fields()`.
Override to replace it.
"""
return self._component_class(
"filterset", search_fields=self.get_search_fields()
)
[docs]
def get_serializer_class(self) -> "type[ModelSerializer]":
"""Return the REST serializer for this model. Override to build your own."""
return cast("type[ModelSerializer]", self._component_class("serializer"))
[docs]
def get_resource_class(self) -> type[ModelResource]:
"""Return the import and export resource. Override to build your own."""
return cast(type[ModelResource], self._component_class("resource"))
[docs]
def get_admin_class(self) -> "type[ModelAdmin]":
"""Return the Django admin class for this model. Override to build your own."""
return cast("type[ModelAdmin]", self._component_class("admin"))
def _get_class(self, class_or_path: str | type) -> type:
"""Import a class from a dotted path, or return the class unchanged.
Args:
class_or_path: A class, or the dotted path of one.
Returns:
The class.
"""
if isinstance(class_or_path, str):
return cast(type, import_string(class_or_path))
return cast(type, class_or_path)
[docs]
def get_display_name(self) -> str:
"""Return the human-readable name for this model."""
return self.display_name or str(self.model._meta.verbose_name).title()
[docs]
def get_description(self) -> str:
"""Return the description for this model."""
if self.metadata and self.metadata.description:
return self.metadata.description
return self.description or f"Configuration for {self.model.__name__}"
[docs]
def get_slug(self) -> str:
"""Return the URL-safe name for this model, used in routes and view names."""
return self.model._meta.model_name or ""
[docs]
def get_verbose_name(self) -> str:
"""Return the model's singular verbose name."""
return str(self.model._meta.verbose_name)
[docs]
def get_verbose_name_plural(self) -> str:
"""Return the model's plural verbose name."""
return str(self.model._meta.verbose_name_plural)
def __repr__(self) -> str:
"""Return the class name and the model label."""
model = self.model._meta.label if self.model else None
return f"<{type(self).__name__}: {model}>"
[docs]
class SampleConfig(ModelConfiguration):
"""Configuration for a Sample subclass. Named for clarity at the call site."""
[docs]
class MeasurementConfig(ModelConfiguration):
"""Configuration for a Measurement subclass. Named for clarity at the call site."""
__all__ = [
"COMPONENTS",
"Authority",
"Citation",
"Component",
"MeasurementConfig",
"ModelConfiguration",
"ModelMetadata",
"SampleConfig",
"flatten_fields",
]