"""Registration-time validation that refuses a plugin registration that cannot work.
Validation runs in the decorator rather than in Django's check framework, because checks only run
from management commands and would never fire on a production boot.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Model
from django.urls import path
if TYPE_CHECKING:
from .base import Plugin
[docs]
class PluginRegistrationError(ImproperlyConfigured):
"""A plugin registration that cannot work."""
def _fail(plugin: Any, model: Any, problem: str) -> None:
"""Raise a registration error naming the plugin, the record type and the problem.
Args:
plugin: The plugin class being registered.
model: The record type it is registered against.
problem: What is wrong.
Raises:
PluginRegistrationError: Always.
"""
plugin_name = getattr(plugin, "__name__", repr(plugin))
model_name = getattr(model, "__name__", repr(model))
msg = f"{plugin_name} registered against {model_name}: {problem}"
raise PluginRegistrationError(msg)
[docs]
def validate_models(plugin_class: type[Plugin], models: tuple[Any, ...]) -> None:
"""Require at least one model and that each is a Django model class.
Refusal raises ``PluginRegistrationError``.
Args:
plugin_class: The plugin being registered.
models: The values given to the decorator.
"""
if not models:
_fail(plugin_class, "nothing", "no model was given to register against")
for model in models:
if not (isinstance(model, type) and issubclass(model, Model)):
_fail(
plugin_class,
model,
f"expected a Django model, got {type(model).__name__}",
)
[docs]
def validate_check(plugin_class: type[Plugin], model: Any) -> None:
"""Require ``check`` to be a bool or a callable the access decision can evaluate.
A ``classmethod`` is refused because it is truthy but not callable, so it would permit every request.
A failing check raises ``PluginRegistrationError``.
Args:
plugin_class: The plugin being registered.
model: The record type it is registered against.
"""
from .access import check_is_valid, resolve_check
check = resolve_check(plugin_class)
if not check_is_valid(check):
_fail(
plugin_class,
model,
f"check is a {type(check).__name__}, which cannot be called. Use a plain function, a "
f"staticmethod or a bool — a classmethod is truthy but not callable, so it would "
f"permit every request",
)
[docs]
def validate_segment(plugin_class: type[Plugin], model: Any, segment: str) -> None:
"""Require a path segment to be usable in a route.
A segment Django rejects raises ``PluginRegistrationError``.
Args:
plugin_class: The plugin being registered.
model: The record type it is registered against.
segment: The URL path segment.
"""
# Built with `path()` so Django reports unknown converters and `<int:pk>/edit` stays valid.
try:
path(f"{segment}/", lambda request: None)
except Exception as exc:
_fail(plugin_class, model, f"url_path {segment!r} is not a valid route ({exc})")
[docs]
def url_names_for(plugin_class: type[Plugin]) -> list[str]:
"""List every URL name the plugin will generate.
Args:
plugin_class: The plugin.
Returns:
The plugin's own name followed by one name per additional view.
"""
base = plugin_class.get_name()
return [base, *(f"{base}-{e.get_name()}" for e in plugin_class.get_extra_views())]
[docs]
def validate_against_existing(
plugin_class: type[Plugin],
model: Any,
existing: list[tuple[type[Plugin], dict]],
) -> None:
"""Require names, segments and generated URL names to be unique for one record type.
A clash raises ``PluginRegistrationError``.
Args:
plugin_class: The plugin being registered.
model: The record type it is registered against.
existing: The ``(plugin class, options)`` entries already registered for it.
"""
# Names alone are not enough: plugin `a` with child `b` and plugin `a-b` reverse to the same name.
name = plugin_class.get_name()
segment = plugin_class.get_url_path()
new_url_names = set(url_names_for(plugin_class))
for other, _ in existing:
if other is plugin_class:
continue
if other.get_name() == name:
_fail(plugin_class, model, f"another plugin already uses the name {name!r}")
if segment is not None and other.get_url_path() == segment:
_fail(
plugin_class,
model,
f"{other.__name__} already serves the segment {segment!r}",
)
clashes = new_url_names & set(url_names_for(other))
if clashes:
_fail(
plugin_class,
model,
f"would generate the address name {sorted(clashes)[0]!r}, which "
f"{other.__name__} already generates",
)
[docs]
def validate_registration(
plugin_class: type[Plugin],
model: Any,
existing: list[tuple[type[Plugin], dict]],
) -> None:
"""Run every check possible when a plugin is registered against one record type.
A registration that cannot work raises ``PluginRegistrationError``.
Args:
plugin_class: The plugin being registered.
model: The record type it is registered against.
existing: The ``(plugin class, options)`` entries already registered for it.
"""
validate_check(plugin_class, model)
segment = plugin_class.get_url_path()
if segment is not None:
validate_segment(plugin_class, model, segment)
validate_extra_views(plugin_class, model)
validate_against_existing(plugin_class, model, existing)