Coverage for gui/models.py: 100%

248 statements  

« 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 

10 

11from .email import Email, email_context 

12 

13from collections import OrderedDict 

14 

15 

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

25 

26 

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

30 

31 description = models.TextField(help_text="Description of what the period represents. Will be " 

32 "displayed when prompting users to re-new their membership") 

33 

34 agreement_url = models.URLField(help_text="URL to the membership agreement") 

35 

36 start = models.DateTimeField(help_text="Date at which the new membership period starts.") 

37 

38 end = models.DateTimeField(help_text=("Date at which members who did not renew their" 

39 "membership will stop receiving emails")) 

40 

41 @classmethod 

42 def active_period_at(cls, datetime): 

43 return MembershipPeriod.objects.filter(start__lt=datetime, end__gt=datetime).first() 

44 

45 @classmethod 

46 def current_period(cls): 

47 return MembershipPeriod.objects.exclude(start__gt=timezone.now()).order_by("start").last() 

48 

49 @property 

50 def members(self): 

51 return Membership.objects.filter(period=self).exclude(approved_on=None, rejected_on=None) 

52 

53 def __str__(self): 

54 return "period '{}' starting on {}".format(self.short_name, self.start) 

55 

56 

57class Membership(models.Model): 

58 period = models.ForeignKey(MembershipPeriod, on_delete=models.CASCADE) 

59 user_profile = models.ForeignKey("Profile", on_delete=models.CASCADE) 

60 

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

65 

66 class Meta: 

67 unique_together = ('period', 'user_profile') 

68 

69 @classmethod 

70 def pending_memberships(self): 

71 return Membership.objects.filter(approved_on=None, rejected_on=None).order_by('id') 

72 

73 @property 

74 def is_pending(self): 

75 return self.approved_on is None and self.rejected_on is None 

76 

77 @property 

78 def is_approved(self): 

79 return self.approved_on is not None and self.rejected_on is None 

80 

81 @property 

82 def is_rejected(self): 

83 return self.rejected_on is not None 

84 

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

95 

96 def approve(self): 

97 if self.approved_on is not None: 

98 raise ValueError('The membership has already been approved') 

99 

100 self.approved_on = timezone.now() 

101 self.save() 

102 

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

108 

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

114 

115 self.rejected_on = timezone.now() 

116 self.rejection_reason = reason 

117 self.save() 

118 

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

124 

125 def __str__(self): 

126 return "{}'s membership for the period {} ({})".format(self.user_profile, self.period.short_name, 

127 self.status) 

128 

129 

130class Profile(models.Model): 

131 user = models.OneToOneField(User, on_delete=models.CASCADE) 

132 

133 memberships = models.ManyToManyField(MembershipPeriod, through="Membership") 

134 

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

140 

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

144 

145 @receiver(post_save, sender=User) 

146 def create_member(sender, instance, created, **kwargs): 

147 if created: 

148 Profile.objects.create(user=instance) 

149 

150 @receiver(post_save, sender=User) 

151 def save_member(sender, instance, **kwargs): 

152 instance.profile.save() 

153 

154 def send_email(self, subject, message): 

155 Email(subject, message, [self.user.email]).send() 

156 

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

163 

164 @cached_property 

165 def last_membership(self): 

166 return self.memberships.exclude(membership__approved_on=None).last() 

167 

168 @cached_property 

169 def active_ballots(self): 

170 return [(b, b.has_voted(self.membership)) for b in Ballot.active_ballots()] 

171 

172 def __str__(self): 

173 return "{} <{}>".format(self.user.get_full_name(), self.user.email) 

174 

175 

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

180 

181 self.count = count 

182 self.total = total 

183 

184 @property 

185 def percentage(self): 

186 if self.total > 0: 

187 return self.count * 100.0 / self.total 

188 else: 

189 return 0 

190 

191 def __eq__(self, other): 

192 return self.count == other.count and self.total == other.total 

193 

194 def __repr__(self): 

195 return "VoteCount({}, {})".format(self.count, self.total) 

196 

197 def __str__(self): 

198 return "{:.1f}% ({} / {})".format(self.percentage, self.count, self.total) 

199 

200 

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

207 

208 created_on = models.DateTimeField(auto_now_add=True) 

209 opening_on = models.DateTimeField() 

210 closing_on = models.DateTimeField() 

211 

212 voters = models.ManyToManyField(Membership, help_text="List of members who voted on this ballot") 

213 

214 @classmethod 

215 def active_ballots(self): 

216 return Ballot.objects.filter(opening_on__lte=timezone.now(), closing_on__gt=timezone.now()) 

217 

218 @cached_property 

219 def period(self): 

220 return MembershipPeriod.active_period_at(self.created_on) 

221 

222 @cached_property 

223 def potential_voters(self): 

224 if self.period is None: 

225 return set() 

226 

227 return set(self.period.members.exclude(approved_on__gt=self.closing_on)) 

228 

229 @cached_property 

230 def missing_voters(self): 

231 return self.potential_voters - set(self.voters.all()) 

232 

233 @property 

234 def is_active(self): 

235 now = timezone.now() 

236 return now >= self.opening_on and now < self.closing_on 

237 

238 @property 

239 def has_started(self): 

240 return timezone.now() > self.opening_on 

241 

242 @property 

243 def has_closed(self): 

244 return timezone.now() > self.closing_on 

245 

246 def has_voted(self, membership): 

247 return self.voters.filter(pk=membership.pk).exists() 

248 

249 @cached_property 

250 def turnout(self): 

251 if self.period is None: 

252 return VoteCount(0, 0) 

253 

254 total = len(self.potential_voters) 

255 count = self.voters.all().count() 

256 return VoteCount(count, total) 

257 

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

261 

262 for member in self.missing_voters: 

263 member.user_profile.send_email(title, message) 

264 

265 def __str__(self): 

266 return "Ballot<{}>".format(self.short_name) 

267 

268 

269# Answer selection 

270class BallotSelectQuestion(models.Model): 

271 ballot = models.ForeignKey('Ballot', on_delete=models.CASCADE, related_name="select_questions") 

272 

273 question = models.CharField(max_length=255, blank=True, null=True, 

274 help_text="Question asking members to select one in multiple answers") 

275 

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

280 

281 ret = OrderedDict() 

282 

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) 

287 

288 return ret 

289 

290 def __str__(self): 

291 return "{}: {}".format(self.ballot, self.question) 

292 

293 

294class BallotSelectQuestionPossibleAnswer(models.Model): 

295 question = models.ForeignKey(BallotSelectQuestion, on_delete=models.CASCADE, related_name="possible_answers") 

296 

297 answer = models.CharField(max_length=100, help_text="One possible answer") 

298 

299 def __str__(self): 

300 return "{}: {}".format(self.question, self.answer) 

301 

302 

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) 

308 

309 class Meta: 

310 unique_together = ('question', 'member') 

311 

312 def __str__(self): 

313 return "{} - '{}' selected '{}'".format(self.question, self.member.user_profile.user.email, self.answer.answer) 

314 

315 

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 

321 

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'] 

326 

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 

333 

334 def __str__(self): 

335 return "{} - score={}".format(self.ranking_option, self.score) 

336 

337 

338class BallotRankingQuestion(models.Model): 

339 ballot = models.ForeignKey('Ballot', on_delete=models.CASCADE, related_name="ranking_questions") 

340 

341 question = models.CharField(max_length=255, blank=True, null=True, 

342 help_text="Question asking members to rank multiple options") 

343 

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

348 

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) 

352 

353 def __str__(self): 

354 return "{}: {}".format(self.ballot, self.question) 

355 

356 

357class BallotRankingQuestionOption(models.Model): 

358 question = models.ForeignKey(BallotRankingQuestion, on_delete=models.CASCADE, related_name="possible_options") 

359 

360 option = models.CharField(max_length=100, help_text="One option to be ranked") 

361 

362 def __str__(self): 

363 return "{}: {}".format(self.question, self.option) 

364 

365 

366class BallotRankingAnswer(models.Model): 

367 question = models.ForeignKey(BallotRankingQuestion, on_delete=models.CASCADE) 

368 member = models.ForeignKey(Membership, on_delete=models.CASCADE) 

369 

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) 

373 

374 class Meta: 

375 unique_together = ('question', 'member', 'rank') 

376 

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