import csv import math import random from django.contrib.auth.decorators import login_required from django.core.mail import send_mass_mail from django.db import transaction from django.db.models import Q from django.http import HttpResponse from django.http import HttpResponseBadRequest, HttpResponseNotAllowed from django.shortcuts import render, redirect, get_object_or_404 from django.template import Context, Template from django.views.decorators.http import require_POST # @@@ switch to pinax-teams from symposion.teams.models import Team from symposion.conf import settings from symposion.proposals.models import ProposalBase, ProposalSection from symposion.utils.mail import send_email from symposion.reviews.forms import ReviewForm, SpeakerCommentForm from symposion.reviews.forms import BulkPresentationForm from symposion.reviews.models import ( ReviewAssignment, Review, LatestVote, ProposalResult, NotificationTemplate, ResultNotification, promote_proposal ) def access_not_permitted(request): return render(request, "symposion/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.score = obj.result.score obj.total_votes = obj.result.vote_count obj.plus_two = obj.result.plus_two obj.plus_one = obj.result.plus_one obj.minus_one = obj.result.minus_one obj.minus_two = obj.result.minus_two lookup_params = dict(proposal=obj) if user_pk: lookup_params["user__pk"] = user_pk else: lookup_params["user"] = request.user try: obj.user_vote = LatestVote.objects.get(**lookup_params).vote obj.user_vote_css = LatestVote.objects.get(**lookup_params).css_class() except LatestVote.DoesNotExist: obj.user_vote = None obj.user_vote_css = "no-vote" yield obj VOTE_THRESHOLD = settings.SYMPOSION_VOTE_THRESHOLD POSITIVE = "positive" NEGATIVE = "negative" INDIFFERENT = "indifferent" CONTROVERSIAL = "controversial" TOO_FEW = "too_few" REVIEW_STATUS_FILTERS = { # proposals with at least VOTE_THRESHOLD reviews and at least one +2 and no -2s, sorted by # the 'score' POSITIVE: lambda qs: qs.filter(result__vote_count__gte=VOTE_THRESHOLD, result__plus_two__gt=0, result__minus_two=0).order_by("-result__score"), # proposals with at least VOTE_THRESHOLD reviews and at least one -2 and no +2s, reverse # sorted by the 'score' NEGATIVE: lambda qs: qs.filter(result__vote_count__gte=VOTE_THRESHOLD, result__minus_two__gt=0, result__plus_two=0).order_by("result__score"), # proposals with at least VOTE_THRESHOLD reviews and neither a +2 or a -2, sorted by total # votes (lowest first) INDIFFERENT: lambda qs: qs.filter(result__vote_count__gte=VOTE_THRESHOLD, result__minus_two=0, result__plus_two=0).order_by("result__vote_count"), # proposals with at least VOTE_THRESHOLD reviews and both a +2 and -2, sorted by total # votes (highest first) CONTROVERSIAL: lambda qs: qs.filter( result__vote_count__gte=VOTE_THRESHOLD, result__plus_two__gt=0, result__minus_two__gt=0).order_by("-result__vote_count"), # proposals with fewer than VOTE_THRESHOLD reviews TOO_FEW: lambda qs: qs.filter( result__vote_count__lt=VOTE_THRESHOLD).order_by("result__vote_count"), } # Returns a list of all proposals, proposals reviewed by the user, or the proposals the user has # yet to review depending on the link user clicks in dashboard @login_required def review_section(request, section_slug, assigned=False, reviewed="all"): 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.section).order_by('id') if assigned: assignments = ReviewAssignment.objects.filter(user=request.user)\ .values_list("proposal__id") queryset = queryset.filter(id__in=assignments) # passing reviewed in from reviews.urls and out to review_list for # appropriate template header rendering if reviewed == "all": queryset = queryset.select_related("result").select_subclasses() reviewed = "all_reviews" elif reviewed == "reviewed": queryset = queryset.filter(reviews__user=request.user) reviewed = "user_reviewed" else: queryset = queryset.exclude(reviews__user=request.user).exclude( speaker__user=request.user) reviewed = "user_not_reviewed" # lca2017 #21 -- chairs want to be able to see their own proposals in the list check_speaker = not request.user.has_perm("reviews.can_manage_%s" % section_slug) proposals = proposals_generator(request, queryset, check_speaker=check_speaker) ctx = { "proposals": proposals, "section": section, "reviewed": reviewed, } return render(request, "symposion/reviews/review_list.html", ctx) @login_required def review_proposals_csv(request, section_slug=None): ''' Returns a CSV representation of all of the proposals this user has permisison to review. ''' filename = "proposals.csv" if section_slug: filename = "proposals_{}.csv".format(section_slug) response = HttpResponse(content_type="text/csv") response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) writer = csv.writer(response, quoting=csv.QUOTE_NONNUMERIC) queryset = ProposalBase.objects.select_related("speaker__user", "result").select_subclasses() if section_slug: queryset = queryset.filter(kind__section__slug=section_slug) queryset = queryset.order_by("submitted") proposals = proposals_generator(request, queryset, check_speaker=False) # The fields from each proposal object to report in the csv fields = [ "id", "proposal_type", "speaker_name", "speaker_email", "title", "audience", "submitted", "other_speakers", "speaker_travel", "speaker_accommodation", "cancelled", "status", "suggested_status", "score", "total_votes", "minus_two", "minus_one", "plus_one", "plus_two", ] # Fields are the heading writer.writerow(fields) for proposal in proposals: proposal.speaker_name = proposal.speaker.name section_slug = proposal.kind.section.slug kind_slug = proposal.kind.slug proposal.proposal_type = kind_slug if hasattr(proposal, "target_audience"): proposal.audience = proposal.get_target_audience_display() else: proposal.audience = "Unknown" proposal.other_speakers = ", ".join( speaker.name for speaker in proposal.additional_speakers.all() ) proposal.speaker_travel = ", ".join( str(bool(speaker.travel_assistance)) for speaker in proposal.speakers() ) proposal.speaker_accommodation = ", ".join( str(bool(speaker.accommodation_assistance)) for speaker in proposal.speakers() ) suggested_status = proposal.status if suggested_status == "undecided": if proposal.score >= 1.5: suggested_status = "auto-accept" elif proposal.score <= -1.5: suggested_status = "auto-reject" proposal.suggested_status = suggested_status if not request.user.has_perm("reviews.can_review_%s" % section_slug): continue csv_line = [getattr(proposal, field) for field in fields] writer.writerow(csv_line) return response @login_required def review_random_proposal(request, section_slug): # lca2017 #16 view for random proposal 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.section) # Remove ones already reviewed queryset = queryset.exclude(reviews__user=request.user) # Remove withdrawn talks queryset = queryset.exclude(cancelled=True) # Remove talks the reviewer can't vote on -- their own. queryset = queryset.exclude(speaker__user=request.user) queryset = queryset.exclude(additional_speakers__user=request.user) if len(queryset) == 0: return redirect("review_section", section_slug=section_slug, reviewed="all") # Direct reviewers to underreviewed proposals too_few_set = REVIEW_STATUS_FILTERS[TOO_FEW](queryset) controversial_set = REVIEW_STATUS_FILTERS[CONTROVERSIAL](queryset) if len(too_few_set) > 0: proposals = too_few_set.all() elif len(controversial_set) > 0 and random.random() < 0.2: proposals = controversial_set.all() else: # Select a proposal with less than the median number of total votes proposals = proposals_generator(request, queryset, check_speaker=False) proposals = list(proposals) proposals.sort(key=lambda proposal: proposal.total_votes) # The first half is the median or less. proposals = proposals[:math.ceil(len(proposals) / 2)] # Realistically, there shouldn't be all that many proposals to choose # from, so this should be cheap. chosen = random.choice(proposals) return redirect("review_detail", pk=chosen.pk) @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) section = get_object_or_404(ProposalSection, section__slug=section_slug) queryset = ProposalBase.objects.select_related("speaker__user", "result") reviewed = LatestVote.objects.filter(user__pk=user_pk).values_list("proposal", flat=True) queryset = queryset.filter(kind__section__slug=section_slug) 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, "section": section, } return render(request, "symposion/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, proposal__kind__section__slug=section_slug, ).count() user_votes = LatestVote.objects.filter( user=user, proposal__kind__section__slug=section_slug, ) user.total_votes = user_votes.exclude( vote=LatestVote.VOTES.ABSTAIN, ).count() user.plus_two = user_votes.filter( vote=LatestVote.VOTES.PLUS_TWO, ).count() user.plus_one = user_votes.filter( vote=LatestVote.VOTES.PLUS_ONE, ).count() user.minus_one = user_votes.filter( vote=LatestVote.VOTES.MINUS_ONE, ).count() user.minus_two = user_votes.filter( vote=LatestVote.VOTES.MINUS_TWO, ).count() user.abstain = user_votes.filter( vote=LatestVote.VOTES.ABSTAIN, ).count() if user.total_votes == 0: user.average = "-" else: user.average = ( ((user.plus_two * 2) + user.plus_one) - ((user.minus_two * 2) + user.minus_one) ) / (user.total_votes * 1.0) yield user reviewers_sorted = list(reviewers()) reviewers_sorted.sort(key=lambda reviewer: 0 - reviewer.total_votes) ctx = { "section_slug": section_slug, "reviewers": reviewers_sorted, } return render(request, "symposion/reviews/review_admin.html", ctx) # FIXME: This view is too complex according to flake8 @login_required @transaction.atomic 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.has_perm("reviews.can_manage_%s" % proposal.kind.section.slug) try: latest_vote = LatestVote.objects.get(proposal=proposal, user=request.user) except LatestVote.DoesNotExist: latest_vote = None if request.method == "POST": if not admin and request.user in speakers: return access_not_permitted(request) if "vote_submit" in request.POST or "vote_submit_and_random" 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() if "vote_submit_and_random" in request.POST: next_page = redirect("user_random", proposal.kind.section.slug) next_page["Location"] += "#invalid_fragment" # Hack. else: next_page = redirect(request.path) return next_page 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.status = "accepted" proposal.result.save() elif result == "reject": proposal.result.status = "rejected" proposal.result.save() elif result == "undecide": proposal.result.status = "undecided" proposal.result.save() elif result == "standby": proposal.result.status = "standby" proposal.result.save() return redirect(request.path) elif "publish_changes" in request.POST: if admin and proposal.result.status == "accepted": promote_proposal(proposal) 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_two = proposal.result.plus_two proposal.plus_one = proposal.result.plus_one proposal.minus_one = proposal.result.minus_one proposal.minus_two = proposal.result.minus_two reviews = Review.objects.filter(proposal=proposal).order_by("-submitted_at") messages = proposal.messages.order_by("submitted_at") return render(request, "symposion/reviews/review_detail.html", { "proposal": proposal, "latest_vote": latest_vote, "reviews": reviews, "review_messages": messages, "review_form": review_form, "message_form": message_form, "is_manager": admin }) @login_required @require_POST @transaction.atomic def review_delete(request, pk): review = get_object_or_404(Review, pk=pk) section_slug = review.section 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): if not request.user.has_perm("reviews.can_review_%s" % section_slug): return access_not_permitted(request) ctx = { "section_slug": section_slug, "vote_threshold": VOTE_THRESHOLD, } queryset = ProposalBase.objects.select_related("speaker__user", "result").select_subclasses() if section_slug: queryset = queryset.filter(kind__section__slug=section_slug) proposals = dict((key, filt(queryset)) for key, filt in REVIEW_STATUS_FILTERS.items()) admin = request.user.has_perm("reviews.can_manage_%s" % section_slug) for status in proposals: if key and key != status: continue proposals[status] = list(proposals_generator(request, proposals[status], check_speaker=not admin)) if key: ctx.update({ "key": key, "proposals": proposals[key], }) else: ctx["proposals"] = proposals return render(request, "symposion/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, "symposion/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 @transaction.atomic def review_bulk_update(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(",") status = form.cleaned_data["status"] or "accepted" talks = ProposalBase.objects.filter(id__in=talk_ids).select_related("result") for talk in talks: talk.result.status = status talk.result.save() return redirect("review_section", section_slug=section_slug) else: form = BulkPresentationForm() return render(request, "symposion/reviews/review_bulk_update.html", { "form": form, "section_slug": section_slug, }) @login_required def result_notification(request, section_slug, status): if not request.user.has_perm("reviews.can_manage_%s" % section_slug): return access_not_permitted(request) proposals = ProposalBase.objects.filter(kind__section__slug=section_slug, result__status=status).select_related("speaker__user", "result").select_subclasses() notification_templates = NotificationTemplate.objects.all() ctx = { "section_slug": section_slug, "status": status, "proposals": proposals, "notification_templates": notification_templates, } return render(request, "symposion/reviews/result_notification.html", ctx) @login_required def result_notification_prepare(request, section_slug, status): if request.method != "POST": return HttpResponseNotAllowed(["POST"]) if not request.user.has_perm("reviews.can_manage_%s" % section_slug): return access_not_permitted(request) proposal_pks = [] try: for pk in request.POST.getlist("_selected_action"): proposal_pks.append(int(pk)) except ValueError: return HttpResponseBadRequest() proposals = ProposalBase.objects.filter( kind__section__slug=section_slug, result__status=status, ) proposals = proposals.filter(pk__in=proposal_pks) proposals = proposals.select_related("speaker__user", "result") proposals = proposals.select_subclasses() notification_template_pk = request.POST.get("notification_template", "") if notification_template_pk: notification_template = NotificationTemplate.objects.get(pk=notification_template_pk) else: notification_template = None ctx = { "section_slug": section_slug, "status": status, "notification_template": notification_template, "proposals": proposals, "proposal_pks": ",".join([str(pk) for pk in proposal_pks]), } return render(request, "symposion/reviews/result_notification_prepare.html", ctx) @login_required def result_notification_send(request, section_slug, status): if request.method != "POST": return HttpResponseNotAllowed(["POST"]) if not request.user.has_perm("reviews.can_manage_%s" % section_slug): return access_not_permitted(request) if not all([k in request.POST for k in ["proposal_pks", "from_address", "subject", "body"]]): return HttpResponseBadRequest() try: proposal_pks = [int(pk) for pk in request.POST["proposal_pks"].split(",")] except ValueError: return HttpResponseBadRequest() proposals = ProposalBase.objects.filter( kind__section__slug=section_slug, result__status=status, ) proposals = proposals.filter(pk__in=proposal_pks) proposals = proposals.select_related("speaker__user", "result") proposals = proposals.select_subclasses() notification_template_pk = request.POST.get("notification_template", "") if notification_template_pk: notification_template = NotificationTemplate.objects.get(pk=notification_template_pk) else: notification_template = None emails = [] for proposal in proposals: rn = ResultNotification() rn.proposal = proposal rn.template = notification_template rn.to_address = proposal.speaker_email rn.from_address = request.POST["from_address"] proposal_context = proposal.notification_email_context() rn.subject = Template(request.POST["subject"]).render( Context({ "proposal": proposal_context }) ) rn.body = Template(request.POST["body"]).render( Context({ "proposal": proposal_context }) ) rn.save() emails.append(rn.email_args) send_mass_mail(emails) return redirect("result_notification", section_slug=section_slug, status=status)