Coverage for apis_core/relations/forms.py: 0%

113 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-22 07:51 +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.generic.forms import 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 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 } 

51 

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) 

57 

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) 

66 

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) 

75 

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

81 

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) 

89 

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 ) 

105 

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 ] 

139 

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 ] 

173 

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

179 

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 } 

189 

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 

218 

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