Source code for django_tenant_options.forms

"""Forms for the Django Tenant Options package."""

import logging

from django import forms
from django.apps import apps
from django.db import IntegrityError
from django.db import transaction
from django.forms.widgets import HiddenInput
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from django_tenant_options.app_settings import DEFAULT_MULTIPLE_CHOICE_FIELD
from django_tenant_options.app_settings import DISABLE_FIELD_FOR_DELETED_SELECTION
from django_tenant_options.choices import OptionType
from django_tenant_options.exceptions import NoTenantProvidedFromViewError
from django_tenant_options.helpers import all_option_subclasses
from django_tenant_options.helpers import get_default_option_names


logger = logging.getLogger("django_tenant_options")


[docs] class TenantFormBaseMixin: """Base mixin that checks for a valid tenant value passed from the view and hides the tenant field."""
[docs] def __init__(self, *args, **kwargs): """Initialize the form with the tenant and hide the tenant field.""" self.tenant = self._pop_tenant(kwargs) super().__init__(*args, **kwargs) self._initialize_tenant_field() self._initialize_option_type_field() self._remove_associated_tenants_field()
def _pop_tenant(self, kwargs): """Extract and validate the tenant from kwargs.""" tenant = kwargs.pop("tenant", None) if not tenant: raise NoTenantProvidedFromViewError("No tenant model class was provided to the form from the view") return tenant def _initialize_tenant_field(self): """Set the tenant field to HiddenInput and initialize it with the tenant instance.""" if "tenant" in self.fields: self.fields["tenant"].initial = self.tenant self.fields["tenant"].widget = HiddenInput() def _initialize_option_type_field(self): """If this is an Option form, ensure option_type is CUSTOM when tenant is provided.""" if "option_type" in self.fields: self.fields["option_type"].initial = OptionType.CUSTOM if not self.data: self.data = self.data.copy() self.data["option_type"] = OptionType.CUSTOM def _remove_associated_tenants_field(self): """Remove the associated_tenants field if it exists.""" if "associated_tenants" in self.fields: del self.fields["associated_tenants"]
[docs] def clean(self): """Ensure the tenant is correct and set option_type to CUSTOM when tenant is provided.""" cleaned_data = super().clean() cleaned_data["tenant"] = self.tenant # If this is an Option form (has option_type field), ensure it's CUSTOM when tenant is provided if "option_type" in self.fields: cleaned_data["option_type"] = OptionType.CUSTOM return cleaned_data
[docs] class OptionFormMixin: """Base mixin for all Option forms."""
[docs] def __init__(self, *args, **kwargs): """Initialize the form and set option_type to CUSTOM.""" super().__init__(*args, **kwargs) if "option_type" in self.fields: self.fields["option_type"].initial = OptionType.CUSTOM self.data = self.data.copy() if self.data else {} self.data["option_type"] = OptionType.CUSTOM
[docs] class OptionCreateFormMixin(OptionFormMixin, TenantFormBaseMixin): # pylint disable=R0903 """Used in forms that allow a tenant to create a new custom option. It requires a `tenant` argument to be passed from the view. This should be an instance of the model class provided in the tenant_model parameter of the concrete OptionModel. The form will set the option_type field to OptionType.CUSTOM and the tenant field to the tenant instance, using a HiddenInput widget. Usage: .. code-block:: python class MyOptionCreateForm(OptionCreateFormMixin, forms.ModelForm): class Meta: model = MyConcreteOptionModel fields = "__all__" def my_options_view(request): form = MyOptionCreateForm(request.POST, tenant=request.user.tenant) """
[docs] def __init__(self, *args, **kwargs): """Initialize the form with the tenant and set the option_type field.""" super().__init__(*args, **kwargs) self._initialize_option_type_field() self._initialize_deleted_field() self._initialize_name_help_text()
def _initialize_option_type_field(self): """Set the option_type field to OptionType.CUSTOM and use a HiddenInput widget.""" self.fields["option_type"].widget = HiddenInput() self.fields["option_type"].initial = OptionType.CUSTOM def _initialize_deleted_field(self): """Set the deleted field to None and use a HiddenInput widget.""" self.fields["deleted"].widget = HiddenInput() self.fields["deleted"].initial = None def _initialize_name_help_text(self): """Append a hint listing reserved default option names that cannot be reused. The names come from the form's Meta.model.default_options. Any existing help_text on the name field is preserved and the hint is appended to it. Models with no default options leave the help_text unchanged. """ if "name" not in self.fields: return option_model = getattr(getattr(self, "_meta", None), "model", None) if option_model is None: return reserved = get_default_option_names(option_model) if not reserved: return quoted = ", ".join(f'"{name}"' for name in reserved) hint = f"Reserved default names you cannot reuse: {quoted}." existing = self.fields["name"].help_text or "" self.fields["name"].help_text = f"{existing} {hint}".strip() if existing else hint
[docs] def clean(self): """Ensure option_type is correct even if HiddenField was manipulated.""" cleaned_data = super().clean() # ensure option_type is correct even if HiddenField was manipulated cleaned_data["option_type"] = OptionType.CUSTOM return cleaned_data
[docs] class OptionUpdateFormMixin(OptionCreateFormMixin): # pylint disable=R0903 """Used in forms that allow a tenant to update an existing custom option. Has the same operation and requirements as OptionCreateFormMixin, but also allows the option to be deleted. Usage: .. code-block:: python class MyOptionUpdateForm(OptionUpdateFormMixin, forms.ModelForm): class Meta: model = MyConcreteOptionModel fields = "__all__" def my_options_view(request, option_id): option = MyConcreteOptionModel.objects.get(id=option_id) form = MyOptionUpdateForm(request.POST, instance=option, tenant=request.user.tenant) """
[docs] def __init__(self, *args, **kwargs): """Initialise the form.""" super().__init__(*args, **kwargs) # Add a delete field to the form self.fields["delete"] = forms.BooleanField( required=False, label=_("Delete this option"), help_text=_("Check this box to remove this option. It can be restored later by an administrator."), )
[docs] def clean(self): """Clean the form data.""" cleaned_data = super().clean() if cleaned_data.get("delete"): cleaned_data["deleted"] = timezone.now() return cleaned_data
[docs] class SelectionsForm(TenantFormBaseMixin, forms.Form): """Creates a form with a `selections` field for managing tenant selections."""
[docs] def __init__(self, *args, **kwargs): """Initialize the form with the tenant and set the selections field.""" self._meta = self.Meta self.selection_model = self._meta.model self.option_model = apps.get_model(self.selection_model.option_model) self.removed_selections = self.option_model.objects.none() self.multiple_choice_field_class = ( getattr(type(self), "multiple_choice_field_class", None) or DEFAULT_MULTIPLE_CHOICE_FIELD ) super().__init__(*args, **kwargs) self._initialize_selections_field() self._remove_option_field() self._set_selections_queryset()
def _initialize_selections_field(self): """Initialize the `selections` field if it's not already present. Consumers are encouraged to override ``label`` and ``help_text`` in a subclass to describe their domain (for example "Priority levels"), but the defaults below are descriptive enough to be accessible out of the box. """ if "selections" not in self.fields: extra_field_kwargs = getattr(type(self), "multiple_choice_field_kwargs", None) or {} self.fields["selections"] = self.multiple_choice_field_class( queryset=self.option_model.objects.none(), required=False, label=_("Available options"), help_text=_( "Select the options that should be available to your users. " "Mandatory options are always included and cannot be removed." ), **extra_field_kwargs, ) def _remove_option_field(self): """Remove the `option` field if it exists.""" if "option" in self.fields: del self.fields["option"] def _set_selections_queryset(self): """Set the queryset for the `selections` field based on the tenant.""" try: self.fields["selections"].queryset = self.selection_model.objects.options_for_tenant(self.tenant) self.fields["selections"].initial = self.selection_model.objects.selected_options_for_tenant(self.tenant) except Exception as e: # pylint: disable=W0718 logger.exception("Failed setting selections queryset for tenant '%s': %s", self.tenant, e) self.fields["selections"].queryset = self.selection_model.objects.none()
[docs] def clean(self): """Ensure `selections` include mandatory options and identify removed selections.""" cleaned_data = super().clean() cleaned_data["selections"] = self._combine_selections_and_mandatory(cleaned_data.get("selections", [])) self.removed_selections = self._identify_removed_selections(cleaned_data["selections"]) logger.debug("cleaned_data: %s", cleaned_data) return cleaned_data
def _combine_selections_and_mandatory(self, selections): """Combine the selections with mandatory options.""" if selections is None: selections = self.option_model.objects.none() mandatory_options = self.option_model.objects.filter(option_type=OptionType.MANDATORY) return (selections | mandatory_options).distinct() def _identify_removed_selections(self, selections): """Identify options removed from the selection by the user.""" current_selections = self.selection_model.objects.filter(tenant=self.tenant, deleted__isnull=True).values_list( "option_id", flat=True ) # Exclude mandatory options and get currently selected options that aren't in the new selections return self.option_model.objects.filter( id__in=current_selections, option_type__in=[OptionType.OPTIONAL, OptionType.CUSTOM] ).exclude(id__in=selections.values_list("id", flat=True))
[docs] def save(self, *args, **kwargs): """Save the selections to the database, handling added and removed options.""" try: with transaction.atomic(): # type: ignore[reportGeneralTypeIssues] self._delete_removed_selections() self._save_new_selections() except IntegrityError as e: logger.warning("Problem creating or deleting selections for %s: %s", self.tenant, e) super().__init__(*args, **kwargs, tenant=self.tenant)
def _delete_removed_selections(self): """Delete any selections that were removed.""" from django_tenant_options.cache import safe_bump_version self.selection_model.objects.filter( tenant=self.tenant, option__in=self.removed_selections, deleted__isnull=True ).update(deleted=timezone.now()) safe_bump_version(self.option_model._meta.label) def _save_new_selections(self): """Create or update the selections that were added.""" for selection in self.cleaned_data["selections"]: self.selection_model.objects.update_or_create( tenant=self.tenant, option=selection, defaults={"deleted": None} )
[docs] class UserFacingFormMixin: """Mixin to handle user-facing forms with Options fields."""
[docs] def __init__(self, *args, **kwargs): """Initialize the form with the tenant and filter ForeignKey fields related to AbstractOption subclasses.""" self.tenant = kwargs.pop("tenant", None) if not self.tenant: raise NoTenantProvidedFromViewError("No tenant model class was provided to the form from the view") super().__init__(*args, **kwargs) self._initialize_tenant_field() self._remove_associated_tenants_field() self._filter_foreign_key_fields()
def _initialize_tenant_field(self): """Initialize the `tenant` field with the tenant instance and set it to `HiddenInput`.""" if "tenant" in self.fields: self.fields["tenant"].initial = self.tenant self.fields["tenant"].widget = HiddenInput() def _remove_associated_tenants_field(self): """Remove the `associated_tenants` field if it exists.""" if "associated_tenants" in self.fields: del self.fields["associated_tenants"] def _filter_foreign_key_fields(self): """Filter queryset for ForeignKey fields related to AbstractOption subclasses.""" option_subclasses = all_option_subclasses() for field_name, field in self.fields.items(): if self._is_foreign_key_to_option_subclass(field, option_subclasses): logger.debug("field_name: %s for field: %s", field_name, field) self._filter_queryset_for_tenant(field) self._set_field_initial_value(field_name) self._handle_deleted_selection(field, field_name) def _is_foreign_key_to_option_subclass(self, field, option_subclasses): """Check if the field is a ForeignKey to an AbstractOption subclass.""" return ( isinstance(field, forms.ModelChoiceField) and field.queryset is not None and field.queryset.model in option_subclasses ) def _filter_queryset_for_tenant(self, field): """Filter the queryset to only show options selected for the tenant.""" field.queryset = field.queryset.model.objects.selected_options_for_tenant(self.tenant) def _set_field_initial_value(self, field_name): """Set the initial value for the field if there's an instance.""" if hasattr(self, "instance") and hasattr(self.instance, "pk") and self.instance.pk: field_value = getattr(self.instance, field_name) if field_value: self.fields[field_name].initial = field_value.pk def _handle_deleted_selection(self, field, field_name): """Handle the case where a selection has been deleted.""" if hasattr(self, "instance") and hasattr(self.instance, "pk") and self.instance.pk: option_for_this_field = getattr(self.instance, field_name) if option_for_this_field and option_for_this_field not in field.queryset: self._handle_disabled_field_for_deleted_selection(field, option_for_this_field) def _handle_disabled_field_for_deleted_selection(self, field, option_for_this_field): """Lock the field if the selected option has been deleted and the setting is enabled. ``readonly`` is not a valid attribute on ``<select>`` and is removed. The field stays ``disabled`` (so the stale value cannot be re-submitted) but also carries ``aria-disabled`` and an explanatory ``help_text`` so screen reader users are told why the control is locked instead of finding it silently skipped. """ if DISABLE_FIELD_FOR_DELETED_SELECTION and option_for_this_field.pk: field.queryset = field.queryset | field.queryset.model.objects.filter(pk=option_for_this_field.pk) field.widget.attrs["disabled"] = "disabled" field.widget.attrs["aria-disabled"] = "true" field.help_text = _( "The previously selected option is no longer available. " "Please ask an administrator to choose a replacement." )
[docs] def clean(self): """Ensure the tenant is correct even if HiddenField was manipulated.""" cleaned_data = super().clean() cleaned_data["tenant"] = self.tenant return cleaned_data
[docs] class AccessibleFormMixin: """Opt-in mixin that links validation errors to their inputs for assistive tech. When a field fails validation, screen readers are not told unless the input carries ``aria-invalid`` and points to its error text via ``aria-describedby``. Django does not do this automatically. Mix this in (before ``forms.Form`` or ``forms.ModelForm``) to get it for free:: class MyForm(AccessibleFormMixin, forms.ModelForm): ... Render the matching error container with the id this mixin references, e.g.:: <ul id="id_{{ field.html_name }}_errors" role="alert">...</ul> """
[docs] def add_error(self, field, error): """Wire aria-invalid/aria-describedby onto every errored field's widget. This inspects ``self.errors`` rather than only the ``field`` argument, so it also covers errors raised as a dict (``add_error(None, {...})``) and the per-field errors ``ModelForm`` validation raises internally - the common ``clean()`` idioms that a single-field check would miss. Non-field errors (keyed under ``NON_FIELD_ERRORS``) are skipped because they have no widget to annotate. The ``aria-describedby`` id matches the documented ``id_{{ field.html_name }}_errors`` container so it is prefix-aware. """ super().add_error(field, error) for name in self.errors: if name not in self.fields: continue bound = self[name] widget = self.fields[name].widget widget.attrs["aria-invalid"] = "true" # Append (rather than overwrite) so a pre-existing aria-describedby - e.g. a # help-text association set by the renderer - is preserved. Idempotent across # the repeated add_error calls Django makes during full_clean. error_id = f"id_{bound.html_name}_errors" existing = widget.attrs.get("aria-describedby", "") if error_id not in existing.split(): widget.attrs["aria-describedby"] = f"{existing} {error_id}".strip()