from random import shuffle
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group as DjangoGroup
from django.db import models
from django.db.models import Manager, Q
from django.utils import timezone
from django.utils.html import strip_tags
from ...utils.date import get_date_range_this_year
[docs]class PollQuerySet(models.query.QuerySet):
[docs] def this_year(self):
"""Get Polls from this school year only."""
start_date, end_date = get_date_range_this_year()
return self.filter(start_time__gte=start_date, start_time__lte=end_date)
[docs]class PollManager(Manager):
[docs] def get_queryset(self):
return PollQuerySet(self.model, using=self._db)
[docs] def visible_to_user(self, user):
"""Get a list of visible polls for a given user (usually request.user).
These visible polls will be those that either have no groups
assigned to them (and are therefore public) or those in which
the user is a member.
"""
return Poll.objects.filter(visible=True).filter(Q(groups__in=user.groups.all()) | Q(groups__isnull=True)).distinct()
[docs]class Poll(models.Model):
"""A Poll, for the TJ community.
Attributes:
title
A title for the poll, that will be displayed to identify it uniquely.
description
A longer description, possibly explaining how to complete the poll.
start_time
A time that the poll should open.
end_time
A time that the poll should close.
visible
Whether the poll is visible to the users it is for.
is_secret
Whether the poll is a 'secret' poll. Poll admins will not be able to view individual
user responses for secret polls.
is_election
Whether the poll is an election.
groups
The Groups that can view and vote in the poll. Like Announcements,
if there are none set, then it is public to all.
Access questions for the poll through poll.question_set.all()
"""
objects = PollManager()
title = models.CharField(max_length=100)
description = models.CharField(max_length=500)
start_time = models.DateTimeField()
end_time = models.DateTimeField()
visible = models.BooleanField(default=False)
is_secret = models.BooleanField(default=False)
is_election = models.BooleanField(default=False)
groups = models.ManyToManyField(DjangoGroup, blank=True)
# Access questions through .question_set
[docs] def before_end_time(self):
"""Has the poll not ended yet?"""
now = timezone.now()
return now < self.end_time
[docs] def before_start_time(self):
"""Has the poll not started yet?"""
now = timezone.now()
return now < self.start_time
[docs] def in_time_range(self):
"""Is it within the poll time range?"""
return not self.before_start_time() and self.before_end_time()
[docs] def get_users_voted(self):
users = []
for q in self.question_set.all():
if users:
users = list(set(q.get_users_voted()) | set(users))
else:
users = list(q.get_users_voted())
return users
[docs] def get_num_eligible_voters(self):
if self.groups.exists():
return get_user_model().objects.exclude(user_type="service").filter(groups__poll=self).distinct().count()
else:
return get_user_model().objects.exclude(user_type="service").count()
[docs] def get_percentage_voted(self, voted, able):
return f"{0 if able == 0 else voted / able:.1%}"
[docs] def get_voted_string(self):
users_voted = len(self.get_users_voted())
users_able = self.get_num_eligible_voters()
percent = self.get_percentage_voted(users_voted, users_able)
return f"{users_voted} out of {users_able} ({percent}) eligible users voted in this poll."
[docs] def has_user_voted(self, user):
return Answer.objects.filter(question__in=self.question_set.all(), user=user).count() == self.question_set.count()
[docs] def can_vote(self, user):
if user.has_admin_permission("polls"):
return True
if not self.visible:
return False
if not self.in_time_range():
return False
if not self.groups.exists():
return True
return user.groups.intersection(self.groups.all()).exists()
def __str__(self):
return self.title
[docs]class Question(models.Model):
"""A question for a Poll.
Attributes:
poll
A ForeignKey to the Poll object the question is for.
question
A text field for entering the question, of which there are choices
the user can make.
num
An integer order in which the question should appear; the primary sort.
type
One of:
Question.STD: Standard
Question.ELECTION: Election (randomized choice order)
Question.RANK: Rank choice election
Question.APP: Approval (can select up to max_choices entries)
Question.SPLIT_APP: Split approval
Question.FREE_RESP: Free response
Question.STD_OTHER: Standard Other field
max_choices
The maximum number of choices that can be selected. Only applies for approval questions.
Access possible choices for this question through question.choice_set.all()
"""
poll = models.ForeignKey(Poll, on_delete=models.CASCADE)
question = models.CharField(max_length=500)
num = models.IntegerField()
STD = "STD"
ELECTION = "ELC"
RANK = "RAN"
APP = "APP"
SPLIT_APP = "SAP"
FREE_RESP = "FRE"
SHORT_RESP = "SRE"
STD_OTHER = "STO"
TYPE = (
(STD, "Standard"),
(ELECTION, "Election"),
(RANK, "Rank choice"),
(APP, "Approval"),
(SPLIT_APP, "Split approval"),
(FREE_RESP, "Free response"),
(SHORT_RESP, "Short response"),
(STD_OTHER, "Standard other"),
)
type = models.CharField(max_length=3, choices=TYPE, default=STD)
max_choices = models.IntegerField(default=1)
[docs] def is_writing(self):
return self.type in [Question.FREE_RESP, Question.SHORT_RESP]
[docs] def is_single_choice(self):
return self.type in [Question.STD, Question.ELECTION, Question.STD_OTHER]
[docs] def is_rank_choice(self):
return self.type in [Question.RANK]
[docs] def is_many_choice(self):
return self.type in [Question.APP, Question.SPLIT_APP]
[docs] def is_choice(self):
return self.type in [Question.STD, Question.ELECTION, Question.STD_OTHER, Question.RANK, Question.APP, Question.SPLIT_APP]
[docs] def trunc_question(self):
comp = strip_tags(self.question)
if len(comp) > 50:
return comp[:47] + "..."
else:
return comp
[docs] def get_users_voted(self):
users = Answer.objects.filter(question=self).values_list("user", flat=True)
return get_user_model().objects.filter(id__in=users)
[docs] def get_total_votes(self):
return sum(a.rank for a in Answer.objects.filter(question=self)) if self.is_rank_choice() else Answer.objects.filter(question=self).count()
def __str__(self):
# return "{} + #{} ('{}')".format(self.poll, self.num, self.trunc_question())
return f"Question #{self.num}: '{self.trunc_question()}'"
[docs] @classmethod
def get_question_types(cls):
return {t[0]: t[1] for t in cls.TYPE}
@property
def random_choice_set(self):
choices = list(self.choice_set.all())
shuffle(choices)
return choices
class Meta:
ordering = ["num"]
[docs]class Choice(models.Model): # individual answer choices
"""A choice for a Question.
Attributes:
question
A ForeignKey to the question this choice is for.
num
An integer order in which the choice should appear; the primary sort.
info
Textual information about this answer choice.
"""
question = models.ForeignKey(Question, on_delete=models.CASCADE)
num = models.IntegerField()
info = models.CharField(max_length=1000)
[docs] def trunc_info(self):
comp = strip_tags(self.info)
if len(comp) > 50:
return comp[:47] + "..."
else:
return comp
[docs] def display_name(self):
return self.trunc_info().split("|", maxsplit=1)[0].strip()
def __str__(self):
# return "{} + O#{}('{}')".format(self.question, self.num, self.trunc_info())
return f"Option #{self.num}: {self.trunc_info()}"
class Meta:
ordering = ["num"]
[docs]class Answer(models.Model): # individual answer choices selected
question = models.ForeignKey(Question, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)
choice = models.ForeignKey(Choice, null=True, on_delete=models.CASCADE) # for multiple choice questions
answer = models.CharField(max_length=10000, null=True) # for free response
clear_vote = models.BooleanField(default=False)
other_vote = models.BooleanField(default=False) # for standard other questions
weight = models.DecimalField(max_digits=4, decimal_places=3, default=1) # for split approval
rank = models.IntegerField(null=True) # for rank choice
[docs] def display_votes(self):
"""Convert rank to votes for displaying by assigning rank 1 the max number of votes and continuing down."""
if self.question.type == Question.RANK:
return self.question.max_choices - self.rank + 1
return None
def __str__(self):
if self.choice:
return f"{self.user} {self.choice}"
elif self.answer:
return f"{self.user} {self.answer[:25]}"
elif self.clear_vote:
return f"{self.user} Clear"
else:
return f"{self.user} None"
[docs]class AnswerVote(models.Model): # record of total selection of a given answer choice
question = models.ForeignKey(Question, on_delete=models.CASCADE)
users = models.ManyToManyField(settings.AUTH_USER_MODEL)
choice = models.ForeignKey(Choice, on_delete=models.CASCADE)
votes = models.DecimalField(max_digits=4, decimal_places=3, default=0) # sum of answer weights
is_writing = models.BooleanField(default=False) # enables distinction between writing/std answers
def __str__(self):
return str(self.choice)