Coverage for gui/forms.py: 100%
160 statements
« prev ^ index » next coverage.py v7.2.1, created at 2023-03-14 06:24 +0000
« 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
7from .models import Membership, MembershipPeriod, Profile
8from .models import BallotSelectQuestionPossibleAnswer, BallotRankingQuestionOption
9from .models import BallotRankingAnswer, BallotSelectedAnswer
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})
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>'
27 return (text_html + data_list)
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)
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")
39 def __init__(self, *args, **kwargs):
40 super().__init__(*args, **kwargs)
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')
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()
55class MembershipApplicationForm(forms.Form):
56 period_id = forms.IntegerField(required=True, widget=forms.HiddenInput())
58 user_id = forms.IntegerField(required=True, widget=forms.HiddenInput())
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")
65 agree_membership = forms.BooleanField(label="I have read and agreed with the membership agreement", required=True)
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'])
72 # Update the user profile
73 user.profile.public_statement = self.cleaned_data['public_statement']
74 user.save()
76 return Membership.objects.create(period=period, user_profile=user.profile)
77 else:
78 return None
81class OptionRankingSubField(forms.Select):
82 template_name = 'gui/widgets/option_ranking_subfield.html'
84 def __init__(self, rank, *args, **kwargs):
85 super().__init__(*args, **kwargs)
86 self.rank = rank
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
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))
104 if attr is None: # pragma: nocover
105 attr = {}
106 attr["data-ranking-id"] = self.field.ranking.pk
108 super().__init__(widgets, attr)
110 def decompress(self, value):
111 if value:
112 return value
113 return [None] * len(self.widgets)
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 --')]
121 @cached_property
122 def select_choices(self):
123 return [('', '-- Select an option --')] + self.options
125 def __createMultiWidget(self):
126 class C(OptionRankingWidget):
127 field = self
128 return C
130 def __init__(self, ranking, *args, **kwargs):
131 self.ranking = ranking
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)))
138 self.widget = self.__createMultiWidget()
139 super().__init__(fields=fields, *args, **kwargs)
141 def compress(self, data_list):
142 return data_list
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
152 super().__init__(*args, **kwargs)
154 if self.ballot is None:
155 return
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
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
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
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
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
204 objects.append(obj)
206 return objects
208 def clean(self):
209 cleaned_data = super().clean()
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
226 return cleaned_data
228 @transaction.atomic
229 def save(self, member):
230 if not self.is_valid():
231 return False
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)
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)
243 # Finally, say that the member has voted!
244 self.ballot.voters.add(member)
246 return True