Coverage for gui/forms.py: 100%

160 statements  

« prev     ^ index     » next       coverage.py v7.2.1, created at 2023-03-14 06:24 +0000

1from django import forms 

2from django.db import transaction 

3from django.shortcuts import get_object_or_404 

4from django.contrib.auth.models import User 

5from django.utils.functional import cached_property 

6 

7from .models import Membership, MembershipPeriod, Profile 

8from .models import BallotSelectQuestionPossibleAnswer, BallotRankingQuestionOption 

9from .models import BallotRankingAnswer, BallotSelectedAnswer 

10 

11 

12# Origin: https://stackoverflow.com/a/32791625 

13class ListTextWidget(forms.TextInput): 

14 def __init__(self, data_list, name, *args, **kwargs): 

15 super(ListTextWidget, self).__init__(*args, **kwargs) 

16 self._name = name 

17 self._list = data_list 

18 self.attrs.update({'list': 'list__%s' % self._name}) 

19 

20 def render(self, name, value, attrs=None, renderer=None): 

21 text_html = super(ListTextWidget, self).render(name, value, attrs=attrs) 

22 data_list = '<datalist id="list__%s">' % self._name 

23 for item in self._list: 

24 data_list += '<option value="%s">' % item 

25 data_list += '</datalist>' 

26 

27 return (text_html + data_list) 

28 

29 

30class ProfileForm(forms.Form): 

31 first_name = forms.CharField(label='First Name', max_length=100) 

32 last_name = forms.CharField(label='Last Name', max_length=100) 

33 

34 affiliation = forms.CharField(label='Affiliation', help_text="Leave empty for hobbyists", 

35 max_length=80, required=False) 

36 public_statement = forms.CharField(widget=forms.Textarea, label="Public Statement", 

37 help_text="This information will be shown only to other members and admins") 

38 

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

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

41 

42 # Override the widget now to ensure the list is fetched every time this object is created 

43 self.fields['affiliation'].widget = ListTextWidget(data_list=Profile.existing_affiliations(), 

44 name='affiliations-list') 

45 

46 def save(self, user): 

47 if self.is_valid(): 

48 user.first_name = self.cleaned_data['first_name'] 

49 user.last_name = self.cleaned_data['last_name'] 

50 user.profile.affiliation = self.cleaned_data['affiliation'] 

51 user.profile.public_statement = self.cleaned_data['public_statement'] 

52 user.save() 

53 

54 

55class MembershipApplicationForm(forms.Form): 

56 period_id = forms.IntegerField(required=True, widget=forms.HiddenInput()) 

57 

58 user_id = forms.IntegerField(required=True, widget=forms.HiddenInput()) 

59 

60 public_statement = forms.CharField(widget=forms.Textarea, label="Public Statement", 

61 help_text="Update your profile's public statement, which will be " 

62 "used by administrators to accept/deny your application " 

63 "and will be seen by other members") 

64 

65 agree_membership = forms.BooleanField(label="I have read and agreed with the membership agreement", required=True) 

66 

67 def save(self): 

68 if self.is_valid() and self.cleaned_data['agree_membership'] is True: 

69 user = get_object_or_404(User, pk=self.cleaned_data['user_id']) 

70 period = get_object_or_404(MembershipPeriod, pk=self.cleaned_data['period_id']) 

71 

72 # Update the user profile 

73 user.profile.public_statement = self.cleaned_data['public_statement'] 

74 user.save() 

75 

76 return Membership.objects.create(period=period, user_profile=user.profile) 

77 else: 

78 return None 

79 

80 

81class OptionRankingSubField(forms.Select): 

82 template_name = 'gui/widgets/option_ranking_subfield.html' 

83 

84 def __init__(self, rank, *args, **kwargs): 

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

86 self.rank = rank 

87 

88 def get_context(self, name, value, attrs): 

89 context = super().get_context(name, value, attrs) 

90 context['widget']['rank'] = self.rank 

91 return context 

92 

93 

94class OptionRankingWidget(forms.MultiWidget): 

95 # NOTICE: the attribute `field` should be set by the MultiValueField 

96 # referencing this widget. See OptionRankingField.__createMultiWidget. 

97 def __init__(self, attr=None): 

98 widgets = [] 

99 for i, option in enumerate(self.field.options): 

100 # Only add non-abstain 

101 if option[0] >= 0: 

102 widgets.append(OptionRankingSubField(rank=i+1, choices=self.field.select_choices)) 

103 

104 if attr is None: # pragma: nocover 

105 attr = {} 

106 attr["data-ranking-id"] = self.field.ranking.pk 

107 

108 super().__init__(widgets, attr) 

109 

110 def decompress(self, value): 

111 if value: 

112 return value 

113 return [None] * len(self.widgets) 

114 

115 

116class OptionRankingField(forms.MultiValueField): 

117 @cached_property 

118 def options(self): 

119 return [(o.id, o.option) for o in self.ranking.possible_options.all()] + [(-1, '-- Abstain --')] 

120 

121 @cached_property 

122 def select_choices(self): 

123 return [('', '-- Select an option --')] + self.options 

124 

125 def __createMultiWidget(self): 

126 class C(OptionRankingWidget): 

127 field = self 

128 return C 

129 

130 def __init__(self, ranking, *args, **kwargs): 

131 self.ranking = ranking 

132 

133 fields = [] 

134 for i, option in enumerate(self.options): 

135 if option[0] >= 0: 

136 fields.append(forms.ChoiceField(required=True, choices=self.options, label=str(i))) 

137 

138 self.widget = self.__createMultiWidget() 

139 super().__init__(fields=fields, *args, **kwargs) 

140 

141 def compress(self, data_list): 

142 return data_list 

143 

144 

145class BallotForm(forms.Form): 

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

147 try: 

148 self.ballot = kwargs.pop('ballot') 

149 except KeyError: 

150 self.ballot = None 

151 

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

153 

154 if self.ballot is None: 

155 return 

156 

157 self.ranking_questions = dict() 

158 for i, ranking in enumerate(self.ballot.ranking_questions.all()): 

159 ranking_field = OptionRankingField(ranking, required=True, 

160 label=ranking.question) 

161 field_name = "ranking_{}".format(i) 

162 self.fields[field_name] = ranking_field 

163 self.ranking_questions[ranking] = field_name 

164 

165 # Construct the form based on the questions found in the ballot. Start with the select questions 

166 self.select_questions = dict() 

167 for i, select in enumerate(self.ballot.select_questions.all()): 

168 fixed_answers = [('', '-- Select an answer --')] 

169 answers = fixed_answers + [(a.id, a.answer) for a in select.possible_answers.all()] 

170 select_field = forms.ChoiceField(required=True, choices=answers, 

171 label=select.question) 

172 field_name = "select_{}".format(i) 

173 self.fields[field_name] = select_field 

174 self.select_questions[select] = field_name 

175 

176 def _get_objects(self, name, Model, ids, fail_on_duplicates=True): 

177 try: 

178 ids = [int(id) for id in ids] 

179 except ValueError: 

180 self.add_error(name, "The selected ids are not integer") 

181 return None 

182 

183 ids_without_abstain = [i for i in ids if i >= 0] 

184 if fail_on_duplicates and len(set(ids_without_abstain)) != len(ids_without_abstain): 

185 self.add_error(name, "Options cannot be duplicated") 

186 return None 

187 

188 objects = [] 

189 has_abstains = False 

190 for obj_id in ids: 

191 obj = Model.objects.filter(pk=obj_id).first() 

192 if obj_id >= 0: 

193 # This is a non-abstain option 

194 if has_abstains: 

195 self.add_error(name, "Only the lowest-ranking options can be abstained") 

196 return None 

197 elif obj is None: 

198 self.add_error(name, "At least one selected item is invalid") 

199 return None 

200 else: 

201 # This is an abstain option 

202 has_abstains = True 

203 

204 objects.append(obj) 

205 

206 return objects 

207 

208 def clean(self): 

209 cleaned_data = super().clean() 

210 

211 for name in list(cleaned_data.keys()): 

212 if name.startswith('select_'): 

213 objs = self._get_objects(name, BallotSelectQuestionPossibleAnswer, [cleaned_data[name]]) 

214 if objs is not None and len(objs) == 1: 

215 cleaned_data[name] = objs[0] 

216 else: 

217 # This case cannot happen because the form is already checking 

218 # that the value returned is one we generated 

219 pass # pragma: no cover 

220 elif name.startswith('ranking_'): 

221 cleaned_data[name] = self._get_objects(name, BallotRankingQuestionOption, cleaned_data[name]) 

222 else: 

223 # Not a possible case since the super().clean already scrubbed the fieldnames 

224 pass # pragma: no cover 

225 

226 return cleaned_data 

227 

228 @transaction.atomic 

229 def save(self, member): 

230 if not self.is_valid(): 

231 return False 

232 

233 # Create all the ranking answers 

234 for question, field in self.ranking_questions.items(): 

235 for rank, option in enumerate(self.cleaned_data[field]): 

236 BallotRankingAnswer.objects.create(question=question, member=member, option=option, rank=rank + 1) 

237 

238 # Create all the select answers 

239 for question, field in self.select_questions.items(): 

240 answer = self.cleaned_data[field] 

241 BallotSelectedAnswer.objects.create(question=question, member=member, answer=answer) 

242 

243 # Finally, say that the member has voted! 

244 self.ballot.voters.add(member) 

245 

246 return True