252697b842
Upgrade site and modules to Django 2.2. Remove and replace obsolete functionality with current equivalents. Update requirements to latest versions where possible. Remove unused dependencies.
454 lines
15 KiB
Python
454 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
from django.core.exceptions import ValidationError
|
||
|
||
from django.db import models
|
||
from django.db.models import Q, F
|
||
from django.db.models import Case, When, Value
|
||
from django.db.models import Count
|
||
from django.db.models.signals import post_save
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.utils.translation import ugettext_lazy as _
|
||
|
||
from symposion import constants
|
||
from symposion.text_parser import parse
|
||
from symposion.proposals.models import ProposalBase
|
||
from symposion.schedule.models import Presentation
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
class Votes(object):
|
||
ABSTAIN = "0"
|
||
PLUS_TWO = "+2"
|
||
PLUS_ONE = "+1"
|
||
MINUS_ONE = "-1"
|
||
MINUS_TWO = "-2"
|
||
|
||
CHOICES = [
|
||
(PLUS_TWO, _("+2 — Good proposal and I will argue for it to be accepted.")),
|
||
(PLUS_ONE, _("+1 — OK proposal, but I will not argue for it to be accepted.")),
|
||
(MINUS_ONE, _("−1 — Weak proposal, but I will not argue strongly against acceptance.")),
|
||
(MINUS_TWO, _("−2 — Serious issues and I will argue to reject this proposal.")),
|
||
(ABSTAIN, _("Abstain - I do not want to review this proposal and I do not want to see it again.")),
|
||
]
|
||
|
||
|
||
VOTES = Votes()
|
||
|
||
|
||
class ReviewAssignment(models.Model):
|
||
AUTO_ASSIGNED_INITIAL = 0
|
||
OPT_IN = 1
|
||
AUTO_ASSIGNED_LATER = 2
|
||
|
||
NUM_REVIEWERS = 3
|
||
|
||
ORIGIN_CHOICES = [
|
||
(AUTO_ASSIGNED_INITIAL, _("auto-assigned, initial")),
|
||
(OPT_IN, _("opted-in")),
|
||
(AUTO_ASSIGNED_LATER, _("auto-assigned, later")),
|
||
]
|
||
|
||
proposal = models.ForeignKey(
|
||
ProposalBase,
|
||
verbose_name=_("Proposal"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
user = models.ForeignKey(
|
||
User,
|
||
verbose_name=_("User"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
|
||
origin = models.IntegerField(choices=ORIGIN_CHOICES, verbose_name=_("Origin"))
|
||
|
||
assigned_at = models.DateTimeField(default=datetime.now, verbose_name=_("Assigned at"))
|
||
opted_out = models.BooleanField(default=False, verbose_name=_("Opted out"))
|
||
|
||
@classmethod
|
||
def create_assignments(cls, proposal, origin=AUTO_ASSIGNED_INITIAL):
|
||
speakers = [proposal.speaker] + list(proposal.additional_speakers.all())
|
||
reviewers = User.objects.exclude(
|
||
pk__in=[
|
||
speaker.user_id
|
||
for speaker in speakers
|
||
if speaker.user_id is not None
|
||
] + [
|
||
assignment.user_id
|
||
for assignment in ReviewAssignment.objects.filter(
|
||
proposal_id=proposal.id)]
|
||
).filter(
|
||
groups__name="reviewers",
|
||
).filter(
|
||
Q(reviewassignment__opted_out=False) | Q(reviewassignment=None)
|
||
).annotate(
|
||
num_assignments=models.Count("reviewassignment")
|
||
).order_by(
|
||
"num_assignments", "?",
|
||
)
|
||
num_assigned_reviewers = ReviewAssignment.objects.filter(
|
||
proposal_id=proposal.id, opted_out=0).count()
|
||
for reviewer in reviewers[:max(0, cls.NUM_REVIEWERS - num_assigned_reviewers)]:
|
||
cls._default_manager.create(
|
||
proposal=proposal,
|
||
user=reviewer,
|
||
origin=origin,
|
||
)
|
||
|
||
|
||
class ProposalMessage(models.Model):
|
||
proposal = models.ForeignKey(
|
||
ProposalBase,
|
||
related_name="messages",
|
||
verbose_name=_("Proposal"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
user = models.ForeignKey(
|
||
User,
|
||
verbose_name=_("User"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
|
||
message = models.TextField(verbose_name=_("Message"))
|
||
message_html = models.TextField(blank=True)
|
||
submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at"))
|
||
|
||
def save(self, *args, **kwargs):
|
||
self.message_html = parse(self.message)
|
||
return super(ProposalMessage, self).save(*args, **kwargs)
|
||
|
||
class Meta:
|
||
ordering = ["submitted_at"]
|
||
verbose_name = _("proposal message")
|
||
verbose_name_plural = _("proposal messages")
|
||
|
||
|
||
class Review(models.Model):
|
||
VOTES = VOTES
|
||
|
||
proposal = models.ForeignKey(
|
||
ProposalBase,
|
||
related_name="reviews",
|
||
verbose_name=_("Proposal"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
user = models.ForeignKey(
|
||
User,
|
||
verbose_name=_("User"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
|
||
# No way to encode "-0" vs. "+0" into an IntegerField, and I don't feel
|
||
# like some complicated encoding system.
|
||
vote = models.CharField(max_length=2, blank=True, choices=VOTES.CHOICES, verbose_name=_("Vote"))
|
||
comment = models.TextField(
|
||
blank=True,
|
||
verbose_name=_("Comment")
|
||
)
|
||
comment_html = models.TextField(blank=True)
|
||
submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at"))
|
||
|
||
def clean(self):
|
||
err = {}
|
||
if self.vote != VOTES.ABSTAIN and not self.comment.strip():
|
||
err["comment"] = ValidationError(_("You must provide a comment"))
|
||
|
||
if err:
|
||
raise ValidationError(err)
|
||
|
||
def save(self, **kwargs):
|
||
self.comment_html = parse(self.comment)
|
||
if self.vote:
|
||
vote, created = LatestVote.objects.get_or_create(
|
||
proposal=self.proposal,
|
||
user=self.user,
|
||
defaults=dict(
|
||
vote=self.vote,
|
||
submitted_at=self.submitted_at,
|
||
)
|
||
)
|
||
if not created:
|
||
LatestVote.objects.filter(pk=vote.pk).update(vote=self.vote)
|
||
self.proposal.result.update_vote(self.vote, previous=vote.vote)
|
||
else:
|
||
self.proposal.result.update_vote(self.vote)
|
||
super(Review, self).save(**kwargs)
|
||
|
||
def delete(self):
|
||
model = self.__class__
|
||
user_reviews = model._default_manager.filter(
|
||
proposal=self.proposal,
|
||
user=self.user,
|
||
)
|
||
try:
|
||
# find the latest review
|
||
latest = user_reviews.exclude(pk=self.pk).order_by("-submitted_at")[0]
|
||
except IndexError:
|
||
# did not find a latest which means this must be the only one.
|
||
# treat it as a last, but delete the latest vote.
|
||
self.proposal.result.update_vote(self.vote, removal=True)
|
||
lv = LatestVote.objects.filter(proposal=self.proposal, user=self.user)
|
||
lv.delete()
|
||
else:
|
||
# handle that we've found a latest vote
|
||
# check if self is the lastest vote
|
||
if self == latest:
|
||
# self is the latest review; revert the latest vote to the
|
||
# previous vote
|
||
previous = user_reviews.filter(submitted_at__lt=self.submitted_at)\
|
||
.order_by("-submitted_at")[0]
|
||
self.proposal.result.update_vote(self.vote, previous=previous.vote, removal=True)
|
||
lv = LatestVote.objects.filter(proposal=self.proposal, user=self.user)
|
||
lv.update(
|
||
vote=previous.vote,
|
||
submitted_at=previous.submitted_at,
|
||
)
|
||
else:
|
||
# self is not the latest review so we just need to decrement
|
||
# the comment count
|
||
self.proposal.result.comment_count = F("comment_count") - 1
|
||
self.proposal.result.save()
|
||
# in all cases we need to delete the review; let's do it!
|
||
super(Review, self).delete()
|
||
|
||
def css_class(self):
|
||
return {
|
||
self.VOTES.ABSTAIN: "abstain",
|
||
self.VOTES.PLUS_TWO: "plus-two",
|
||
self.VOTES.PLUS_ONE: "plus-one",
|
||
self.VOTES.MINUS_ONE: "minus-one",
|
||
self.VOTES.MINUS_TWO: "minus-two",
|
||
}[self.vote]
|
||
|
||
@property
|
||
def section(self):
|
||
return self.proposal.kind.section.slug
|
||
|
||
class Meta:
|
||
verbose_name = _("review")
|
||
verbose_name_plural = _("reviews")
|
||
|
||
|
||
class LatestVote(models.Model):
|
||
VOTES = VOTES
|
||
|
||
proposal = models.ForeignKey(
|
||
ProposalBase,
|
||
related_name="votes",
|
||
verbose_name=_("Proposal"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
user = models.ForeignKey(
|
||
User,
|
||
verbose_name=_("User"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
|
||
# No way to encode "-0" vs. "+0" into an IntegerField, and I don't feel
|
||
# like some complicated encoding system.
|
||
vote = models.CharField(max_length=2, choices=VOTES.CHOICES, verbose_name=_("Vote"))
|
||
submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at"))
|
||
|
||
class Meta:
|
||
unique_together = [("proposal", "user")]
|
||
verbose_name = _("latest vote")
|
||
verbose_name_plural = _("latest votes")
|
||
|
||
def css_class(self):
|
||
return {
|
||
self.VOTES.ABSTAIN: "abstain",
|
||
self.VOTES.PLUS_TWO: "plus-two",
|
||
self.VOTES.PLUS_ONE: "plus-one",
|
||
self.VOTES.MINUS_ONE: "minus-one",
|
||
self.VOTES.MINUS_TWO: "minus-two",
|
||
}[self.vote]
|
||
|
||
|
||
class ProposalResult(models.Model):
|
||
proposal = models.OneToOneField(
|
||
ProposalBase,
|
||
related_name="result",
|
||
verbose_name=_("Proposal"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
score = models.DecimalField(max_digits=5, decimal_places=2, default=Decimal("0.00"), verbose_name=_("Score"))
|
||
comment_count = models.PositiveIntegerField(default=0, verbose_name=_("Comment count"))
|
||
# vote_count only counts non-abstain votes.
|
||
vote_count = models.PositiveIntegerField(default=0, verbose_name=_("Vote count"))
|
||
abstain = models.PositiveIntegerField(default=0, verbose_name=_("Abstain"))
|
||
plus_two = models.PositiveIntegerField(default=0, verbose_name=_("Plus two"))
|
||
plus_one = models.PositiveIntegerField(default=0, verbose_name=_("Plus one"))
|
||
minus_one = models.PositiveIntegerField(default=0, verbose_name=_("Minus one"))
|
||
minus_two = models.PositiveIntegerField(default=0, verbose_name=_("Minus two"))
|
||
accepted = models.NullBooleanField(choices=[
|
||
(True, "accepted"),
|
||
(False, "rejected"),
|
||
(None, "undecided"),
|
||
], default=None, verbose_name=_("Accepted"))
|
||
status = models.CharField(max_length=20, choices=[
|
||
("accepted", _("accepted")),
|
||
("rejected", _("rejected")),
|
||
("undecided", _("undecided")),
|
||
("standby", _("standby")),
|
||
], default="undecided", verbose_name=_("Status"))
|
||
|
||
@classmethod
|
||
def full_calculate(cls):
|
||
for proposal in ProposalBase.objects.all():
|
||
result, created = cls._default_manager.get_or_create(proposal=proposal)
|
||
result.update_vote()
|
||
|
||
def calculate_score(self):
|
||
if self.vote_count == 0:
|
||
return 0
|
||
else:
|
||
return ((2 * self.plus_two + self.plus_one) - (2 * self.minus_two + self.minus_one)) / self.vote_count
|
||
|
||
def update_vote(self, *a, **k):
|
||
proposal = self.proposal
|
||
self.comment_count = Review.objects.filter(proposal=proposal).count()
|
||
agg = LatestVote.objects.filter(proposal=proposal).values(
|
||
"vote"
|
||
).annotate(
|
||
count=Count("vote")
|
||
)
|
||
vote_count = {}
|
||
# Set the defaults
|
||
for option in VOTES.CHOICES:
|
||
vote_count[option[0]] = 0
|
||
# Set the actual values if present
|
||
for d in agg:
|
||
vote_count[d["vote"]] = d["count"]
|
||
|
||
self.abstain = vote_count[VOTES.ABSTAIN]
|
||
self.plus_two = vote_count[VOTES.PLUS_TWO]
|
||
self.plus_one = vote_count[VOTES.PLUS_ONE]
|
||
self.minus_one = vote_count[VOTES.MINUS_ONE]
|
||
self.minus_two = vote_count[VOTES.MINUS_TWO]
|
||
self.vote_count = sum(i[1] for i in vote_count.items()) - self.abstain
|
||
self.score = self.calculate_score()
|
||
self.save()
|
||
|
||
class Meta:
|
||
verbose_name = _("proposal_result")
|
||
verbose_name_plural = _("proposal_results")
|
||
|
||
|
||
class Comment(models.Model):
|
||
proposal = models.ForeignKey(
|
||
ProposalBase,
|
||
related_name="comments",
|
||
verbose_name=_("Proposal"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
commenter = models.ForeignKey(
|
||
User,
|
||
verbose_name=_("Commenter"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
text = models.TextField(verbose_name=_("Text"))
|
||
text_html = models.TextField(blank=True)
|
||
|
||
# Or perhaps more accurately, can the user see this comment.
|
||
public = models.BooleanField(choices=[(True, _("public")), (False, _("private"))], default=False, verbose_name=_("Public"))
|
||
commented_at = models.DateTimeField(default=datetime.now, verbose_name=_("Commented at"))
|
||
|
||
class Meta:
|
||
verbose_name = _("comment")
|
||
verbose_name_plural = _("comments")
|
||
|
||
def save(self, *args, **kwargs):
|
||
self.text_html = parse(self.text)
|
||
return super(Comment, self).save(*args, **kwargs)
|
||
|
||
|
||
class NotificationTemplate(models.Model):
|
||
|
||
label = models.CharField(max_length=100, verbose_name=_("Label"))
|
||
from_address = models.EmailField(verbose_name=_("From address"))
|
||
subject = models.CharField(max_length=100, verbose_name=_("Subject"))
|
||
body = models.TextField(verbose_name=_("Body"))
|
||
|
||
class Meta:
|
||
verbose_name = _("notification template")
|
||
verbose_name_plural = _("notification templates")
|
||
|
||
|
||
class ResultNotification(models.Model):
|
||
|
||
proposal = models.ForeignKey(
|
||
ProposalBase,
|
||
related_name="notifications",
|
||
verbose_name=_("Proposal"),
|
||
on_delete=models.CASCADE,
|
||
)
|
||
template = models.ForeignKey(
|
||
NotificationTemplate,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
verbose_name=_("Template")
|
||
)
|
||
timestamp = models.DateTimeField(default=datetime.now, verbose_name=_("Timestamp"))
|
||
to_address = models.EmailField(verbose_name=_("To address"))
|
||
from_address = models.EmailField(verbose_name=_("From address"))
|
||
subject = models.CharField(max_length=255, verbose_name=_("Subject"))
|
||
body = models.TextField(verbose_name=_("Body"))
|
||
|
||
def recipients(self):
|
||
for speaker in self.proposal.speakers():
|
||
yield speaker.email
|
||
|
||
def __unicode__(self):
|
||
return self.proposal.title + ' ' + self.timestamp.strftime('%Y-%m-%d %H:%M:%S')
|
||
|
||
@property
|
||
def email_args(self):
|
||
return (self.subject, self.body, self.from_address, self.recipients())
|
||
|
||
|
||
def promote_proposal(proposal):
|
||
if hasattr(proposal, "presentation") and proposal.presentation:
|
||
# already promoted
|
||
presentation = proposal.presentation
|
||
presentation.title = proposal.title
|
||
presentation.abstract = proposal.abstract
|
||
presentation.speaker = proposal.speaker
|
||
presentation.proposal_base = proposal
|
||
presentation.save()
|
||
presentation.additional_speakers.clear()
|
||
else:
|
||
presentation = Presentation(
|
||
title=proposal.title,
|
||
abstract=proposal.abstract,
|
||
speaker=proposal.speaker,
|
||
section=proposal.section,
|
||
proposal_base=proposal,
|
||
)
|
||
presentation.save()
|
||
for speaker in proposal.additional_speakers.all():
|
||
presentation.additional_speakers.add(speaker)
|
||
presentation.save()
|
||
|
||
return presentation
|
||
|
||
|
||
def unpromote_proposal(proposal):
|
||
if hasattr(proposal, "presentation") and proposal.presentation:
|
||
proposal.presentation.delete()
|
||
|
||
|
||
def accepted_proposal(sender, instance=None, **kwargs):
|
||
if instance is None:
|
||
return
|
||
if instance.status == "accepted":
|
||
promote_proposal(instance.proposal)
|
||
else:
|
||
unpromote_proposal(instance.proposal)
|
||
|
||
|
||
post_save.connect(accepted_proposal, sender=ProposalResult)
|