add reviews app

This commit is contained in:
Luke Hatcher 2012-08-14 03:54:45 -04:00
parent 67bca7473b
commit 3d68af9796
16 changed files with 977 additions and 0 deletions

View file

View 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,
}

View 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")

View 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."
)

View file

View 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()

View file

@ -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

View 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
View 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)

View 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
View 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
View 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"),
)

View 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
View 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,
})