add reviews app
This commit is contained in:
parent
67bca7473b
commit
3d68af9796
16 changed files with 977 additions and 0 deletions
0
symposion/reviews/__init__.py
Normal file
0
symposion/reviews/__init__.py
Normal file
13
symposion/reviews/context_processors.py
Normal file
13
symposion/reviews/context_processors.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from symposion.proposals.models import ProposalSection
|
||||
|
||||
|
||||
def reviews(request):
|
||||
sections = []
|
||||
for section in ProposalSection.objects.all():
|
||||
if request.user.has_perm("reviews.can_review_%s" % section.section.slug):
|
||||
sections.append(section)
|
||||
return {
|
||||
"review_sections": sections,
|
||||
}
|
9
symposion/reviews/fixture_gen.py
Normal file
9
symposion/reviews/fixture_gen.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.contrib.auth.models import Group
|
||||
|
||||
from fixture_generator import fixture_generator
|
||||
|
||||
|
||||
@fixture_generator(Group)
|
||||
def initial_data():
|
||||
Group.objects.create(name="reviewers")
|
||||
Group.objects.create(name="reviewers-admins")
|
40
symposion/reviews/forms.py
Normal file
40
symposion/reviews/forms.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
from django import forms
|
||||
|
||||
from markitup.widgets import MarkItUpWidget
|
||||
|
||||
from symposion.reviews.models import Review, Comment, ProposalMessage, VOTES
|
||||
|
||||
|
||||
class ReviewForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Review
|
||||
fields = ["vote", "comment"]
|
||||
widgets = { "comment": MarkItUpWidget() }
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ReviewForm, self).__init__(*args, **kwargs)
|
||||
self.fields["vote"] = forms.ChoiceField(
|
||||
widget = forms.RadioSelect(),
|
||||
choices = VOTES.CHOICES
|
||||
)
|
||||
|
||||
|
||||
class ReviewCommentForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ["text"]
|
||||
widgets = { "text": MarkItUpWidget() }
|
||||
|
||||
|
||||
class SpeakerCommentForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ProposalMessage
|
||||
fields = ["message"]
|
||||
widgets = { "message": MarkItUpWidget() }
|
||||
|
||||
|
||||
class BulkPresentationForm(forms.Form):
|
||||
talk_ids = forms.CharField(
|
||||
max_length=500,
|
||||
help_text="Provide a comma seperated list of talk ids to accept."
|
||||
)
|
0
symposion/reviews/management/__init__.py
Normal file
0
symposion/reviews/management/__init__.py
Normal file
0
symposion/reviews/management/commands/__init__.py
Normal file
0
symposion/reviews/management/commands/__init__.py
Normal file
11
symposion/reviews/management/commands/calculate_results.py
Normal file
11
symposion/reviews/management/commands/calculate_results.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from symposion.reviews.models import ProposalResult
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
ProposalResult.full_calculate()
|
|
@ -0,0 +1,25 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from symposion.proposals.models import ProposalSection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
ct, created = ContentType.objects.get_or_create(
|
||||
model="",
|
||||
app_label="reviews",
|
||||
defaults={"name": "reviews"}
|
||||
)
|
||||
|
||||
for ps in ProposalSection.objects.all():
|
||||
for action in ["review", "manage"]:
|
||||
perm, created = Permission.objects.get_or_create(
|
||||
codename="can_%s_%s" % (action, ps.section.slug),
|
||||
content_type__pk=ct.id,
|
||||
defaults={"name": "Can %s %s" % (action, ps), "content_type": ct}
|
||||
)
|
||||
print perm
|
15
symposion/reviews/management/commands/promoteproposals.py
Normal file
15
symposion/reviews/management/commands/promoteproposals.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.db import connections
|
||||
|
||||
from symposion.reviews.models import ProposalResult, promote_proposal
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
accepted_proposals = ProposalResult.objects.filter(accepted=True)
|
||||
accepted_proposals = accepted_proposals.order_by("proposal")
|
||||
|
||||
for result in accepted_proposals:
|
||||
promote_proposal(result.proposal)
|
||||
connections["default"].cursor().execute("SELECT setval('schedule_session_id_seq', (SELECT max(id) FROM schedule_session))")
|
309
symposion/reviews/models.py
Normal file
309
symposion/reviews/models.py
Normal file
|
@ -0,0 +1,309 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from markitup.fields import MarkupField
|
||||
|
||||
from symposion.proposals.models import ProposalBase
|
||||
# from symposion.schedule.models import Presentation
|
||||
|
||||
|
||||
class ProposalScoreExpression(object):
|
||||
|
||||
def as_sql(self, qn, connection=None):
|
||||
sql = "((3 * plus_one + plus_zero) - (minus_zero + 3 * minus_one))"
|
||||
return sql, []
|
||||
|
||||
def prepare_database_save(self, unused):
|
||||
return self
|
||||
|
||||
|
||||
class Votes(object):
|
||||
PLUS_ONE = "+1"
|
||||
PLUS_ZERO = "+0"
|
||||
MINUS_ZERO = u"−0"
|
||||
MINUS_ONE = u"−1"
|
||||
|
||||
CHOICES = [
|
||||
(PLUS_ONE, u"+1 — Good proposal and I will argue for it to be accepted."),
|
||||
(PLUS_ZERO, u"+0 — OK proposal, but I will not argue for it to be accepted."),
|
||||
(MINUS_ZERO, u"−0 — Weak proposal, but I will not argue strongly against acceptance."),
|
||||
(MINUS_ONE, u"−1 — Serious issues and I will argue to reject this proposal."),
|
||||
]
|
||||
VOTES = Votes()
|
||||
|
||||
|
||||
class ReviewAssignment(models.Model):
|
||||
AUTO_ASSIGNED_INITIAL = 0
|
||||
OPT_IN = 1
|
||||
AUTO_ASSIGNED_LATER = 2
|
||||
|
||||
ORIGIN_CHOICES = [
|
||||
(AUTO_ASSIGNED_INITIAL, "auto-assigned, initial"),
|
||||
(OPT_IN, "opted-in"),
|
||||
(AUTO_ASSIGNED_LATER, "auto-assigned, later"),
|
||||
]
|
||||
|
||||
proposal = models.ForeignKey("proposals.ProposalBase")
|
||||
user = models.ForeignKey(User)
|
||||
|
||||
origin = models.IntegerField(choices=ORIGIN_CHOICES)
|
||||
|
||||
assigned_at = models.DateTimeField(default=datetime.now)
|
||||
opted_out = models.BooleanField()
|
||||
|
||||
@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
|
||||
]
|
||||
).filter(
|
||||
groups__name="reviewers",
|
||||
).filter(
|
||||
Q(reviewassignment__opted_out=False) | Q(reviewassignment=None)
|
||||
).annotate(
|
||||
num_assignments=models.Count("reviewassignment")
|
||||
).order_by(
|
||||
"num_assignments",
|
||||
)
|
||||
for reviewer in reviewers[:3]:
|
||||
cls._default_manager.create(
|
||||
proposal=proposal,
|
||||
user=reviewer,
|
||||
origin=origin,
|
||||
)
|
||||
|
||||
|
||||
class ProposalMessage(models.Model):
|
||||
proposal = models.ForeignKey("proposals.ProposalBase", related_name="messages")
|
||||
user = models.ForeignKey(User)
|
||||
|
||||
message = MarkupField()
|
||||
submitted_at = models.DateTimeField(default=datetime.now, editable=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["submitted_at"]
|
||||
|
||||
|
||||
class Review(models.Model):
|
||||
VOTES = VOTES
|
||||
|
||||
proposal = models.ForeignKey("proposals.ProposalBase", related_name="reviews")
|
||||
user = models.ForeignKey(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)
|
||||
comment = MarkupField()
|
||||
submitted_at = models.DateTimeField(default=datetime.now, editable=False)
|
||||
|
||||
def save(self, **kwargs):
|
||||
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,
|
||||
)
|
||||
# find previous vote before self
|
||||
try:
|
||||
previous = user_reviews.filter(submitted_at__lt=self.submitted_at).order_by("-submitted_at")[0]
|
||||
except IndexError:
|
||||
# did not find a previous 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 previous vote
|
||||
# check if self is the last vote
|
||||
if self == user_reviews.order_by("-submitted_at")[0]:
|
||||
# self is the latest which means we need to treat as last.
|
||||
# revert the latest vote to previous vote.
|
||||
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 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.PLUS_ONE: "plus-one",
|
||||
self.VOTES.PLUS_ZERO: "plus-zero",
|
||||
self.VOTES.MINUS_ZERO: "minus-zero",
|
||||
self.VOTES.MINUS_ONE: "minus-one",
|
||||
}[self.vote]
|
||||
|
||||
@property
|
||||
def section(self):
|
||||
return self.proposal.kind.section.slug
|
||||
|
||||
|
||||
class LatestVote(models.Model):
|
||||
VOTES = VOTES
|
||||
|
||||
proposal = models.ForeignKey("proposals.ProposalBase", related_name="votes")
|
||||
user = models.ForeignKey(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)
|
||||
submitted_at = models.DateTimeField(default=datetime.now, editable=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("proposal", "user")]
|
||||
|
||||
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.vote]
|
||||
|
||||
|
||||
class ProposalResult(models.Model):
|
||||
proposal = models.OneToOneField("proposals.ProposalBase", related_name="result")
|
||||
score = models.DecimalField(max_digits=5, decimal_places=2, default=Decimal("0.00"))
|
||||
comment_count = models.PositiveIntegerField(default=1)
|
||||
vote_count = models.PositiveIntegerField(default=1)
|
||||
plus_one = models.PositiveIntegerField(default=0)
|
||||
plus_zero = models.PositiveIntegerField(default=0)
|
||||
minus_zero = models.PositiveIntegerField(default=0)
|
||||
minus_one = models.PositiveIntegerField(default=0)
|
||||
accepted = models.NullBooleanField(choices=[
|
||||
(True, "accepted"),
|
||||
(False, "rejected"),
|
||||
(None, "undecided"),
|
||||
], default=None)
|
||||
|
||||
@classmethod
|
||||
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=ProposalScoreExpression())
|
||||
|
||||
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
|
||||
self.save()
|
||||
model = self.__class__
|
||||
model._default_manager.filter(pk=self.pk).update(score=ProposalScoreExpression())
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
proposal = models.ForeignKey("proposals.ProposalBase", related_name="comments")
|
||||
commenter = models.ForeignKey(User)
|
||||
text = MarkupField()
|
||||
|
||||
# Or perhaps more accurately, can the user see this comment.
|
||||
public = models.BooleanField(choices=[
|
||||
(True, "public"),
|
||||
(False, "private"),
|
||||
])
|
||||
commented_at = models.DateTimeField(default=datetime.now)
|
||||
|
||||
|
||||
def promote_proposal(proposal):
|
||||
raise NotImplementedError()
|
||||
# presentation, created = Presentation.objects.get_or_create(
|
||||
# pk=proposal.pk,
|
||||
# defaults=dict(
|
||||
# title=proposal.title,
|
||||
# description=proposal.description,
|
||||
# kind=proposal.kind,
|
||||
# category=proposal.category,
|
||||
# duration=proposal.duration,
|
||||
# abstract=proposal.abstract,
|
||||
# audience_level=proposal.audience_level,
|
||||
# submitted=proposal.submitted,
|
||||
# speaker=proposal.speaker,
|
||||
# )
|
||||
# )
|
||||
# if created:
|
||||
# for speaker in proposal.additional_speakers.all():
|
||||
# presentation.additional_speakers.add(speaker)
|
||||
# presentation.save()
|
||||
# return presentation
|
||||
|
||||
|
||||
def accepted_proposal(sender, instance=None, **kwargs):
|
||||
if instance is None:
|
||||
return
|
||||
if instance.accepted == True:
|
||||
promote_proposal(instance.proposal)
|
||||
post_save.connect(accepted_proposal, sender=ProposalResult)
|
0
symposion/reviews/templatetags/__init__.py
Normal file
0
symposion/reviews/templatetags/__init__.py
Normal file
20
symposion/reviews/templatetags/review_tags.py
Normal file
20
symposion/reviews/templatetags/review_tags.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from django import template
|
||||
|
||||
from symposion.reviews.models import Review, ReviewAssignment
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.assignment_tag(takes_context=True)
|
||||
def user_reviews(context):
|
||||
request = context["request"]
|
||||
reviews = Review.objects.filter(user=request.user)
|
||||
return reviews
|
||||
|
||||
|
||||
@register.assignment_tag(takes_context=True)
|
||||
def review_assignments(context):
|
||||
request = context["request"]
|
||||
assignments = ReviewAssignment.objects.filter(user=request.user)
|
||||
return assignments
|
143
symposion/reviews/tests.py
Normal file
143
symposion/reviews/tests.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
|
||||
from django.contrib.auth.models import User, Group
|
||||
|
||||
from symposion.proposals.models import Proposal
|
||||
from symposion.reviews.models import Review, ReviewAssignment
|
||||
|
||||
|
||||
class login(object):
|
||||
def __init__(self, testcase, user, password):
|
||||
self.testcase = testcase
|
||||
success = testcase.client.login(username=user, password=password)
|
||||
self.testcase.assertTrue(
|
||||
success,
|
||||
"login with username=%r, password=%r failed" % (user, password)
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.testcase.client.logout()
|
||||
|
||||
|
||||
class ReviewTests(TestCase):
|
||||
fixtures = ["proposals"]
|
||||
|
||||
def get(self, url_name, *args, **kwargs):
|
||||
return self.client.get(reverse(url_name, args=args, kwargs=kwargs))
|
||||
|
||||
def post(self, url_name, *args, **kwargs):
|
||||
data = kwargs.pop("data")
|
||||
return self.client.post(reverse(url_name, args=args, kwargs=kwargs), data)
|
||||
|
||||
def login(self, user, password):
|
||||
return login(self, user, password)
|
||||
|
||||
def test_detail_perms(self):
|
||||
guidos_proposal = Proposal.objects.all()[0]
|
||||
response = self.get("review_detail", pk=guidos_proposal.pk)
|
||||
|
||||
# Not logged in
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
with self.login("guido", "pythonisawesome"):
|
||||
response = self.get("review_detail", pk=guidos_proposal.pk)
|
||||
# Guido can see his own proposal.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
with self.login("matz", "pythonsucks"):
|
||||
response = self.get("review_detail", pk=guidos_proposal.pk)
|
||||
# Matz can't see guido's proposal
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
larry = User.objects.get(username="larryw")
|
||||
# Larry is a trustworthy guy, he's a reviewer.
|
||||
larry.groups.add(Group.objects.get(name="reviewers"))
|
||||
with self.login("larryw", "linenoisehere"):
|
||||
response = self.get("review_detail", pk=guidos_proposal.pk)
|
||||
# Reviewers can see a review detail page.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_reviewing(self):
|
||||
guidos_proposal = Proposal.objects.all()[0]
|
||||
|
||||
with self.login("guido", "pythonisawesome"):
|
||||
response = self.post("review_review", pk=guidos_proposal.pk, data={
|
||||
"vote": "+1",
|
||||
})
|
||||
# It redirects, but...
|
||||
self.assertEqual(response.status_code, 302)
|
||||
# ... no vote recorded
|
||||
self.assertEqual(guidos_proposal.reviews.count(), 0)
|
||||
|
||||
larry = User.objects.get(username="larryw")
|
||||
# Larry is a trustworthy guy, he's a reviewer.
|
||||
larry.groups.add(Group.objects.get(name="reviewers"))
|
||||
with self.login("larryw", "linenoisehere"):
|
||||
response = self.post("review_review", pk=guidos_proposal.pk, data={
|
||||
"vote": "+0",
|
||||
"text": "Looks like a decent proposal, and Guido is a smart guy",
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(guidos_proposal.reviews.count(), 1)
|
||||
self.assertEqual(ReviewAssignment.objects.count(), 1)
|
||||
assignment = ReviewAssignment.objects.get()
|
||||
self.assertEqual(assignment.proposal, guidos_proposal)
|
||||
self.assertEqual(assignment.origin, ReviewAssignment.OPT_IN)
|
||||
self.assertEqual(guidos_proposal.comments.count(), 1)
|
||||
comment = guidos_proposal.comments.get()
|
||||
self.assertFalse(comment.public)
|
||||
|
||||
response = self.post("review_review", pk=guidos_proposal.pk, data={
|
||||
"vote": "+1",
|
||||
"text": "Actually Perl is dead, we really need a talk on the future",
|
||||
})
|
||||
self.assertEqual(guidos_proposal.reviews.count(), 2)
|
||||
self.assertEqual(ReviewAssignment.objects.count(), 1)
|
||||
assignment = ReviewAssignment.objects.get()
|
||||
self.assertEqual(assignment.review, Review.objects.order_by("-id")[0])
|
||||
self.assertEqual(guidos_proposal.comments.count(), 2)
|
||||
|
||||
# Larry's a big fan...
|
||||
response = self.post("review_review", pk=guidos_proposal.pk, data={
|
||||
"vote": "+20",
|
||||
})
|
||||
self.assertEqual(guidos_proposal.reviews.count(), 2)
|
||||
|
||||
def test_speaker_commenting(self):
|
||||
guidos_proposal = Proposal.objects.all()[0]
|
||||
|
||||
with self.login("guido", "pythonisawesome"):
|
||||
response = self.get("review_comment", pk=guidos_proposal.pk)
|
||||
# Guido can comment on his proposal.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.post("review_comment", pk=guidos_proposal.pk, data={
|
||||
"text": "FYI I can do this as a 30-minute or 45-minute talk.",
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(guidos_proposal.comments.count(), 1)
|
||||
comment = guidos_proposal.comments.get()
|
||||
self.assertTrue(comment.public)
|
||||
|
||||
larry = User.objects.get(username="larryw")
|
||||
# Larry is a trustworthy guy, he's a reviewer.
|
||||
larry.groups.add(Group.objects.get(name="reviewers"))
|
||||
with self.login("larryw", "linenoisehere"):
|
||||
response = self.get("review_comment", pk=guidos_proposal.pk)
|
||||
# Larry can comment, since he's a reviewer
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.post("review_comment", pk=guidos_proposal.pk, data={
|
||||
"text": "Thanks for the heads-up Guido."
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(guidos_proposal.comments.count(), 2)
|
||||
|
||||
with self.login("matz", "pythonsucks"):
|
||||
response = self.get("review_comment", pk=guidos_proposal.pk)
|
||||
# Matz can't comment.
|
||||
self.assertEqual(response.status_code, 302)
|
18
symposion/reviews/urls.py
Normal file
18
symposion/reviews/urls.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from django.conf.urls.defaults import patterns, url
|
||||
|
||||
|
||||
urlpatterns = patterns("symposion.reviews.views",
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/$", "review_section", name="review_section"),
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/assignments/$", "review_section", {"assigned": True}, name="review_section_assignments"),
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/status/$", "review_status", name="review_status"),
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/status/(?P<key>[\w]+)/$", "review_status", name="review_status"),
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/list/(?P<user_pk>\d+)/$", "review_list", name="review_list_user"),
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/admin/$", "review_admin", name="review_admin"),
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/admin/accept/$", "review_bulk_accept", name="review_bulk_accept"),
|
||||
|
||||
url(r"^review/(?P<pk>\d+)/$", "review_detail", name="review_detail"),
|
||||
|
||||
url(r"^(?P<pk>\d+)/delete/$", "review_delete", name="review_delete"),
|
||||
url(r"^assignments/$", "review_assignments", name="review_assignments"),
|
||||
url(r"^assignment/(?P<pk>\d+)/opt-out/$", "review_assignment_opt_out", name="review_assignment_opt_out"),
|
||||
)
|
19
symposion/reviews/utils.py
Normal file
19
symposion/reviews/utils.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
def has_permission(user, proposal, speaker=False, reviewer=False):
|
||||
"""
|
||||
Returns whether or not ther user has permission to review this proposal,
|
||||
with the specified requirements.
|
||||
|
||||
If ``speaker`` is ``True`` then the user can be one of the speakers for the
|
||||
proposal. If ``reviewer`` is ``True`` the speaker can be a part of the
|
||||
reviewer group.
|
||||
"""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if speaker:
|
||||
if (user == proposal.speaker.user or
|
||||
proposal.additional_speakers.filter(user=user).exists()):
|
||||
return True
|
||||
if reviewer:
|
||||
if user.groups.filter(name="reviewers").exists():
|
||||
return True
|
||||
return False
|
355
symposion/reviews/views.py
Normal file
355
symposion/reviews/views.py
Normal file
|
@ -0,0 +1,355 @@
|
|||
from django.db.models import Q
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from symposion.proposals.models import ProposalBase, ProposalSection
|
||||
from symposion.reviews.forms import ReviewForm, SpeakerCommentForm
|
||||
from symposion.reviews.forms import BulkPresentationForm
|
||||
from symposion.reviews.models import ReviewAssignment, Review, LatestVote, ProposalResult
|
||||
from symposion.teams.models import Team
|
||||
from symposion.utils.mail import send_email
|
||||
|
||||
|
||||
def access_not_permitted(request):
|
||||
return render(request, "reviews/access_not_permitted.html")
|
||||
|
||||
|
||||
def proposals_generator(request, queryset, user_pk=None, check_speaker=True):
|
||||
|
||||
for obj in queryset:
|
||||
# @@@ this sucks; we can do better
|
||||
if check_speaker:
|
||||
if request.user in [s.user for s in obj.speakers()]:
|
||||
continue
|
||||
|
||||
try:
|
||||
obj.result
|
||||
except ProposalResult.DoesNotExist:
|
||||
ProposalResult.objects.get_or_create(proposal=obj)
|
||||
|
||||
obj.comment_count = obj.result.comment_count
|
||||
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
|
||||
lookup_params = dict(proposal=obj)
|
||||
|
||||
if user_pk:
|
||||
lookup_params["user__pk"] = user_pk
|
||||
else:
|
||||
lookup_params["user"] = request.user
|
||||
|
||||
try:
|
||||
obj.latest_vote = LatestVote.objects.get(**lookup_params).css_class()
|
||||
except LatestVote.DoesNotExist:
|
||||
obj.latest_vote = "no-vote"
|
||||
|
||||
yield obj
|
||||
|
||||
|
||||
@login_required
|
||||
def review_section(request, section_slug, assigned=False):
|
||||
|
||||
if not request.user.has_perm("reviews.can_review_%s" % section_slug):
|
||||
return access_not_permitted(request)
|
||||
|
||||
section = get_object_or_404(ProposalSection, section__slug=section_slug)
|
||||
queryset = ProposalBase.objects.filter(kind__section=section)
|
||||
|
||||
if assigned:
|
||||
assignments = ReviewAssignment.objects.filter(user=request.user).values_list("proposal__id")
|
||||
queryset = queryset.filter(id__in=assignments)
|
||||
|
||||
queryset = queryset.select_related("result").select_subclasses()
|
||||
|
||||
proposals = proposals_generator(request, queryset)
|
||||
|
||||
ctx = {
|
||||
"proposals": proposals,
|
||||
}
|
||||
|
||||
return render(request, "reviews/review_list.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
def review_list(request, section_slug, user_pk):
|
||||
|
||||
# if they're not a reviewer admin and they aren't the person whose
|
||||
# review list is being asked for, don't let them in
|
||||
if not request.user.has_perm("reviews.can_manage_%s" % section_slug):
|
||||
if not request.user.pk == user_pk:
|
||||
return access_not_permitted(request)
|
||||
|
||||
queryset = ProposalBase.objects.select_related("speaker__user", "result")
|
||||
reviewed = LatestVote.objects.filter(user__pk=user_pk).values_list("proposal", flat=True)
|
||||
queryset = queryset.filter(pk__in=reviewed)
|
||||
proposals = queryset.order_by("submitted")
|
||||
|
||||
admin = request.user.has_perm("reviews.can_manage_%s" % section_slug)
|
||||
|
||||
proposals = proposals_generator(request, proposals, user_pk=user_pk, check_speaker=not admin)
|
||||
|
||||
ctx = {
|
||||
"proposals": proposals,
|
||||
}
|
||||
return render(request, "reviews/review_list.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
def review_admin(request, section_slug):
|
||||
|
||||
if not request.user.has_perm("reviews.can_manage_%s" % section_slug):
|
||||
return access_not_permitted(request)
|
||||
|
||||
def reviewers():
|
||||
already_seen = set()
|
||||
|
||||
for team in Team.objects.filter(permissions__codename="can_review_%s" % section_slug):
|
||||
for membership in team.memberships.filter(Q(state="member") | Q(state="manager")):
|
||||
user = membership.user
|
||||
if user.pk in already_seen:
|
||||
continue
|
||||
already_seen.add(user.pk)
|
||||
|
||||
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 = user,
|
||||
vote = LatestVote.VOTES.PLUS_ONE
|
||||
).count()
|
||||
user.plus_zero = LatestVote.objects.filter(
|
||||
user = user,
|
||||
vote = LatestVote.VOTES.PLUS_ZERO
|
||||
).count()
|
||||
user.minus_zero = LatestVote.objects.filter(
|
||||
user = user,
|
||||
vote = LatestVote.VOTES.MINUS_ZERO
|
||||
).count()
|
||||
user.minus_one = LatestVote.objects.filter(
|
||||
user = user,
|
||||
vote = LatestVote.VOTES.MINUS_ONE
|
||||
).count()
|
||||
|
||||
yield user
|
||||
|
||||
ctx = {
|
||||
"section_slug": section_slug,
|
||||
"reviewers": reviewers(),
|
||||
}
|
||||
return render(request, "reviews/review_admin.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
def review_detail(request, pk):
|
||||
|
||||
proposals = ProposalBase.objects.select_related("result").select_subclasses()
|
||||
proposal = get_object_or_404(proposals, pk=pk)
|
||||
|
||||
if not request.user.has_perm("reviews.can_review_%s" % proposal.kind.section.slug):
|
||||
return access_not_permitted(request)
|
||||
|
||||
speakers = [s.user for s in proposal.speakers()]
|
||||
|
||||
if not request.user.is_superuser and request.user in speakers:
|
||||
return access_not_permitted(request)
|
||||
|
||||
admin = request.user.is_staff
|
||||
|
||||
try:
|
||||
latest_vote = LatestVote.objects.get(proposal=proposal, user=request.user)
|
||||
except LatestVote.DoesNotExist:
|
||||
latest_vote = None
|
||||
|
||||
if request.method == "POST":
|
||||
if request.user in speakers:
|
||||
return access_not_permitted(request)
|
||||
|
||||
if "vote_submit" in request.POST:
|
||||
review_form = ReviewForm(request.POST)
|
||||
if review_form.is_valid():
|
||||
|
||||
review = review_form.save(commit=False)
|
||||
review.user = request.user
|
||||
review.proposal = proposal
|
||||
review.save()
|
||||
|
||||
return redirect(request.path)
|
||||
else:
|
||||
message_form = SpeakerCommentForm()
|
||||
elif "message_submit" in request.POST:
|
||||
message_form = SpeakerCommentForm(request.POST)
|
||||
if message_form.is_valid():
|
||||
|
||||
message = message_form.save(commit=False)
|
||||
message.user = request.user
|
||||
message.proposal = proposal
|
||||
message.save()
|
||||
|
||||
for speaker in speakers:
|
||||
if speaker and speaker.email:
|
||||
ctx = {
|
||||
"proposal": proposal,
|
||||
"message": message,
|
||||
"reviewer": False,
|
||||
}
|
||||
send_email(
|
||||
[speaker.email], "proposal_new_message",
|
||||
context = ctx
|
||||
)
|
||||
|
||||
return redirect(request.path)
|
||||
else:
|
||||
initial = {}
|
||||
if latest_vote:
|
||||
initial["vote"] = latest_vote.vote
|
||||
if request.user in speakers:
|
||||
review_form = None
|
||||
else:
|
||||
review_form = ReviewForm(initial=initial)
|
||||
elif "result_submit" in request.POST:
|
||||
if admin:
|
||||
result = request.POST["result_submit"]
|
||||
|
||||
if result == "accept":
|
||||
proposal.result.accepted = True
|
||||
proposal.result.save()
|
||||
elif result == "reject":
|
||||
proposal.result.accepted = False
|
||||
proposal.result.save()
|
||||
elif result == "undecide":
|
||||
proposal.result.accepted = None
|
||||
proposal.result.save()
|
||||
|
||||
return redirect(request.path)
|
||||
else:
|
||||
initial = {}
|
||||
if latest_vote:
|
||||
initial["vote"] = latest_vote.vote
|
||||
if request.user in speakers:
|
||||
review_form = None
|
||||
else:
|
||||
review_form = ReviewForm(initial=initial)
|
||||
message_form = SpeakerCommentForm()
|
||||
|
||||
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
|
||||
|
||||
reviews = Review.objects.filter(proposal=proposal).order_by("-submitted_at")
|
||||
messages = proposal.messages.order_by("submitted_at")
|
||||
|
||||
return render(request, "reviews/review_detail.html", {
|
||||
"proposal": proposal,
|
||||
"latest_vote": latest_vote,
|
||||
"reviews": reviews,
|
||||
"review_messages": messages,
|
||||
"review_form": review_form,
|
||||
"message_form": message_form
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def review_delete(request, pk):
|
||||
review = get_object_or_404(Review, pk=pk)
|
||||
section_slug = review.section.slug
|
||||
|
||||
if not request.user.has_perm("reviews.can_manage_%s" % section_slug):
|
||||
return access_not_permitted(request)
|
||||
|
||||
review = get_object_or_404(Review, pk=pk)
|
||||
review.delete()
|
||||
|
||||
return redirect("review_detail", pk=review.proposal.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def review_status(request, section_slug=None, key=None):
|
||||
|
||||
ctx = {
|
||||
"section_slug": section_slug
|
||||
}
|
||||
|
||||
queryset = ProposalBase.objects.select_related("speaker__user", "result").select_subclasses()
|
||||
if section_slug:
|
||||
queryset = queryset.filter(kind__section__slug=section_slug)
|
||||
|
||||
proposals = {
|
||||
# proposals with at least 3 reviews and at least one +1 and no -1s, sorted by the 'score'
|
||||
"positive": queryset.filter(result__vote_count__gte=3, result__plus_one__gt=0, result__minus_one=0).order_by("-result__score"),
|
||||
# proposals with at least 3 reviews and at least one -1 and no +1s, reverse sorted by the 'score'
|
||||
"negative": queryset.filter(result__vote_count__gte=3, result__minus_one__gt=0, result__plus_one=0).order_by("result__score"),
|
||||
# proposals with at least 3 reviews and neither a +1 or a -1, sorted by total votes (lowest first)
|
||||
"indifferent": queryset.filter(result__vote_count__gte=3, result__minus_one=0, result__plus_one=0).order_by("result__vote_count"),
|
||||
# proposals with at least 3 reviews and both a +1 and -1, sorted by total votes (highest first)
|
||||
"controversial": queryset.filter(result__vote_count__gte=3, result__plus_one__gt=0, result__minus_one__gt=0).order_by("-result__vote_count"),
|
||||
# proposals with fewer than 3 reviews
|
||||
"too_few": queryset.filter(result__vote_count__lt=3).order_by("result__vote_count"),
|
||||
}
|
||||
|
||||
admin = request.user.has_perm("reviews.can_manage_%s" % section_slug)
|
||||
|
||||
if key:
|
||||
ctx.update({
|
||||
"key": key,
|
||||
"proposals": proposals_generator(request, proposals[key], check_speaker=not admin),
|
||||
"proposal_count": proposals[key].count(),
|
||||
})
|
||||
else:
|
||||
ctx["proposals"] = proposals
|
||||
|
||||
return render(request, "reviews/review_stats.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
def review_assignments(request):
|
||||
if not request.user.groups.filter(name="reviewers").exists():
|
||||
return access_not_permitted(request)
|
||||
assignments = ReviewAssignment.objects.filter(
|
||||
user=request.user,
|
||||
opted_out=False
|
||||
)
|
||||
return render(request, "reviews/review_assignment.html", {
|
||||
"assignments": assignments,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def review_assignment_opt_out(request, pk):
|
||||
review_assignment = get_object_or_404(ReviewAssignment,
|
||||
pk=pk,
|
||||
user=request.user
|
||||
)
|
||||
if not review_assignment.opted_out:
|
||||
review_assignment.opted_out = True
|
||||
review_assignment.save()
|
||||
ReviewAssignment.create_assignments(review_assignment.proposal, origin=ReviewAssignment.AUTO_ASSIGNED_LATER)
|
||||
return redirect("review_assignments")
|
||||
|
||||
|
||||
@login_required
|
||||
def review_bulk_accept(request, section_slug):
|
||||
if not request.user.has_perm("reviews.can_manage_%s" % section_slug):
|
||||
return access_not_permitted(request)
|
||||
if request.method == "POST":
|
||||
form = BulkPresentationForm(request.POST)
|
||||
if form.is_valid():
|
||||
talk_ids = form.cleaned_data["talk_ids"].split(",")
|
||||
talks = ProposalBase.objects.filter(id__in=talk_ids).select_related("result")
|
||||
for talk in talks:
|
||||
talk.result.accepted = True
|
||||
talk.result.save()
|
||||
return redirect("review_list")
|
||||
else:
|
||||
form = BulkPresentationForm()
|
||||
|
||||
return render(request, "reviews/review_bulk_accept.html", {
|
||||
"form": form,
|
||||
})
|
Loading…
Reference in a new issue