diff --git a/symposion/reviews/migrations/0002_auto_20170810_2141.py b/symposion/reviews/migrations/0002_auto_20170810_2141.py new file mode 100644 index 00000000..f465b9b3 --- /dev/null +++ b/symposion/reviews/migrations/0002_auto_20170810_2141.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2017-08-10 21:41 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_reviews', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='proposalresult', + old_name='minus_one', + new_name='strong_reject', + ), + migrations.RenameField( + model_name='proposalresult', + old_name='minus_zero', + new_name='weak_reject', + ), + migrations.RenameField( + model_name='proposalresult', + old_name='plus_one', + new_name='strong_accept', + ), + migrations.RenameField( + model_name='proposalresult', + old_name='plus_zero', + new_name='weak_accept', + ), + migrations.AddField( + model_name='proposalresult', + name='abstain', + field=models.PositiveIntegerField(default=0, verbose_name='Abstain'), + ), + migrations.AlterField( + model_name='latestvote', + name='vote', + field=models.CharField(choices=[('+2', '++ \u2014 Good proposal and I will argue for it to be accepted.'), ('+1', '+ \u2014 OK proposal, but I will not argue for it to be accepted.'), ('-1', '\u2212 \u2014 Weak proposal, but I will not argue strongly against acceptance.'), ('-2', '\u2212- \u2014 Serious issues and I will argue to reject this proposal.'), ('0', 'Abstain - I do not want to review this proposal and I do not want to see it again.')], max_length=2, verbose_name='Vote'), + ), + migrations.AlterField( + model_name='review', + name='comment', + field=models.TextField(blank=True, verbose_name='Comment'), + ), + migrations.AlterField( + model_name='review', + name='vote', + field=models.CharField(blank=True, choices=[('+2', '++ \u2014 Good proposal and I will argue for it to be accepted.'), ('+1', '+ \u2014 OK proposal, but I will not argue for it to be accepted.'), ('-1', '\u2212 \u2014 Weak proposal, but I will not argue strongly against acceptance.'), ('-2', '\u2212- \u2014 Serious issues and I will argue to reject this proposal.'), ('0', 'Abstain - I do not want to review this proposal and I do not want to see it again.')], max_length=2, verbose_name='Vote'), + ), + ] diff --git a/symposion/reviews/migrations/0003_auto_20170810_2200.py b/symposion/reviews/migrations/0003_auto_20170810_2200.py new file mode 100644 index 00000000..f3612e24 --- /dev/null +++ b/symposion/reviews/migrations/0003_auto_20170810_2200.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2017-08-10 22:00 +from __future__ import unicode_literals + +from django.db import migrations + +VOTES = { + "+1" : "++", + "+0" : "+", + "-0" : "-", + "-1" : "--", + "0" : "0", +} + + +def convert_votes(apps, schema_editor): + LatestVote = apps.get_model('symposion_reviews', 'LatestVote') + Review = apps.get_model('symposion_reviews', 'Review') + for vote in LatestVote.objects.all(): + vote.vote = VOTES[vote.vote] + vote.save() + for review in Review.objects.all(): + review.vote = VOTES[review.vote] + review.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_reviews', '0002_auto_20170810_2141'), + ] + + operations = [ + migrations.RunPython(convert_votes), + ] diff --git a/symposion/reviews/migrations/0004_auto_20170810_2206.py b/symposion/reviews/migrations/0004_auto_20170810_2206.py new file mode 100644 index 00000000..81b4e704 --- /dev/null +++ b/symposion/reviews/migrations/0004_auto_20170810_2206.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2017-08-10 22:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_reviews', '0003_auto_20170810_2200'), + ] + + operations = [ + migrations.AlterField( + model_name='latestvote', + name='vote', + field=models.CharField(choices=[('++', '++ : Good proposal and I will argue for it to be accepted.'), ('+', '+ : OK proposal, but I will not argue for it to be accepted.'), ('-', '\u2212 : Weak proposal, but I will not argue strongly against acceptance.'), ('--', '\u2212- : Serious issues and I will argue to reject this proposal.'), ('0', 'Abstain : I do not want to review this proposal and I do not want to see it again.')], max_length=2, verbose_name='Vote'), + ), + migrations.AlterField( + model_name='proposalresult', + name='strong_accept', + field=models.PositiveIntegerField(default=0, verbose_name='Strong accept'), + ), + migrations.AlterField( + model_name='proposalresult', + name='strong_reject', + field=models.PositiveIntegerField(default=0, verbose_name='Strong reject'), + ), + migrations.AlterField( + model_name='proposalresult', + name='weak_accept', + field=models.PositiveIntegerField(default=0, verbose_name='Weak accept'), + ), + migrations.AlterField( + model_name='proposalresult', + name='weak_reject', + field=models.PositiveIntegerField(default=0, verbose_name='Weak reject'), + ), + migrations.AlterField( + model_name='review', + name='vote', + field=models.CharField(blank=True, choices=[('++', '++ : Good proposal and I will argue for it to be accepted.'), ('+', '+ : OK proposal, but I will not argue for it to be accepted.'), ('-', '\u2212 : Weak proposal, but I will not argue strongly against acceptance.'), ('--', '\u2212- : Serious issues and I will argue to reject this proposal.'), ('0', 'Abstain : I do not want to review this proposal and I do not want to see it again.')], max_length=2, verbose_name='Vote'), + ), + ] diff --git a/symposion/reviews/models.py b/symposion/reviews/models.py index 1419e134..7d1adff3 100644 --- a/symposion/reviews/models.py +++ b/symposion/reviews/models.py @@ -3,8 +3,12 @@ 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 @@ -16,23 +20,38 @@ def score_expression(): - return ( - (3 * F("plus_one") + F("plus_zero")) - - (F("minus_zero") + 3 * F("minus_one")) + ''' Produces a score range centred around 0 with a minimum of -1 and a + maximum of + 1. + + This function can be overridden. + ''' + + score = ( + (1.0 * F("strong_accept") + 0.5 * F("weak_accept")) - + (0.5 * F("weak_reject") + 1.0 * F("strong_reject")) + ) / ( + F("vote_count") - F("abstain") * 1.0 + ) + + return Case( + When(vote_count=F("abstain"), then=Value("0")), # no divide by zero + default=score, ) class Votes(object): - PLUS_ONE = "+1" - PLUS_ZERO = "+0" - MINUS_ZERO = "−0" - MINUS_ONE = "−1" + ABSTAIN = "0" + STRONG_ACCEPT = "++" + WEAK_ACCEPT = "+" + WEAK_REJECT = "-" + STRONG_REJECT = "--" CHOICES = [ - (PLUS_ONE, _("+1 — Good proposal and I will argue for it to be accepted.")), - (PLUS_ZERO, _("+0 — OK proposal, but I will not argue for it to be accepted.")), - (MINUS_ZERO, _("−0 — Weak proposal, but I will not argue strongly against acceptance.")), - (MINUS_ONE, _("−1 — Serious issues and I will argue to reject this proposal.")), + (STRONG_ACCEPT, _("++ : Good proposal and I will argue for it to be accepted.")), + (WEAK_ACCEPT, _("+ : OK proposal, but I will not argue for it to be accepted.")), + (WEAK_REJECT, _("− : Weak proposal, but I will not argue strongly against acceptance.")), + (STRONG_REJECT, _("−- : 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() @@ -116,10 +135,21 @@ class Review(models.Model): # 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(verbose_name=_("Comment")) + 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: @@ -177,10 +207,11 @@ def delete(self): def css_class(self): return { - self.VOTES.PLUS_ONE: "plus-one", - self.VOTES.PLUS_ZERO: "plus-zero", - self.VOTES.MINUS_ZERO: "minus-zero", - self.VOTES.MINUS_ONE: "minus-one", + self.VOTES.ABSTAIN: "abstain", + self.VOTES.STRONG_ACCEPT: "strong-accept", + self.VOTES.WEAK_ACCEPT: "weak-accept", + self.VOTES.WEAK_REJECT: "weak-reject", + self.VOTES.STRONG_REJECT: "strong-reject", }[self.vote] @property @@ -210,10 +241,11 @@ class Meta: def css_class(self): return { - self.VOTES.PLUS_ONE: "plus-one", - self.VOTES.PLUS_ZERO: "plus-zero", - self.VOTES.MINUS_ZERO: "minus-zero", - self.VOTES.MINUS_ONE: "minus-one", + self.VOTES.ABSTAIN: "abstain", + self.VOTES.STRONG_ACCEPT: "strong-accept", + self.VOTES.WEAK_ACCEPT: "weak-accept", + self.VOTES.WEAK_REJECT: "weak-reject", + self.VOTES.STRONG_REJECT: "strong-reject", }[self.vote] @@ -221,11 +253,13 @@ 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")) - plus_one = models.PositiveIntegerField(default=0, verbose_name=_("Plus one")) - plus_zero = models.PositiveIntegerField(default=0, verbose_name=_("Plus zero")) - minus_zero = models.PositiveIntegerField(default=0, verbose_name=_("Minus zero")) - minus_one = models.PositiveIntegerField(default=0, verbose_name=_("Minus one")) + abstain = models.PositiveIntegerField(default=0, verbose_name=_("Abstain")) + strong_accept = models.PositiveIntegerField(default=0, verbose_name=_("Strong accept")) + weak_accept = models.PositiveIntegerField(default=0, verbose_name=_("Weak accept")) + weak_reject = models.PositiveIntegerField(default=0, verbose_name=_("Weak reject")) + strong_reject = models.PositiveIntegerField(default=0, verbose_name=_("Strong reject")) accepted = models.NullBooleanField(choices=[ (True, "accepted"), (False, "rejected"), @@ -242,52 +276,30 @@ class ProposalResult(models.Model): def full_calculate(cls): for proposal in ProposalBase.objects.all(): result, created = cls._default_manager.get_or_create(proposal=proposal) - result.comment_count = Review.objects.filter(proposal=proposal).count() - result.vote_count = LatestVote.objects.filter(proposal=proposal).count() - result.plus_one = LatestVote.objects.filter( - proposal=proposal, - vote=VOTES.PLUS_ONE - ).count() - result.plus_zero = LatestVote.objects.filter( - proposal=proposal, - vote=VOTES.PLUS_ZERO - ).count() - result.minus_zero = LatestVote.objects.filter( - proposal=proposal, - vote=VOTES.MINUS_ZERO - ).count() - result.minus_one = LatestVote.objects.filter( - proposal=proposal, - vote=VOTES.MINUS_ONE - ).count() - result.save() - cls._default_manager.filter(pk=result.pk).update(score=score_expression()) - - def update_vote(self, vote, previous=None, removal=False): - mapping = { - VOTES.PLUS_ONE: "plus_one", - VOTES.PLUS_ZERO: "plus_zero", - VOTES.MINUS_ZERO: "minus_zero", - VOTES.MINUS_ONE: "minus_one", - } - if previous: - if previous == vote: - return - if removal: - setattr(self, mapping[previous], models.F(mapping[previous]) + 1) - else: - setattr(self, mapping[previous], models.F(mapping[previous]) - 1) - else: - if removal: - self.vote_count = models.F("vote_count") - 1 - else: - self.vote_count = models.F("vote_count") + 1 - if removal: - setattr(self, mapping[vote], models.F(mapping[vote]) - 1) - self.comment_count = models.F("comment_count") - 1 - else: - setattr(self, mapping[vote], models.F(mapping[vote]) + 1) - self.comment_count = models.F("comment_count") + 1 + 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.strong_accept = vote_count[VOTES.STRONG_ACCEPT] + self.weak_accept = vote_count[VOTES.WEAK_ACCEPT] + self.weak_reject = vote_count[VOTES.WEAK_REJECT] + self.strong_reject = vote_count[VOTES.STRONG_REJECT] + 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()) diff --git a/symposion/reviews/views.py b/symposion/reviews/views.py index 83cdc8bf..77b5a00e 100644 --- a/symposion/reviews/views.py +++ b/symposion/reviews/views.py @@ -40,11 +40,12 @@ def proposals_generator(request, queryset, user_pk=None, check_speaker=True): ProposalResult.objects.get_or_create(proposal=obj) obj.comment_count = obj.result.comment_count + obj.score = obj.result.score obj.total_votes = obj.result.vote_count - obj.plus_one = obj.result.plus_one - obj.plus_zero = obj.result.plus_zero - obj.minus_zero = obj.result.minus_zero - obj.minus_one = obj.result.minus_one + obj.strong_accept = obj.result.strong_accept + obj.weak_accept = obj.result.weak_accept + obj.weak_reject = obj.result.weak_reject + obj.strong_reject = obj.result.strong_reject lookup_params = dict(proposal=obj) if user_pk: @@ -144,21 +145,21 @@ def reviewers(): user.comment_count = Review.objects.filter(user=user).count() user.total_votes = LatestVote.objects.filter(user=user).count() - user.plus_one = LatestVote.objects.filter( + user.strong_accept = LatestVote.objects.filter( user=user, - vote=LatestVote.VOTES.PLUS_ONE + vote=LatestVote.VOTES.STRONG_ACCEPT ).count() - user.plus_zero = LatestVote.objects.filter( + user.weak_accept = LatestVote.objects.filter( user=user, - vote=LatestVote.VOTES.PLUS_ZERO + vote=LatestVote.VOTES.WEAK_ACCEPT ).count() - user.minus_zero = LatestVote.objects.filter( + user.weak_reject = LatestVote.objects.filter( user=user, - vote=LatestVote.VOTES.MINUS_ZERO + vote=LatestVote.VOTES.WEAK_REJECT ).count() - user.minus_one = LatestVote.objects.filter( + user.strong_reject = LatestVote.objects.filter( user=user, - vote=LatestVote.VOTES.MINUS_ONE + vote=LatestVote.VOTES.STRONG_REJECT ).count() yield user @@ -268,10 +269,10 @@ def review_detail(request, pk): proposal.comment_count = proposal.result.comment_count proposal.total_votes = proposal.result.vote_count - proposal.plus_one = proposal.result.plus_one - proposal.plus_zero = proposal.result.plus_zero - proposal.minus_zero = proposal.result.minus_zero - proposal.minus_one = proposal.result.minus_one + proposal.strong_accept = proposal.result.strong_accept + proposal.weak_accept = proposal.result.weak_accept + proposal.weak_reject = proposal.result.weak_reject + proposal.strong_reject = proposal.result.strong_reject reviews = Review.objects.filter(proposal=proposal).order_by("-submitted_at") messages = proposal.messages.order_by("submitted_at") @@ -319,22 +320,22 @@ def review_status(request, section_slug=None, key=None): queryset = queryset.filter(kind__section__slug=section_slug) proposals = { - # proposals with at least VOTE_THRESHOLD reviews and at least one +1 and no -1s, sorted by + # proposals with at least VOTE_THRESHOLD reviews and at least one ++ and no --s, sorted by # the 'score' - "positive": queryset.filter(result__vote_count__gte=VOTE_THRESHOLD, result__plus_one__gt=0, - result__minus_one=0).order_by("-result__score"), - # proposals with at least VOTE_THRESHOLD reviews and at least one -1 and no +1s, reverse + "positive": queryset.filter(result__vote_count__gte=VOTE_THRESHOLD, result__strong_accept__gt=0, + result__strong_reject=0).order_by("-result__score"), + # proposals with at least VOTE_THRESHOLD reviews and at least one -- and no ++s, reverse # sorted by the 'score' - "negative": queryset.filter(result__vote_count__gte=VOTE_THRESHOLD, result__minus_one__gt=0, - result__plus_one=0).order_by("result__score"), - # proposals with at least VOTE_THRESHOLD reviews and neither a +1 or a -1, sorted by total + "negative": queryset.filter(result__vote_count__gte=VOTE_THRESHOLD, result__strong_reject__gt=0, + result__strong_accept=0).order_by("result__score"), + # proposals with at least VOTE_THRESHOLD reviews and neither a ++ or a --, sorted by total # votes (lowest first) - "indifferent": queryset.filter(result__vote_count__gte=VOTE_THRESHOLD, result__minus_one=0, - result__plus_one=0).order_by("result__vote_count"), - # proposals with at least VOTE_THRESHOLD reviews and both a +1 and -1, sorted by total + "indifferent": queryset.filter(result__vote_count__gte=VOTE_THRESHOLD, result__strong_reject=0, + result__strong_accept=0).order_by("result__vote_count"), + # proposals with at least VOTE_THRESHOLD reviews and both a ++ and --, sorted by total # votes (highest first) "controversial": queryset.filter(result__vote_count__gte=VOTE_THRESHOLD, - result__plus_one__gt=0, result__minus_one__gt=0) + result__strong_accept__gt=0, result__strong_reject__gt=0) .order_by("-result__vote_count"), # proposals with fewer than VOTE_THRESHOLD reviews "too_few": queryset.filter(result__vote_count__lt=VOTE_THRESHOLD)