Coverage for gui/models.py: 100%
248 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.contrib.auth.models import User
2from django.contrib.humanize.templatetags.humanize import ordinal
3from django.db import models
4from django.db.models import Count
5from django.db.models.signals import post_save
6from django.dispatch import receiver
7from django.template.loader import render_to_string
8from django.utils.functional import cached_property
9from django.utils import timezone
11from .email import Email, email_context
13from collections import OrderedDict
16class Link(models.Model):
17 short_name = models.CharField(max_length=100,
18 help_text="Short name to describe the link")
19 description = models.CharField(max_length=255, blank=True, null=True,
20 help_text="Longer description")
21 index_position = models.IntegerField(help_text="Smaller index means it will "
22 "be higher in the list",
23 default=0)
24 url = models.URLField(help_text="Path to the document/website you want to link")
27class MembershipPeriod(models.Model):
28 short_name = models.CharField(max_length=50, unique=True,
29 help_text="Name/title of the period. Eg. '2017-2018'")
31 description = models.TextField(help_text="Description of what the period represents. Will be "
32 "displayed when prompting users to re-new their membership")
34 agreement_url = models.URLField(help_text="URL to the membership agreement")
36 start = models.DateTimeField(help_text="Date at which the new membership period starts.")
38 end = models.DateTimeField(help_text=("Date at which members who did not renew their"
39 "membership will stop receiving emails"))
41 @classmethod
42 def active_period_at(cls, datetime):
43 return MembershipPeriod.objects.filter(start__lt=datetime, end__gt=datetime).first()
45 @classmethod
46 def current_period(cls):
47 return MembershipPeriod.objects.exclude(start__gt=timezone.now()).order_by("start").last()
49 @property
50 def members(self):
51 return Membership.objects.filter(period=self).exclude(approved_on=None, rejected_on=None)
53 def __str__(self):
54 return "period '{}' starting on {}".format(self.short_name, self.start)
57class Membership(models.Model):
58 period = models.ForeignKey(MembershipPeriod, on_delete=models.CASCADE)
59 user_profile = models.ForeignKey("Profile", on_delete=models.CASCADE)
61 applied_on = models.DateTimeField(auto_now_add=True)
62 approved_on = models.DateTimeField(null=True, blank=True, db_index=True)
63 rejected_on = models.DateTimeField(null=True, blank=True, db_index=True)
64 rejection_reason = models.TextField(null=True, blank=True, help_text="Reason for the rejection")
66 class Meta:
67 unique_together = ('period', 'user_profile')
69 @classmethod
70 def pending_memberships(self):
71 return Membership.objects.filter(approved_on=None, rejected_on=None).order_by('id')
73 @property
74 def is_pending(self):
75 return self.approved_on is None and self.rejected_on is None
77 @property
78 def is_approved(self):
79 return self.approved_on is not None and self.rejected_on is None
81 @property
82 def is_rejected(self):
83 return self.rejected_on is not None
85 @property
86 def status(self):
87 if self.is_pending:
88 return "Pending approval"
89 elif self.is_approved:
90 return "Approved on {}".format(self.approved_on.date())
91 elif self.is_rejected:
92 return "Rejected on {}".format(self.rejected_on.date())
93 else:
94 raise ValueError("Unknown state")
96 def approve(self):
97 if self.approved_on is not None:
98 raise ValueError('The membership has already been approved')
100 self.approved_on = timezone.now()
101 self.save()
103 # Send a confirmation email
104 context = email_context(membership=self)
105 self.user_profile.send_email('Your application has been approved!',
106 render_to_string('gui/email/application_accepted_message.txt',
107 context))
109 def reject(self, reason):
110 if self.rejected_on is not None:
111 raise ValueError('The membership has already been rejected')
112 if reason is None or len(reason) == 0:
113 raise ValueError('A membership cannot be rejected without a reason')
115 self.rejected_on = timezone.now()
116 self.rejection_reason = reason
117 self.save()
119 # Send a rejection email
120 context = email_context(membership=self)
121 self.user_profile.send_email('Your application has been rejected...',
122 render_to_string('gui/email/application_rejected_message.txt',
123 context))
125 def __str__(self):
126 return "{}'s membership for the period {} ({})".format(self.user_profile, self.period.short_name,
127 self.status)
130class Profile(models.Model):
131 user = models.OneToOneField(User, on_delete=models.CASCADE)
133 memberships = models.ManyToManyField(MembershipPeriod, through="Membership")
135 affiliation = models.CharField(max_length=80, blank=True,
136 help_text="Current affiliation (leave empty for hobbyists or "
137 "if you want to keep this private)")
138 public_statement = models.TextField(blank=True,
139 help_text="This information will be displayed to other members")
141 @classmethod
142 def existing_affiliations(cls):
143 return sorted(set([e[0] for e in Profile.objects.values_list('affiliation') if len(e[0]) > 0]))
145 @receiver(post_save, sender=User)
146 def create_member(sender, instance, created, **kwargs):
147 if created:
148 Profile.objects.create(user=instance)
150 @receiver(post_save, sender=User)
151 def save_member(sender, instance, **kwargs):
152 instance.profile.save()
154 def send_email(self, subject, message):
155 Email(subject, message, [self.user.email]).send()
157 @cached_property
158 def membership(self):
159 cur_period = MembershipPeriod.current_period()
160 if cur_period is None:
161 return None
162 return Membership.objects.filter(user_profile=self, period=cur_period).first()
164 @cached_property
165 def last_membership(self):
166 return self.memberships.exclude(membership__approved_on=None).last()
168 @cached_property
169 def active_ballots(self):
170 return [(b, b.has_voted(self.membership)) for b in Ballot.active_ballots()]
172 def __str__(self):
173 return "{} <{}>".format(self.user.get_full_name(), self.user.email)
176class VoteCount:
177 def __init__(self, count, total):
178 if count > total:
179 raise ValueError("The count of votes cannot be greater than the total amount of votes")
181 self.count = count
182 self.total = total
184 @property
185 def percentage(self):
186 if self.total > 0:
187 return self.count * 100.0 / self.total
188 else:
189 return 0
191 def __eq__(self, other):
192 return self.count == other.count and self.total == other.total
194 def __repr__(self):
195 return "VoteCount({}, {})".format(self.count, self.total)
197 def __str__(self):
198 return "{:.1f}% ({} / {})".format(self.percentage, self.count, self.total)
201class Ballot(models.Model):
202 short_name = models.CharField(max_length=100, unique=True,
203 help_text="Short name to describe the ballot")
204 description = models.TextField(blank=True, null=True,
205 help_text="Longer description of the ballot containing all the "
206 "relevant information for members to use to vote.")
208 created_on = models.DateTimeField(auto_now_add=True)
209 opening_on = models.DateTimeField()
210 closing_on = models.DateTimeField()
212 voters = models.ManyToManyField(Membership, help_text="List of members who voted on this ballot")
214 @classmethod
215 def active_ballots(self):
216 return Ballot.objects.filter(opening_on__lte=timezone.now(), closing_on__gt=timezone.now())
218 @cached_property
219 def period(self):
220 return MembershipPeriod.active_period_at(self.created_on)
222 @cached_property
223 def potential_voters(self):
224 if self.period is None:
225 return set()
227 return set(self.period.members.exclude(approved_on__gt=self.closing_on))
229 @cached_property
230 def missing_voters(self):
231 return self.potential_voters - set(self.voters.all())
233 @property
234 def is_active(self):
235 now = timezone.now()
236 return now >= self.opening_on and now < self.closing_on
238 @property
239 def has_started(self):
240 return timezone.now() > self.opening_on
242 @property
243 def has_closed(self):
244 return timezone.now() > self.closing_on
246 def has_voted(self, membership):
247 return self.voters.filter(pk=membership.pk).exists()
249 @cached_property
250 def turnout(self):
251 if self.period is None:
252 return VoteCount(0, 0)
254 total = len(self.potential_voters)
255 count = self.voters.all().count()
256 return VoteCount(count, total)
258 def send_reminder(self):
259 title = "Please cast your vote for the '{}' vote".format(self.short_name)
260 message = render_to_string('gui/email/ballot_reminder_message.txt', email_context(ballot=self))
262 for member in self.missing_voters:
263 member.user_profile.send_email(title, message)
265 def __str__(self):
266 return "Ballot<{}>".format(self.short_name)
269# Answer selection
270class BallotSelectQuestion(models.Model):
271 ballot = models.ForeignKey('Ballot', on_delete=models.CASCADE, related_name="select_questions")
273 question = models.CharField(max_length=255, blank=True, null=True,
274 help_text="Question asking members to select one in multiple answers")
276 @cached_property
277 def tally(self):
278 if not self.ballot.has_closed:
279 raise ValueError("No tally can be performed until the ballot has closed")
281 ret = OrderedDict()
283 total_votes = self.selected_answers.count()
284 possible_answers = BallotSelectQuestionPossibleAnswer.objects.filter(question=self)
285 for answer in possible_answers.annotate(vote_count=Count('votes')).order_by('-vote_count'):
286 ret[answer] = VoteCount(answer.vote_count, total_votes)
288 return ret
290 def __str__(self):
291 return "{}: {}".format(self.ballot, self.question)
294class BallotSelectQuestionPossibleAnswer(models.Model):
295 question = models.ForeignKey(BallotSelectQuestion, on_delete=models.CASCADE, related_name="possible_answers")
297 answer = models.CharField(max_length=100, help_text="One possible answer")
299 def __str__(self):
300 return "{}: {}".format(self.question, self.answer)
303class BallotSelectedAnswer(models.Model):
304 question = models.ForeignKey(BallotSelectQuestion, on_delete=models.CASCADE, related_name="selected_answers")
305 answer = models.ForeignKey(BallotSelectQuestionPossibleAnswer, on_delete=models.CASCADE,
306 null=True, related_name="votes")
307 member = models.ForeignKey(Membership, on_delete=models.CASCADE)
309 class Meta:
310 unique_together = ('question', 'member')
312 def __str__(self):
313 return "{} - '{}' selected '{}'".format(self.question, self.member.user_profile.user.email, self.answer.answer)
316# Ranking selection
317class OptionRankingVotes:
318 def __init__(self, ranking_option, option_count):
319 self.ranking_option = ranking_option
320 self.option_count = option_count
322 # Initialize an array indexed by the rank with 0s
323 self.count_by_rank = [0] * option_count
324 for e in ranking_option.votes.values('rank').annotate(count=Count('rank')):
325 self.count_by_rank[e['rank'] - 1] = e['count']
327 @cached_property
328 def score(self):
329 score = 0
330 for rank, count in enumerate(self.count_by_rank):
331 score += (self.option_count - rank) * count
332 return score
334 def __str__(self):
335 return "{} - score={}".format(self.ranking_option, self.score)
338class BallotRankingQuestion(models.Model):
339 ballot = models.ForeignKey('Ballot', on_delete=models.CASCADE, related_name="ranking_questions")
341 question = models.CharField(max_length=255, blank=True, null=True,
342 help_text="Question asking members to rank multiple options")
344 @cached_property
345 def tally(self):
346 if not self.ballot.has_closed:
347 raise ValueError("No tally can be performed until the ballot has closed")
349 p_opts = self.possible_options.all()
350 options = [OptionRankingVotes(o, len(p_opts)) for o in p_opts]
351 return sorted(options, key=lambda o: o.score, reverse=True)
353 def __str__(self):
354 return "{}: {}".format(self.ballot, self.question)
357class BallotRankingQuestionOption(models.Model):
358 question = models.ForeignKey(BallotRankingQuestion, on_delete=models.CASCADE, related_name="possible_options")
360 option = models.CharField(max_length=100, help_text="One option to be ranked")
362 def __str__(self):
363 return "{}: {}".format(self.question, self.option)
366class BallotRankingAnswer(models.Model):
367 question = models.ForeignKey(BallotRankingQuestion, on_delete=models.CASCADE)
368 member = models.ForeignKey(Membership, on_delete=models.CASCADE)
370 option = models.ForeignKey(BallotRankingQuestionOption, on_delete=models.CASCADE, related_name="votes",
371 blank=True, null=True)
372 rank = models.PositiveSmallIntegerField(help_text="Rank this option got", default=0)
374 class Meta:
375 unique_together = ('question', 'member', 'rank')
377 def __str__(self):
378 if self.option is not None:
379 return "{}: '{}' - '{}' was ranked {}".format(self.question, self.member.user_profile.user.email,
380 self.option.option, ordinal(self.rank))
381 else:
382 return "{}: '{}' - abstained for {} rank".format(self.question, self.member.user_profile.user.email,
383 ordinal(self.rank))