Files @ 997380152e45
Branch filter:

Location: symposion_app/symposion/reviews/models.py - annotation

Sachi King
Silence MARKDOWN debug
3d68af979659
3d68af979659
3d68af979659
3d68af979659
d305cd8c13d7
d305cd8c13d7
3d68af979659
11f697d13757
6e133970d958
57acd04852f8
3d68af979659
3d68af979659
3d68af979659
3207621058b8
3d68af979659
11f697d13757
3d68af979659
37c976c2f798
3d68af979659
3d68af979659
11f697d13757
6e133970d958
f1f29c6f61de
f1f29c6f61de
be4404c60283
7f3ed91daed6
11f697d13757
3d68af979659
6e133970d958
7f3ed91daed6
6e133970d958
6e133970d958
6e133970d958
3d68af979659
3d68af979659
be4404c60283
f1f29c6f61de
3d68af979659
03a231093fde
03a231093fde
36ab6d599ffc
3d68af979659
f1f29c6f61de
f1f29c6f61de
f1f29c6f61de
f1f29c6f61de
be4404c60283
3d68af979659
298b162be697
298b162be697
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
e180c7f00b6d
e180c7f00b6d
36ab6d599ffc
3d68af979659
3207621058b8
3207621058b8
3207621058b8
3d68af979659
36ab6d599ffc
3207621058b8
3207621058b8
36ab6d599ffc
3207621058b8
36ab6d599ffc
3207621058b8
3207621058b8
36ab6d599ffc
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
e180c7f00b6d
e180c7f00b6d
e180c7f00b6d
e180c7f00b6d
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
e180c7f00b6d
3d68af979659
e180c7f00b6d
e180c7f00b6d
e180c7f00b6d
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3207621058b8
3207621058b8
36ab6d599ffc
11f697d13757
11f697d13757
3207621058b8
3d68af979659
11f697d13757
11f697d13757
11f697d13757
11f697d13757
3d68af979659
3d68af979659
3207621058b8
3207621058b8
3d68af979659
3d68af979659
3d68af979659
3d68af979659
36ab6d599ffc
3207621058b8
3207621058b8
36ab6d599ffc
3d68af979659
3d68af979659
3207621058b8
d305cd8c13d7
d305cd8c13d7
d305cd8c13d7
d305cd8c13d7
11f697d13757
3207621058b8
36ab6d599ffc
d305cd8c13d7
d305cd8c13d7
d305cd8c13d7
d305cd8c13d7
d305cd8c13d7
d305cd8c13d7
d305cd8c13d7
d305cd8c13d7
3d68af979659
11f697d13757
3d68af979659
3d68af979659
36ab6d599ffc
36ab6d599ffc
36ab6d599ffc
36ab6d599ffc
36ab6d599ffc
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
36ab6d599ffc
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
2c46e56b353c
2c46e56b353c
3d68af979659
2c46e56b353c
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
2c46e56b353c
2c46e56b353c
2c46e56b353c
2c46e56b353c
2c46e56b353c
36ab6d599ffc
36ab6d599ffc
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3d68af979659
2c46e56b353c
2c46e56b353c
3d68af979659
3d68af979659
3d68af979659
3d68af979659
36ab6d599ffc
3d68af979659
3d68af979659
be4404c60283
acc1b1490e33
3d68af979659
3d68af979659
acc1b1490e33
3d68af979659
36ab6d599ffc
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3d68af979659
3d68af979659
3d68af979659
36ab6d599ffc
3207621058b8
3207621058b8
36ab6d599ffc
3d68af979659
3d68af979659
3207621058b8
3207621058b8
36ab6d599ffc
3d68af979659
3d68af979659
3207621058b8
3207621058b8
36ab6d599ffc
3d68af979659
3d68af979659
be4404c60283
acc1b1490e33
3d68af979659
3d68af979659
acc1b1490e33
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3207621058b8
3207621058b8
3207621058b8
7f3ed91daed6
3207621058b8
be4404c60283
f1f29c6f61de
3207621058b8
3207621058b8
f1f29c6f61de
3d68af979659
3d68af979659
3d68af979659
3d68af979659
3207621058b8
04228555827f
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3207621058b8
36ab6d599ffc
3d68af979659
3d68af979659
3d68af979659
3d68af979659
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
57acd04852f8
7f3ed91daed6
3d68af979659
3d68af979659
11f697d13757
3d68af979659
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3d68af979659
3d68af979659
3207621058b8
3207621058b8
11f697d13757
11f697d13757
36ab6d599ffc
3d68af979659
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3d68af979659
11f697d13757
031ee3807e84
11f697d13757
11f697d13757
3d68af979659
3dd746836545
36ab6d599ffc
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3207621058b8
3dd746836545
3dd746836545
3dd746836545
36ab6d599ffc
3207621058b8
36ab6d599ffc
3207621058b8
3207621058b8
3207621058b8
3207621058b8
433a99a4020c
3207621058b8
36ab6d599ffc
3dd2f14f7293
3dd2f14f7293
3dd2f14f7293
3dd2f14f7293
c7608fb0d5fa
c7608fb0d5fa
c7608fb0d5fa
733c4adc530b
733c4adc530b
3dd2f14f7293
3dd746836545
3dd746836545
3d68af979659
37c976c2f798
37c976c2f798
37c976c2f798
699b32b938d2
699b32b938d2
699b32b938d2
699b32b938d2
699b32b938d2
699b32b938d2
37c976c2f798
37c976c2f798
36ab6d599ffc
36ab6d599ffc
36ab6d599ffc
36ab6d599ffc
36ab6d599ffc
37c976c2f798
37c976c2f798
699b32b938d2
699b32b938d2
699b32b938d2
36ab6d599ffc
37c976c2f798
3d68af979659
3d68af979659
d4b53263950c
d4b53263950c
d4b53263950c
d4b53263950c
d4b53263950c
3d68af979659
d02e7f83c6ed
3d68af979659
04228555827f
3d68af979659
d4b53263950c
d4b53263950c
298b162be697
298b162be697
3d68af979659
# -*- 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.models import User
from django.utils.translation import ugettext_lazy as _

from symposion.markdown_parser import parse
from symposion.proposals.models import ProposalBase
from symposion.schedule.models import Presentation


def score_expression():
    score = (
        (2 * F("plus_two") + F("plus_one")) -
        (F("minus_one") + 2 * F("minus_two"))
    ) / (
        F("vote_count") * 1.0
    )

    return Case(
        When(vote_count=0, then=Value("0")),  # no divide by zero
        default=score,
    )


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"))
    user = models.ForeignKey(User, verbose_name=_("User"))

    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"))
    user = models.ForeignKey(User, verbose_name=_("User"))

    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"))
    user = models.ForeignKey(User, verbose_name=_("User"))

    # 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 = models.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"))
    user = models.ForeignKey(User, verbose_name=_("User"))

    # 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"))
    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 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.save()
        model = self.__class__
        model._default_manager.filter(pk=self.pk).update(score=score_expression())

    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"))
    commenter = models.ForeignKey(User, verbose_name=_("Commenter"))
    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"))
    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)