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

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 

10 

11from apis_core.core.fields import ApisListSelect2 

12from apis_core.generic.forms import GenericFilterSetForm, GenericModelForm 

13from apis_core.generic.forms.fields import ModelImportChoiceField 

14 

15 

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

24 

25 def validate(self, value): 

26 if "_" not in value: 

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

28 

29 

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

43 

44 class Media: 

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

46 

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 } 

55 

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) 

61 

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) 

70 

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) 

79 

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

85 

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) 

93 

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 ) 

109 

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 ] 

145 

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 ] 

181 

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

187 

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 } 

197 

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 

226 

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 

233 

234 

235class RelationFilterSetForm(GenericFilterSetForm): 

236 """ 

237 FilterSet form for relations based on GenericFilterSetForm. 

238 Excludes `relation_ptr` from the columns selector 

239 """ 

240 

241 columns_exclude = ["relation_ptr"]