Merge branch 'result-notification'
This commit is contained in:
commit
fd3e6ef3fa
7 changed files with 354 additions and 2 deletions
6
symposion/reviews/admin.py
Normal file
6
symposion/reviews/admin.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from symposion.reviews.models import NotificationTemplate
|
||||
|
||||
|
||||
admin.site.register(NotificationTemplate)
|
|
@ -284,6 +284,34 @@ class Comment(models.Model):
|
|||
commented_at = models.DateTimeField(default=datetime.now)
|
||||
|
||||
|
||||
class NotificationTemplate(models.Model):
|
||||
|
||||
label = models.CharField(max_length=100)
|
||||
subject = models.CharField(max_length=100)
|
||||
body = models.TextField(
|
||||
help_text=(
|
||||
"If the <b>Body</b> includes the string <code>{{ proposal }}</code> "
|
||||
"then it will be replaced with the title of the proposal when the "
|
||||
"email is sent."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ResultNotification(models.Model):
|
||||
|
||||
proposal = models.ForeignKey("proposals.ProposalBase", related_name="notifications")
|
||||
template = models.ForeignKey(NotificationTemplate, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
timestamp = models.DateTimeField(default=datetime.now)
|
||||
to_address = models.EmailField()
|
||||
from_address = models.EmailField()
|
||||
subject = models.CharField(max_length=100)
|
||||
body = models.TextField()
|
||||
|
||||
@property
|
||||
def email_args(self):
|
||||
return (self.subject, self.body, self.from_address, [self.to_address])
|
||||
|
||||
|
||||
def promote_proposal(proposal):
|
||||
|
||||
if hasattr(proposal, "presentation") and proposal.presentation:
|
||||
|
|
|
@ -5,10 +5,13 @@ 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\-]+)/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"^section/(?P<section_slug>[\w\-]+)/notification/(?P<status>\w+)/$", "result_notification", name="result_notification"),
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/notification/(?P<status>\w+)/prepare/$", "result_notification_prepare", name="result_notification_prepare"),
|
||||
url(r"^section/(?P<section_slug>[\w\-]+)/notification/(?P<status>\w+)/send/$", "result_notification_send", name="result_notification_send"),
|
||||
|
||||
url(r"^review/(?P<pk>\d+)/$", "review_detail", name="review_detail"),
|
||||
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import re
|
||||
|
||||
from django.core.mail import send_mass_mail
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseBadRequest, HttpResponseNotAllowed
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
|
@ -11,7 +15,10 @@ 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
|
||||
from symposion.reviews.models import (
|
||||
ReviewAssignment, Review, LatestVote, ProposalResult, NotificationTemplate,
|
||||
ResultNotification
|
||||
)
|
||||
|
||||
|
||||
def access_not_permitted(request):
|
||||
|
@ -369,3 +376,106 @@ def review_bulk_accept(request, section_slug):
|
|||
return render(request, "reviews/review_bulk_accept.html", {
|
||||
"form": form,
|
||||
})
|
||||
|
||||
|
||||
@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, "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, "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", "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 = settings.DEFAULT_FROM_EMAIL
|
||||
rn.subject = request.POST["subject"]
|
||||
rn.body = re.sub(r"{{\s*proposal\s*}}", proposal.title, request.POST["body"])
|
||||
rn.save()
|
||||
emails.append(rn.email_args)
|
||||
|
||||
send_mass_mail(emails)
|
||||
|
||||
return redirect("result_notification", section_slug=section_slug, status=status)
|
||||
|
|
|
@ -84,6 +84,11 @@
|
|||
{% trans "Voting Status" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if request.user.is_staff %}
|
||||
<li>
|
||||
<a href="{% url result_notification section.section.slug 'accepted' %}">Result Notification</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
|
153
symposion/templates/reviews/result_notification.html
Normal file
153
symposion/templates/reviews/result_notification.html
Normal file
|
@ -0,0 +1,153 @@
|
|||
{% extends "reviews/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block extra_style %}
|
||||
<style type="text/css">
|
||||
.table-striped tbody tr.selected td {
|
||||
background-color: #F7F4E6;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
<ul class="nav nav-pills">
|
||||
<li{% if status == 'accepted' %} class="active"{% endif %}><a href="{% url result_notification section_slug 'accepted' %}">accepted</a>
|
||||
<li{% if status == 'rejected' %} class="active"{% endif %}><a href="{% url result_notification section_slug 'rejected' %}">rejected</a>
|
||||
<li{% if status == 'standby' %} class="active"{% endif %}><a href="{% url result_notification section_slug 'standby' %}">standby</a>
|
||||
</ul>
|
||||
|
||||
<h1>Result Notification</h1>
|
||||
|
||||
<form method="post" action="{% url result_notification_prepare section_slug status %}">
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
<p>
|
||||
Select one or more proposals (<span class="action-counter">0</span> currently selected)
|
||||
<br/>
|
||||
then pick an email template
|
||||
<select name="notification_template">
|
||||
<option value="">[blank]</option>
|
||||
{% for template in notification_templates %}
|
||||
<option value="{{ template.pk }}">{{ template.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<br/>
|
||||
<button id="next-button" type="submit" class="btn btn-primary" disabled>Next <i class="icon icon-chevron-right"></i></button>
|
||||
</p>
|
||||
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead>
|
||||
<th><input type="checkbox" id="action-toggle"></th>
|
||||
<th>#</th>
|
||||
<th>{% trans "Speaker / Title" %}</th>
|
||||
<th>{% trans "Category" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Notified?" %}</th>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for proposal in proposals %}
|
||||
<tr>
|
||||
<td><input class="action-select" type="checkbox" name="_selected_action" value="{{ proposal.pk }}"></td>
|
||||
<td>{{ proposal.number }}</td>
|
||||
<td>
|
||||
<a href="{% url review_detail proposal.pk %}">
|
||||
<small><strong>{{ proposal.speaker }}</strong></small>
|
||||
<br />
|
||||
{{ proposal.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ proposal.track }}</td>
|
||||
<td>
|
||||
{% with proposal.result.status as status %}
|
||||
<div class="{{ status }}">
|
||||
{% if status != "undecided" %}
|
||||
<span>{{ status }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td>
|
||||
{% if proposal.notifications.exists %}yes{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script type="text/javascript">
|
||||
(function($) {
|
||||
$.fn.actions = function(opts) {
|
||||
var options = $.extend({}, $.fn.actions.defaults, opts);
|
||||
var actionCheckboxes = $(this);
|
||||
checker = function(checked) {
|
||||
$(actionCheckboxes).prop("checked", checked)
|
||||
.parent().parent().toggleClass(options.selectedClass, checked);
|
||||
}
|
||||
updateCounter = function() {
|
||||
var sel = $(actionCheckboxes).filter(":checked").length;
|
||||
$(options.counterContainer).html(sel);
|
||||
$(options.allToggle).prop("checked", function() {
|
||||
if (sel == actionCheckboxes.length) {
|
||||
value = true;
|
||||
} else {
|
||||
value = false;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (sel == 0) {
|
||||
$("#next-button").prop("disabled", true);
|
||||
} else {
|
||||
$("#next-button").prop("disabled", false);
|
||||
}
|
||||
}
|
||||
// Check state of checkboxes and reinit state if needed
|
||||
$(this).filter(":checked").each(function(i) {
|
||||
$(this).parent().parent().toggleClass(options.selectedClass);
|
||||
updateCounter();
|
||||
});
|
||||
$(options.allToggle).click(function() {
|
||||
checker($(this).prop("checked"));
|
||||
updateCounter();
|
||||
});
|
||||
lastChecked = null;
|
||||
$(actionCheckboxes).click(function(event) {
|
||||
if (!event) { var event = window.event; }
|
||||
var target = event.target ? event.target : event.srcElement;
|
||||
if (lastChecked && $.data(lastChecked) != $.data(target) && event.shiftKey == true) {
|
||||
var inrange = false;
|
||||
$(lastChecked).prop("checked", target.checked)
|
||||
.parent().parent().toggleClass(options.selectedClass, target.checked);
|
||||
$(actionCheckboxes).each(function() {
|
||||
if ($.data(this) == $.data(lastChecked) || $.data(this) == $.data(target)) {
|
||||
inrange = (inrange) ? false : true;
|
||||
}
|
||||
if (inrange) {
|
||||
$(this).prop("checked", target.checked)
|
||||
.parent().parent().toggleClass(options.selectedClass, target.checked);
|
||||
}
|
||||
});
|
||||
}
|
||||
$(target).parent().parent().toggleClass(options.selectedClass, target.checked);
|
||||
lastChecked = target;
|
||||
updateCounter();
|
||||
});
|
||||
}
|
||||
/* Setup plugin defaults */
|
||||
$.fn.actions.defaults = {
|
||||
counterContainer: "span.action-counter",
|
||||
allToggle: "#action-toggle",
|
||||
selectedClass: "selected"
|
||||
}
|
||||
})($);
|
||||
$(function() {
|
||||
$("tr input.action-select").actions();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
47
symposion/templates/reviews/result_notification_prepare.html
Normal file
47
symposion/templates/reviews/result_notification_prepare.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
{% extends "reviews/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block body %}
|
||||
<h1>Result Notification Prepare</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="span4">
|
||||
<h2>Proposals</h2>
|
||||
<table class="table table-striped table-compact">
|
||||
{% for proposal in proposals %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ proposal.speaker }}</strong> ({{ proposal.speaker.email }})
|
||||
<br />
|
||||
{{ proposal.title }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="span6">
|
||||
<h2>Email</h2>
|
||||
|
||||
<form method="post" action="{% url result_notification_send section_slug status %}">
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
<label>Subject</label>
|
||||
<input type="text" name="subject" class="span5" value="{{ notification_template.subject }}" />
|
||||
<br/>
|
||||
<label>Body</label>
|
||||
<textarea class="span5" rows="10" name="body">{{ notification_template.body }}</textarea>
|
||||
<br/>
|
||||
<input type="hidden" name="notification_template" value="{{ notification_template.pk }}" />
|
||||
<input type="hidden" name="proposal_pks" value="{{ proposal_pks }}" />
|
||||
<p>
|
||||
If the <b>Body</b> includes the string <code>{% templatetag openvariable %} proposal {% templatetag closevariable %}</code> then it will be
|
||||
replaced with the title of the proposal when the email is sent.
|
||||
</p>
|
||||
<button type="submit" class="btn btn-primary">Send {{ proposals|length }} Email{{ proposals|length|pluralize }}</button>
|
||||
<a class="btn" href="{% url result_notification section_slug status %}">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
Loading…
Reference in a new issue