Coverage for sm/forms.py: 98%
49 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 19:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 19:06 +0000
1from django import forms
3# from django.forms import TextInput # If we want/need to override
4from django.forms.models import ModelChoiceField, ModelMultipleChoiceField
5from taggit.forms import TagWidget
7from .mixins import filter_queryset_for_user
8from django.utils.translation import gettext as _
11class SMForm(forms.ModelForm):
12 """
13 Base model form. Accepts a ``user`` kwarg so FK choice fields are scoped to
14 the user's accessible groups (prevents a user from referencing another
15 tenant's related objects through the web UI).
16 """
18 class Meta:
19 fields = "__all__"
21 def __init__(self, *args, user=None, **kwargs):
22 self.user = user
23 super().__init__(*args, **kwargs)
24 self.scope_querysets()
26 def scope_querysets(self):
27 for field in self.fields.values():
28 if isinstance(field, (ModelChoiceField, ModelMultipleChoiceField)):
29 model = getattr(field.queryset, "model", None)
30 if model is not None and hasattr(model, "group"):
31 field.queryset = filter_queryset_for_user(field.queryset, self.user)
34class UniquePerGroupMixin:
35 """
36 Enforces name uniqueness per scope at the form level. The scope is the
37 instance's group (pre-assigned by ``MultiTenantMixin.get_form``) or the
38 single global scope when the group is null. The DB UniqueConstraint
39 ``(name, group)`` treats NULL rows as always distinct, so this check is
40 needed to prevent duplicate *global* items.
41 """
43 #: Model fields that must be unique together within a scope.
44 unique_fields: tuple = ("name",)
46 def clean(self):
47 cleaned_data = super().clean()
48 values = [cleaned_data.get(f) for f in self.unique_fields]
49 if any(v is None or v == "" for v in values):
50 return cleaned_data
51 model = self.Meta.model
52 lookup = {f: cleaned_data.get(f) for f in self.unique_fields}
53 group = getattr(self.instance, "group_id", None)
54 if group:
55 lookup["group"] = group
56 qs = model.objects.filter(**lookup)
57 if self.instance and self.instance.pk:
58 qs = qs.exclude(pk=self.instance.pk)
59 if qs.exists():
60 self.add_error(
61 "name",
62 _("An item with this name already exists in this scope."),
63 )
64 return cleaned_data
67class BulkActionForm(forms.Form):
68 """
69 Generic bulk-action form used by the shared list templates. Subclasses can
70 add extra fields (e.g. a status dropdown for models with a status FK).
71 """
73 delete = forms.BooleanField(required=False, label=_("Delete selected items"))
76class SMFormDisabled(SMForm):
77 """
78 Form for the detail view, disables all user input
79 """
81 def __init__(self, *args, **kwargs):
82 super().__init__(*args, **kwargs)
83 instance = getattr(self, "instance", None)
84 if instance and instance.pk:
85 for field in self.fields:
86 if self.fields[field].widget.__class__ is not TagWidget:
87 # self.fields[field].widget = TextInput()
88 pass # No logic for this atm
89 self.fields[field].widget.attrs["disabled"] = True
91 class Meta(SMForm.Meta):
92 pass