Coverage for apis_core/relations/forms.py: 0%
113 statements
« prev ^ index » next coverage.py v7.6.8, created at 2024-12-20 09:24 +0000
« prev ^ index » next coverage.py v7.6.8, created at 2024-12-20 09:24 +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.generic.forms import 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 Meta:
44 fields = "__all__"
45 widgets = {
46 "subj_content_type": forms.HiddenInput(),
47 "subj_object_id": forms.HiddenInput(),
48 "obj_content_type": forms.HiddenInput(),
49 "obj_object_id": forms.HiddenInput(),
50 }
52 def __entities_autocomplete_with_params(self, content_types=[ContentType]) -> str:
53 """helper method to generate the entities autocomplete url with contentype parameters"""
54 url = reverse("apis_core:apis_entities:autocomplete")
55 params = [f"entities={ct.app_label}.{ct.model}" for ct in content_types]
56 return url + "?" + "&".join(params)
58 def __subj_autocomplete_url(self) -> str:
59 """generate the autocomplete url for the subj field, using the subject
60 types configured in the relation"""
61 subj_content_types = [
62 ContentType.objects.get_for_model(model)
63 for model in self.Meta.model.subj_list()
64 ]
65 return self.__entities_autocomplete_with_params(subj_content_types)
67 def __obj_autocomplete_url(self) -> str:
68 """generate the autocomplete url for the obj field, using the object
69 types configured in the relation"""
70 obj_content_types = [
71 ContentType.objects.get_for_model(model)
72 for model in self.Meta.model.obj_list()
73 ]
74 return self.__entities_autocomplete_with_params(obj_content_types)
76 def __init__(self, *args, **kwargs):
77 """
78 Initialize the form and add the `subj` and `obj` fields using the
79 generic apis_entities autocomplete with the correct parameters.
80 """
82 self.is_reverse = kwargs.pop("reverse", False)
83 hx_post_route = kwargs.pop("hx_post_route", False)
84 super().__init__(*args, **kwargs)
85 subj_content_type = kwargs["initial"].get("subj_content_type", None)
86 subj_object_id = kwargs["initial"].get("subj_object_id", None)
87 obj_content_type = kwargs["initial"].get("obj_content_type", None)
88 obj_object_id = kwargs["initial"].get("obj_object_id", None)
90 self.subj_instance, self.obj_instance = None, None
91 if instance := kwargs.get("instance"):
92 self.subj_instance = instance.subj
93 self.obj_instance = instance.obj
94 else:
95 if subj_content_type and subj_object_id:
96 model = get_object_or_404(ContentType, pk=subj_content_type)
97 self.subj_instance = get_object_or_404(
98 model.model_class(), pk=subj_object_id
99 )
100 if obj_content_type and obj_object_id:
101 model = get_object_or_404(ContentType, pk=obj_content_type)
102 self.obj_instance = get_object_or_404(
103 model.model_class(), pk=obj_object_id
104 )
106 self.fields["subj_object_id"].required = False
107 self.fields["subj_content_type"].required = False
108 if not subj_object_id:
109 if subj_content_type:
110 """ If we know the content type the subject will have, we
111 use another autocomplete field that allows us to paste links
112 and provides external autocomplete results.
113 """
114 ct = ContentType.objects.get(pk=subj_content_type)
115 self.fields["subj"] = ModelImportChoiceField(
116 queryset=ct.model_class().objects.all()
117 )
118 self.fields["subj"].widget = autocomplete.ListSelect2(
119 url=reverse("apis_core:generic:autocomplete", args=[ct])
120 + "?create=True"
121 )
122 self.fields["subj"].widget.choices = self.fields["subj"].choices
123 else:
124 """ If we don't know the content type, we use a generic autocomplete
125 field that autocompletes any content type the relation can have as a
126 subject.
127 """
128 self.fields["subj_ct_and_id"] = CustomSelect2ListChoiceField()
129 self.fields["subj_ct_and_id"].widget = autocomplete.ListSelect2(
130 url=self.__subj_autocomplete_url()
131 )
132 if self.subj_instance:
133 content_type = ContentType.objects.get_for_model(self.subj_instance)
134 select_identifier = f"{content_type.id}_{self.subj_instance.id}"
135 self.fields["subj_ct_and_id"].initial = select_identifier
136 self.fields["subj_ct_and_id"].choices = [
137 (select_identifier, self.subj_instance)
138 ]
140 self.fields["obj_object_id"].required = False
141 self.fields["obj_content_type"].required = False
142 if not obj_object_id:
143 if obj_content_type:
144 """ If we know the content type the object will have, we
145 use another autocomplete field that allows us to paste links
146 and provides external autocomplete results.
147 """
148 ct = ContentType.objects.get(pk=obj_content_type)
149 self.fields["obj"] = ModelImportChoiceField(
150 queryset=ct.model_class().objects.all()
151 )
152 self.fields["obj"].widget = autocomplete.ListSelect2(
153 url=reverse("apis_core:generic:autocomplete", args=[ct])
154 + "?create=True"
155 )
156 self.fields["obj"].widget.choices = self.fields["obj"].choices
157 else:
158 """ If we don't know the content type, we use a generic autocomplete
159 field that autocompletes any content type the relation can have as a
160 object.
161 """
162 self.fields["obj_ct_and_id"] = CustomSelect2ListChoiceField()
163 self.fields["obj_ct_and_id"].widget = autocomplete.ListSelect2(
164 url=self.__obj_autocomplete_url()
165 )
166 if self.obj_instance:
167 content_type = ContentType.objects.get_for_model(self.obj_instance)
168 select_identifier = f"{content_type.id}_{self.obj_instance.id}"
169 self.fields["obj_ct_and_id"].initial = select_identifier
170 self.fields["obj_ct_and_id"].choices = [
171 (select_identifier, self.obj_instance)
172 ]
174 self.order_fields(self.field_order)
175 self.helper = FormHelper(self)
176 model_ct = ContentType.objects.get_for_model(self.Meta.model)
177 self.helper.form_id = f"relation_{model_ct.model}_form"
178 self.helper.add_input(Submit("submit", "Submit"))
180 if hx_post_route:
181 urlparams = kwargs["initial"]
182 urlparams["reverse"] = self.is_reverse
183 urlparams = {k: v for k, v in urlparams.items() if v}
184 self.helper.attrs = {
185 "hx-post": hx_post_route + "?" + urlencode(urlparams),
186 "hx-swap": "outerHTML",
187 "hx-target": f"#{self.helper.form_id}",
188 }
190 def clean(self) -> dict:
191 """
192 We check if there are `subj` or `obj` fields in the form data
193 and if so, we use the data to create objects for the real fields of
194 the Relation
195 """
196 cleaned_data = super().clean()
197 if "subj_ct_and_id" in cleaned_data:
198 subj_content_type, subj_object_id = cleaned_data["subj_ct_and_id"].split(
199 "_"
200 )
201 cleaned_data["subj_content_type"] = ContentType.objects.get(
202 pk=subj_content_type
203 )
204 cleaned_data["subj_object_id"] = subj_object_id
205 del cleaned_data["subj_ct_and_id"]
206 if "obj_ct_and_id" in cleaned_data:
207 obj_content_type, obj_object_id = cleaned_data["obj_ct_and_id"].split("_")
208 cleaned_data["obj_content_type"] = ContentType.objects.get(
209 pk=obj_content_type
210 )
211 cleaned_data["obj_object_id"] = obj_object_id
212 del cleaned_data["obj_ct_and_id"]
213 if "subj" in cleaned_data:
214 cleaned_data["subj_object_id"] = cleaned_data.pop("subj").id
215 if "obj" in cleaned_data:
216 cleaned_data["obj_object_id"] = cleaned_data.pop("obj").id
217 return cleaned_data
219 @property
220 def relation_name(self) -> str:
221 """A helper method to access the correct name of the relation"""
222 if self.is_reverse:
223 return self._meta.model.reverse_name
224 return self._meta.model.name