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

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 

9 

10from apis_core.core.fields import ApisListSelect2 

11from apis_core.generic.forms import GenericFilterSetForm, GenericModelForm 

12from apis_core.generic.forms.fields import ModelImportChoiceField 

13 

14 

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 """ 

23 

24 def validate(self, value): 

25 if "_" not in value: 

26 raise ValidationError("please choose a correct value") 

27 

28 

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 """ 

42 

43 class Media: 

44 js = ["js/relation_dialog.js"] 

45 

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 } 

54 

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) 

60 

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) 

69 

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) 

78 

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 """ 

84 

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) 

111 

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) 

118 

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 ) 

148 

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 ] 

161 

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 ) 

191 

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 ] 

204 

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")) 

210 

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 } 

217 

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 

246 

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 

253 

254 

255class RelationFilterSetForm(GenericFilterSetForm): 

256 """ 

257 FilterSet form for relations based on GenericFilterSetForm. 

258 Excludes `relation_ptr` from the columns selector 

259 """ 

260 

261 columns_exclude = ["relation_ptr"]