Coverage for apis_core/relations/forms/__init__.py: 23%
120 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-06-25 10:00 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-06-25 10:00 +0000
1from crispy_forms.helper import FormHelper
2from crispy_forms.layout import Submit
3from dal import autocomplete
4from django import forms
5from django.contrib.contenttypes.models import ContentType
6from django.core.exceptions import ValidationError
7from django.urls import reverse
8from django.utils.http import urlencode
10from apis_core.core.fields import ApisListSelect2
11from apis_core.generic.forms import GenericFilterSetForm, GenericModelForm
12from apis_core.generic.forms.fields import ModelImportChoiceField
15class CustomSelect2ListChoiceField(autocomplete.Select2ListChoiceField):
16 """
17 We use a custom Select2ListChoiceField in our Relation form,
18 because we don't want the form value to be validated. The field
19 uses the `choices` setting as a basis for validating, but our
20 choices span over multiple querysets, so its easier to simply not
21 validate (some validation happens in the `clean` method anyway).
22 """
24 def validate(self, value):
25 if "_" not in value:
26 raise ValidationError("please choose a correct value")
29class RelationForm(GenericModelForm):
30 """
31 This form overrides generic form for relation editing.
32 Relations have generic relations to subj and obj, but we
33 hide those ForeignKey form fields and instead show
34 autocomplete choices fields.
35 In addition, one can pass a hx_post_route argument to the
36 form to make the form set the `hx-post` attribute to the
37 given value.
38 We also pass a `reverse` boolean, wich gets passed on
39 to the htmx POST endpoint using url parameters (the endpoint
40 can then select the success_url based on the `reverse` state).
41 """
43 class Media:
44 js = ["js/relation_dialog.js"]
46 class Meta:
47 fields = "__all__"
48 widgets = {
49 "subj_content_type": forms.HiddenInput(),
50 "subj_object_id": forms.HiddenInput(),
51 "obj_content_type": forms.HiddenInput(),
52 "obj_object_id": forms.HiddenInput(),
53 }
55 def __entities_autocomplete_with_params(self, content_types=[ContentType]) -> str:
56 """helper method to generate the entities autocomplete url with contentype parameters"""
57 url = reverse("apis_core:apis_entities:autocomplete")
58 params = [f"entities={ct.app_label}.{ct.model}" for ct in content_types]
59 return url + "?" + "&".join(params)
61 def __subj_autocomplete_url(self) -> str:
62 """generate the autocomplete url for the subj field, using the subject
63 types configured in the relation"""
64 subj_content_types = [
65 ContentType.objects.get_for_model(model)
66 for model in self.Meta.model.subj_list()
67 ]
68 return self.__entities_autocomplete_with_params(subj_content_types)
70 def __obj_autocomplete_url(self) -> str:
71 """generate the autocomplete url for the obj field, using the object
72 types configured in the relation"""
73 obj_content_types = [
74 ContentType.objects.get_for_model(model)
75 for model in self.Meta.model.obj_list()
76 ]
77 return self.__entities_autocomplete_with_params(obj_content_types)
79 def __init__(self, *args, **kwargs):
80 """
81 Initialize the form and add the `subj` and `obj` fields using the
82 generic apis_entities autocomplete with the correct parameters.
83 """
85 self.params = kwargs.pop("params", {})
86 # workaround: if there is only one possible subj or obj type
87 # we use that as subj_content_type or obj_content_type, which
88 # lets us use another endpoint for autocomplete
89 # this can be removed when we stop allowing multiple types
90 # for relation subjects or objects
91 initial = kwargs["initial"]
92 if (
93 initial.get("subj_content_type", None) is None
94 and len(self.Meta.model.subj_list()) == 1
95 ):
96 kwargs["initial"]["subj_content_type"] = ContentType.objects.get_for_model(
97 self.Meta.model.subj_list()[0]
98 ).id
99 if (
100 initial.get("obj_content_type", None) is None
101 and len(self.Meta.model.obj_list()) == 1
102 ):
103 kwargs["initial"]["obj_content_type"] = ContentType.objects.get_for_model(
104 self.Meta.model.obj_list()[0]
105 ).id
106 super().__init__(*args, **kwargs)
107 subj_content_type = kwargs["initial"].get("subj_content_type", None)
108 subj_object_id = kwargs["initial"].get("subj_object_id", None)
109 obj_content_type = kwargs["initial"].get("obj_content_type", None)
110 obj_object_id = kwargs["initial"].get("obj_object_id", None)
112 self.subj_instance, self.obj_instance = None, None
113 if instance := kwargs.get("instance"):
114 self.subj_instance = instance.subj
115 self.obj_instance = instance.obj
116 subj_object_id = getattr(instance.subj, "id", None)
117 obj_object_id = getattr(instance.obj, "id", None)
119 self.fields["subj_object_id"].required = False
120 self.fields["subj_content_type"].required = False
121 if not subj_object_id:
122 if subj_content_type:
123 """ If we know the content type the subject will have, we
124 use another autocomplete field that allows us to paste links
125 and provides external autocomplete results.
126 """
127 ct = ContentType.objects.get(pk=subj_content_type)
128 self.fields["subj"] = ModelImportChoiceField(
129 queryset=ct.model_class().objects.all()
130 )
131 self.fields["subj"].widget = ApisListSelect2(
132 attrs={"data-html": True},
133 url=reverse("apis_core:generic:autocomplete", args=[ct])
134 + "?create=True",
135 )
136 self.fields["subj"].widget.choices = self.fields["subj"].choices
137 self.fields["subj"].required = False
138 else:
139 """ If we don't know the content type, we use a generic autocomplete
140 field that autocompletes any content type the relation can have as a
141 subject.
142 """
143 self.fields["subj_ct_and_id"] = CustomSelect2ListChoiceField()
144 self.fields["subj_ct_and_id"].widget = ApisListSelect2(
145 attrs={"data-html": True},
146 url=self.__subj_autocomplete_url(),
147 )
149 if self.subj_instance:
150 self.fields["subj_ct_and_id"] = CustomSelect2ListChoiceField()
151 self.fields["subj_ct_and_id"].widget = ApisListSelect2(
152 attrs={"data-html": True},
153 url=self.__subj_autocomplete_url(),
154 )
155 content_type = ContentType.objects.get_for_model(self.subj_instance)
156 select_identifier = f"{content_type.id}_{self.subj_instance.id}"
157 self.fields["subj_ct_and_id"].initial = select_identifier
158 self.fields["subj_ct_and_id"].choices = [
159 (select_identifier, self.subj_instance)
160 ]
162 self.fields["obj_object_id"].required = False
163 self.fields["obj_content_type"].required = False
164 if not obj_object_id:
165 if obj_content_type:
166 """ If we know the content type the object will have, we
167 use another autocomplete field that allows us to paste links
168 and provides external autocomplete results.
169 """
170 ct = ContentType.objects.get(pk=obj_content_type)
171 self.fields["obj"] = ModelImportChoiceField(
172 queryset=ct.model_class().objects.all()
173 )
174 self.fields["obj"].widget = ApisListSelect2(
175 attrs={"data-html": True},
176 url=reverse("apis_core:generic:autocomplete", args=[ct])
177 + "?create=True",
178 )
179 self.fields["obj"].widget.choices = self.fields["obj"].choices
180 self.fields["obj"].required = False
181 else:
182 """ If we don't know the content type, we use a generic autocomplete
183 field that autocompletes any content type the relation can have as a
184 object.
185 """
186 self.fields["obj_ct_and_id"] = CustomSelect2ListChoiceField()
187 self.fields["obj_ct_and_id"].widget = ApisListSelect2(
188 attrs={"data-html": True},
189 url=self.__obj_autocomplete_url(),
190 )
192 if self.obj_instance:
193 self.fields["obj_ct_and_id"] = CustomSelect2ListChoiceField()
194 self.fields["obj_ct_and_id"].widget = ApisListSelect2(
195 attrs={"data-html": True},
196 url=self.__obj_autocomplete_url(),
197 )
198 content_type = ContentType.objects.get_for_model(self.obj_instance)
199 select_identifier = f"{content_type.id}_{self.obj_instance.id}"
200 self.fields["obj_ct_and_id"].initial = select_identifier
201 self.fields["obj_ct_and_id"].choices = [
202 (select_identifier, self.obj_instance)
203 ]
205 self.order_fields(self.field_order)
206 self.helper = FormHelper(self)
207 model_ct = ContentType.objects.get_for_model(self.Meta.model)
208 self.helper.form_id = f"relation_{model_ct.model}_form"
209 self.helper.add_input(Submit("submit", "Submit"))
211 if hx_post_route := self.params.get("hx_post_route", False):
212 self.helper.attrs = {
213 "hx-post": hx_post_route + "?" + urlencode(self.params, doseq=True),
214 "hx-swap": "outerHTML",
215 "hx-target": f"#{self.helper.form_id}",
216 }
218 def clean(self) -> dict:
219 """
220 We check if there are `subj` or `obj` fields in the form data
221 and if so, we use the data to create objects for the real fields of
222 the Relation
223 """
224 cleaned_data = super().clean()
225 if "subj_ct_and_id" in cleaned_data:
226 subj_content_type, subj_object_id = cleaned_data["subj_ct_and_id"].split(
227 "_"
228 )
229 cleaned_data["subj_content_type"] = ContentType.objects.get(
230 pk=subj_content_type
231 )
232 cleaned_data["subj_object_id"] = subj_object_id
233 del cleaned_data["subj_ct_and_id"]
234 if "obj_ct_and_id" in cleaned_data:
235 obj_content_type, obj_object_id = cleaned_data["obj_ct_and_id"].split("_")
236 cleaned_data["obj_content_type"] = ContentType.objects.get(
237 pk=obj_content_type
238 )
239 cleaned_data["obj_object_id"] = obj_object_id
240 del cleaned_data["obj_ct_and_id"]
241 if cleaned_data.get("subj", None):
242 cleaned_data["subj_object_id"] = cleaned_data.pop("subj").id
243 if cleaned_data.get("obj", None):
244 cleaned_data["obj_object_id"] = cleaned_data.pop("obj").id
245 return cleaned_data
247 @property
248 def relation_name(self) -> str:
249 """A helper method to access the correct name of the relation"""
250 if self.params["reverse"]:
251 return self._meta.model.reverse_name
252 return self._meta.model.name
255class RelationFilterSetForm(GenericFilterSetForm):
256 """
257 FilterSet form for relations based on GenericFilterSetForm.
258 Excludes `relation_ptr` from the columns selector
259 """
261 columns_exclude = ["relation_ptr"]