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

100 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-16 07:42 +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 

12 

13 

14class CustomSelect2ListChoiceField(autocomplete.Select2ListChoiceField): 

15 """ 

16 We use a custom Select2ListChoiceField in our Relation form, 

17 because we don't want the form value to be validated. The field 

18 uses the `choices` setting as a basis for validating, but our 

19 choices span over multiple querysets, so its easier to simply not 

20 validate (some validation happens in the `clean` method anyway). 

21 """ 

22 

23 def validate(self, value): 

24 if "_" not in value: 

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

26 

27 

28class RelationForm(GenericModelForm): 

29 """ 

30 This form overrides generic form for relation editing. 

31 Relations have generic relations to subj and obj, but we 

32 hide those ForeignKey form fields and instead show 

33 autocomplete choices fields. 

34 """ 

35 

36 class Meta: 

37 fields = "__all__" 

38 widgets = { 

39 "subj_content_type": forms.HiddenInput(), 

40 "subj_object_id": forms.HiddenInput(), 

41 "obj_content_type": forms.HiddenInput(), 

42 "obj_object_id": forms.HiddenInput(), 

43 } 

44 

45 def __entities_autocomplete_with_params(self, content_types=[ContentType]) -> str: 

46 """helper method to generate the entities autocomplete url with contentype parameters""" 

47 url = reverse("apis_core:apis_entities:autocomplete") 

48 params = [f"entities={ct.app_label}.{ct.model}" for ct in content_types] 

49 return url + "?" + "&".join(params) 

50 

51 def __subj_autocomplete_url(self, subj_content_type=None) -> str: 

52 """generate the autocomplete url for the subj field. by default use the 

53 subject types configured in the relation. If there is a subj_content_type 

54 passed in the `initial` dict of the form, use that contenttype instead of 

55 the configured ones""" 

56 subj_content_types = [ 

57 ContentType.objects.get_for_model(model) 

58 for model in self.Meta.model.subj_list() 

59 ] 

60 if subj_content_type: 

61 subj_content_types = [ContentType.objects.get(pk=subj_content_type)] 

62 return self.__entities_autocomplete_with_params(subj_content_types) 

63 

64 def __obj_autocomplete_url(self, obj_content_type=None) -> str: 

65 """generate the autocomplete url for the obj field. by default use the 

66 object types configured in the relation. If there is a obj_content_type 

67 passed in the `initial` dict of the form, use that contenttype instead of 

68 the configured ones""" 

69 obj_content_types = [ 

70 ContentType.objects.get_for_model(model) 

71 for model in self.Meta.model.obj_list() 

72 ] 

73 if obj_content_type: 

74 obj_content_types = [ContentType.objects.get(pk=obj_content_type)] 

75 return self.__entities_autocomplete_with_params(obj_content_types) 

76 

77 def __init__(self, *args, **kwargs): 

78 """ 

79 Initialize the form and add the `subj` and `obj` fields using the 

80 generic apis_entities autocomplete with the correct parameters. 

81 """ 

82 

83 super().__init__(*args, **kwargs) 

84 subj_content_type = kwargs["initial"].get("subj_content_type", None) 

85 subj_object_id = kwargs["initial"].get("subj_object_id", None) 

86 obj_content_type = kwargs["initial"].get("obj_content_type", None) 

87 obj_object_id = kwargs["initial"].get("obj_object_id", None) 

88 

89 self.subj_instance, self.obj_instance = None, None 

90 if instance := kwargs.get("instance"): 

91 self.subj_instance = instance.subj 

92 self.obj_instance = instance.obj 

93 else: 

94 if subj_content_type and subj_object_id: 

95 model = get_object_or_404(ContentType, pk=subj_content_type) 

96 self.subj_instance = get_object_or_404( 

97 model.model_class(), pk=subj_object_id 

98 ) 

99 if obj_content_type and obj_object_id: 

100 model = get_object_or_404(ContentType, pk=obj_content_type) 

101 self.obj_instance = get_object_or_404( 

102 model.model_class(), pk=obj_object_id 

103 ) 

104 

105 self.fields["subj_object_id"].required = False 

106 self.fields["subj_content_type"].required = False 

107 if not subj_object_id: 

108 self.fields["subj"] = CustomSelect2ListChoiceField() 

109 self.fields["subj"].widget = autocomplete.ListSelect2( 

110 url=self.__subj_autocomplete_url(subj_content_type) 

111 ) 

112 if self.subj_instance: 

113 content_type = ContentType.objects.get_for_model(self.subj_instance) 

114 select_identifier = f"{content_type.id}_{self.subj_instance.id}" 

115 self.fields["subj"].initial = select_identifier 

116 self.fields["subj"].choices = [(select_identifier, self.subj_instance)] 

117 

118 self.fields["obj_object_id"].required = False 

119 self.fields["obj_content_type"].required = False 

120 if not obj_object_id: 

121 self.fields["obj"] = CustomSelect2ListChoiceField() 

122 self.fields["obj"].widget = autocomplete.ListSelect2( 

123 url=self.__obj_autocomplete_url(obj_content_type) 

124 ) 

125 if self.obj_instance: 

126 content_type = ContentType.objects.get_for_model(self.obj_instance) 

127 select_identifier = f"{content_type.id}_{self.obj_instance.id}" 

128 self.fields["obj"].initial = select_identifier 

129 self.fields["obj"].choices = [(select_identifier, self.obj_instance)] 

130 

131 self.helper = FormHelper(self) 

132 model_ct = ContentType.objects.get_for_model(self.Meta.model) 

133 self.helper.form_id = f"relation_{model_ct.model}_form" 

134 self.helper.add_input(Submit("submit", "Submit")) 

135 

136 def clean(self) -> dict: 

137 """ 

138 We check if there are `subj` or `obj` fields in the form data 

139 and if so, we use the data to create objects for the real fields of 

140 the Relation 

141 """ 

142 cleaned_data = super().clean() 

143 if "subj" in cleaned_data: 

144 subj_content_type, subj_object_id = cleaned_data["subj"].split("_") 

145 cleaned_data["subj_content_type"] = ContentType.objects.get( 

146 pk=subj_content_type 

147 ) 

148 cleaned_data["subj_object_id"] = subj_object_id 

149 del cleaned_data["subj"] 

150 if "obj" in cleaned_data: 

151 obj_content_type, obj_object_id = cleaned_data["obj"].split("_") 

152 cleaned_data["obj_content_type"] = ContentType.objects.get( 

153 pk=obj_content_type 

154 ) 

155 cleaned_data["obj_object_id"] = obj_object_id 

156 del cleaned_data["obj"] 

157 return cleaned_data 

158 

159 

160class RelationFormHX(RelationForm): 

161 """ 

162 A Relation form that sets a hx-post attribute to the 

163 form to make the htmx POST request use another route. 

164 We also pass a `reverse` boolean, wich gets passed on 

165 to the POST endpoint using url parameters. The POST 

166 endpoint then calculates the success_url based on the 

167 `reverse` state. 

168 """ 

169 

170 def __init__(self, *args, **kwargs): 

171 self.is_reverse = kwargs.pop("reverse", False) 

172 super().__init__(*args, **kwargs) 

173 urlparams = kwargs["initial"] 

174 urlparams["reverse"] = self.is_reverse 

175 urlparams = {k: v for k, v in urlparams.items() if v} 

176 relation_content_type = ContentType.objects.get_for_model(self.Meta.model) 

177 hx_post = ( 

178 reverse( 

179 "apis_core:relations:create_relation_form", args=[relation_content_type] 

180 ) 

181 + "?" 

182 + urlencode(urlparams) 

183 ) 

184 self.helper.attrs = { 

185 "hx-post": hx_post, 

186 "hx-swap": "outerHTML", 

187 "hx-target": f"#{self.helper.form_id}", 

188 } 

189 

190 @property 

191 def relation_name(self) -> str: 

192 if self.is_reverse: 

193 return self._meta.model.reverse_name 

194 return self._meta.model.name