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