add proposals app from pycon

This commit is contained in:
Luke Hatcher 2012-07-12 00:38:39 -04:00
parent 2b7f5546a0
commit 7596922f4d
10 changed files with 697 additions and 0 deletions

View file

View file

@ -0,0 +1,36 @@
import csv
from django.http import HttpResponse
def export_as_csv_action(description="Export selected objects as CSV file",
fields=None, exclude=None, header=True):
"""
This function returns an export csv action
'fields' and 'exclude' work like in django ModelForm
'header' is whether or not to output the column names as the first row
"""
def export_as_csv(modeladmin, request, queryset):
"""
Generic csv export admin action.
based on http://djangosnippets.org/snippets/1697/
"""
opts = modeladmin.model._meta
if fields:
fieldset = set(fields)
field_names = fieldset
elif exclude:
excludeset = set(exclude)
field_names = field_names - excludeset
response = HttpResponse(mimetype='text/csv')
response['Content-Disposition'] = 'attachment; filename=%s.csv' % unicode(opts).replace('.', '_')
writer = csv.writer(response)
if header:
writer.writerow(list(field_names))
for obj in queryset:
writer.writerow([unicode(getattr(obj, field)).encode("utf-8", "replace") for field in field_names])
return response
export_as_csv.short_description = description
return export_as_csv

View file

@ -0,0 +1,32 @@
from django.contrib import admin
# from symposion.proposals.actions import export_as_csv_action
from symposion.proposals.models import ProposalSection, ProposalKind
# admin.site.register(Proposal,
# list_display = [
# "id",
# "title",
# "speaker",
# "speaker_email",
# "kind",
# "audience_level",
# "cancelled",
# ],
# list_filter = [
# "kind__name",
# "result__accepted",
# ],
# actions = [export_as_csv_action("CSV Export", fields=[
# "id",
# "title",
# "speaker",
# "speaker_email",
# "kind",
# ])]
# )
admin.site.register(ProposalSection)
admin.site.register(ProposalKind)

View file

@ -0,0 +1,41 @@
from django import forms
from django.db.models import Q
from symposion.proposals.models import SupportingDocument
# from markitup.widgets import MarkItUpWidget
# @@@ generic proposal form
class AddSpeakerForm(forms.Form):
email = forms.EmailField(
label="Email address of new speaker (use their email address, not yours)"
)
def __init__(self, *args, **kwargs):
self.proposal = kwargs.pop("proposal")
super(AddSpeakerForm, self).__init__(*args, **kwargs)
def clean_email(self):
value = self.cleaned_data["email"]
exists = self.proposal.additional_speakers.filter(
Q(user=None, invite_email=value) |
Q(user__email=value)
).exists()
if exists:
raise forms.ValidationError(
"This email address has already been invited to your talk proposal"
)
return value
class SupportingDocumentCreateForm(forms.ModelForm):
class Meta:
model = SupportingDocument
fields = [
"file",
"description",
]

View file

@ -0,0 +1,27 @@
from django.db import models
from django.db.models.query import QuerySet
class CachingM2MQuerySet(QuerySet):
def __init__(self, *args, **kwargs):
super(CachingM2MQuerySet, self).__init__(*args, **kwargs)
self.cached_m2m_field = kwargs["m2m_field"]
def iterator(self):
parent_iter = super(CachingM2MQuerySet, self).iterator()
m2m_model = getattr(self.model, self.cached_m2m_field).through
for obj in parent_iter:
if obj.id in cached_objects:
setattr(obj, "_cached_m2m_%s" % self.cached_m2m_field)
yield obj
class ProposalManager(models.Manager):
def cache_m2m(self, m2m_field):
return CachingM2MQuerySet(self.model, using=self._db, m2m_field=m2m_field)
AdditionalSpeaker = queryset.model.additional_speakers.through
additional_speakers = collections.defaultdict(set)
for additional_speaker in AdditionalSpeaker._default_manager.filter(proposal__in=queryset).select_related("speaker__user"):
additional_speakers[additional_speaker.proposal_id].add(additional_speaker.speaker)

View file

@ -0,0 +1,146 @@
import datetime
import os
import uuid
from django.core.urlresolvers import reverse
from django.db import models
from django.db.models import Q
from django.contrib.auth.models import User
from markitup.fields import MarkupField
from model_utils.managers import InheritanceManager
from symposion.conference.models import Section
class ProposalSection(models.Model):
"""
configuration of proposal submissions for a specific Section.
a section is available for proposals iff:
* it is after start (if there is one) and
* it is before end (if there is one) and
* closed is NULL or False
"""
section = models.OneToOneField(Section)
start = models.DateTimeField(null=True, blank=True)
end = models.DateTimeField(null=True, blank=True)
closed = models.NullBooleanField()
published = models.NullBooleanField()
@classmethod
def available(cls):
now = datetime.datetime.now()
return cls._default_manager.filter(
Q(start__lt=now) | Q(start=None),
Q(end__gt=now) | Q(end=None),
Q(closed=False) | Q(closed=None),
)
def __unicode__(self):
return self.section.name
class ProposalKind(models.Model):
"""
e.g. talk vs panel vs tutorial vs poster
Note that if you have different deadlines, reviewers, etc. you'll want
to distinguish the section as well as the kind.
"""
section = models.ForeignKey(Section, related_name="proposal_kinds")
name = models.CharField("name", max_length=100)
slug = models.SlugField()
def __unicode__(self):
return self.name
class ProposalBase(models.Model):
objects = InheritanceManager()
kind = models.ForeignKey(ProposalKind)
title = models.CharField(max_length=100)
description = models.TextField(
max_length=400, # @@@ need to enforce 400 in UI
help_text="If your talk is accepted this will be made public and printed in the program. Should be one paragraph, maximum 400 characters."
)
abstract = MarkupField(
help_text="Detailed description and outline. Will be made public if your talk is accepted. Edit using <a href='http://warpedvisions.org/projects/markdown-cheat-sheet/' target='_blank'>Markdown</a>."
)
additional_notes = MarkupField(
blank=True,
help_text="Anything else you'd like the program committee to know when making their selection: your past speaking experience, open source community experience, etc. Edit using <a href='http://warpedvisions.org/projects/markdown-cheat-sheet/' target='_blank'>Markdown</a>."
)
submitted = models.DateTimeField(
default=datetime.datetime.now,
editable=False,
)
speaker = models.ForeignKey("speakers.Speaker", related_name="proposals")
additional_speakers = models.ManyToManyField("speakers.Speaker", through="AdditionalSpeaker", blank=True)
cancelled = models.BooleanField(default=False)
def can_edit(self):
return True
@property
def speaker_email(self):
return self.speaker.email
@property
def number(self):
return str(self.pk).zfill(3)
def speakers(self):
yield self.speaker
for speaker in self.additional_speakers.exclude(additionalspeaker__status=AdditionalSpeaker.SPEAKING_STATUS_DECLINED):
yield speaker
class AdditionalSpeaker(models.Model):
SPEAKING_STATUS_PENDING = 1
SPEAKING_STATUS_ACCEPTED = 2
SPEAKING_STATUS_DECLINED = 3
SPEAKING_STATUS = [
(SPEAKING_STATUS_PENDING, "Pending"),
(SPEAKING_STATUS_ACCEPTED, "Accepted"),
(SPEAKING_STATUS_DECLINED, "Declined"),
]
speaker = models.ForeignKey("speakers.Speaker")
proposalbase = models.ForeignKey(ProposalBase)
status = models.IntegerField(choices=SPEAKING_STATUS, default=SPEAKING_STATUS_PENDING)
class Meta:
db_table = "proposals_proposalbase_additional_speakers"
unique_together = ("speaker", "proposalbase")
def uuid_filename(instance, filename):
ext = filename.split(".")[-1]
filename = "%s.%s" % (uuid.uuid4(), ext)
return os.path.join("document", filename)
class SupportingDocument(models.Model):
proposal = models.ForeignKey(ProposalBase, related_name="supporting_documents")
uploaded_by = models.ForeignKey(User)
created_at = models.DateTimeField(default=datetime.datetime.now)
file = models.FileField(upload_to=uuid_filename)
description = models.CharField(max_length=140)
def download_url(self):
return reverse("proposal_document_download", args=[self.pk, os.path.basename(self.file.name).lower()])

View file

@ -0,0 +1,73 @@
from django import template
from symposion.proposals.models import AdditionalSpeaker
register = template.Library()
class AssociatedProposalsNode(template.Node):
@classmethod
def handle_token(cls, parser, token):
bits = token.split_contents()
if len(bits) == 3 and bits[1] == "as":
return cls(bits[2])
else:
raise template.TemplateSyntaxError("%r takes 'as var'" % bits[0])
def __init__(self, context_var):
self.context_var = context_var
def render(self, context):
request = context["request"]
if request.user.speaker_profile:
pending = AdditionalSpeaker.SPEAKING_STATUS_ACCEPTED
speaker = request.user.speaker_profile
queryset = AdditionalSpeaker.objects.filter(speaker=speaker, status=pending)
context[self.context_var] = [item.proposalbase for item in queryset]
else:
context[self.context_var] = None
return u""
class PendingProposalsNode(template.Node):
@classmethod
def handle_token(cls, parser, token):
bits = token.split_contents()
if len(bits) == 3 and bits[1] == "as":
return cls(bits[2])
else:
raise template.TemplateSyntaxError("%r takes 'as var'" % bits[0])
def __init__(self, context_var):
self.context_var = context_var
def render(self, context):
request = context["request"]
if request.user.speaker_profile:
pending = AdditionalSpeaker.SPEAKING_STATUS_PENDING
speaker = request.user.speaker_profile
queryset = AdditionalSpeaker.objects.filter(speaker=speaker, status=pending)
context[self.context_var] = [item.proposalbase for item in queryset]
else:
context[self.context_var] = None
return u""
@register.tag
def pending_proposals(parser, token):
"""
{% pending_proposals as pending_proposals %}
"""
return PendingProposalsNode.handle_token(parser, token)
@register.tag
def associated_proposals(parser, token):
"""
{% associated_proposals as associated_proposals %}
"""
return AssociatedProposalsNode.handle_token(parser, token)

View file

@ -0,0 +1,18 @@
from django.conf.urls.defaults import *
urlpatterns = patterns("symposion.proposals.views",
url(r"^submit/$", "proposal_submit", name="proposal_submit"),
url(r"^submit/(\w+)/$", "proposal_submit_kind", name="proposal_submit_kind"),
url(r"^(\d+)/$", "proposal_detail", name="proposal_detail"),
url(r"^(\d+)/edit/$", "proposal_edit", name="proposal_edit"),
url(r"^(\d+)/speakers/$", "proposal_speaker_manage", name="proposal_speaker_manage"),
url(r"^(\d+)/cancel/$", "proposal_cancel", name="proposal_cancel"),
url(r"^(\d+)/leave/$", "proposal_leave", name="proposal_leave"),
url(r"^(\d+)/join/$", "proposal_pending_join", name="proposal_pending_join"),
url(r"^(\d+)/decline/$", "proposal_pending_decline", name="proposal_pending_decline"),
url(r"^(\d+)/document/create/$", "document_create", name="proposal_document_create"),
url(r"^document/(\d+)/delete/$", "document_delete", name="proposal_document_delete"),
url(r"^document/(\d+)/([^/]+)$", "document_download", name="proposal_document_download"),
)

View file

@ -0,0 +1,324 @@
import random
import sys
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import render, redirect, get_object_or_404
from django.utils.hashcompat import sha_constructor
from django.views import static
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from account.models import EmailAddress
from symposion.proposals.models import ProposalBase, ProposalSection, ProposalKind
from symposion.proposals.models import SupportingDocument, AdditionalSpeaker
from symposion.speakers.models import Speaker
from symposion.utils.mail import send_email
from symposion.proposals.forms import AddSpeakerForm, SupportingDocumentCreateForm
def get_form(name):
dot = name.rindex('.')
mod_name, form_name = name[:dot], name[dot + 1:]
__import__(mod_name)
return getattr(sys.modules[mod_name], form_name)
def proposal_submit(request):
if not request.user.is_authenticated():
return redirect("home") # @@@ unauth'd speaker info page?
else:
try:
request.user.speaker_profile
except ObjectDoesNotExist:
return redirect("dashboard")
kinds = []
for proposal_section in ProposalSection.available():
for kind in proposal_section.section.proposal_kinds.all():
kinds.append(kind)
return render(request, "proposals/proposal_submit.html", {
"kinds": kinds,
})
def proposal_submit_kind(request, kind_slug):
kind = get_object_or_404(ProposalKind, slug=kind_slug)
if not request.user.is_authenticated():
return redirect("home") # @@@ unauth'd speaker info page?
else:
try:
speaker_profile = request.user.speaker_profile
except ObjectDoesNotExist:
return redirect("dashboard")
form_class = get_form(settings.PROPOSAL_FORMS[kind_slug])
if request.method == "POST":
form = form_class(request.POST)
if form.is_valid():
proposal = form.save(commit=False)
proposal.kind = kind
proposal.speaker = speaker_profile
proposal.save()
form.save_m2m()
messages.success(request, "Proposal submitted.")
if "add-speakers" in request.POST:
return redirect("proposal_speaker_manage", proposal.pk)
return redirect("dashboard")
else:
form = form_class()
return render(request, "proposals/proposal_submit_kind.html", {
"kind": kind,
"form": form,
})
@login_required
def proposal_speaker_manage(request, pk):
queryset = ProposalBase.objects.select_related("speaker")
proposal = get_object_or_404(queryset, pk=pk)
proposal = ProposalBase.objects.get_subclass(pk=proposal.pk)
if proposal.speaker != request.user.speaker_profile:
raise Http404()
if request.method == "POST":
add_speaker_form = AddSpeakerForm(request.POST, proposal=proposal)
if add_speaker_form.is_valid():
message_ctx = {
"proposal": proposal,
}
def create_speaker_token(email_address):
# create token and look for an existing speaker to prevent
# duplicate tokens and confusing the pending speaker
try:
pending = Speaker.objects.get(
Q(user=None, invite_email=email_address)
)
except Speaker.DoesNotExist:
salt = sha_constructor(str(random.random())).hexdigest()[:5]
token = sha_constructor(salt + email_address).hexdigest()
pending = Speaker.objects.create(
invite_email=email_address,
invite_token=token,
)
else:
token = pending.invite_token
return pending, token
email_address = add_speaker_form.cleaned_data["email"]
# check if email is on the site now
users = EmailAddress.objects.get_users_for(email_address)
if users:
# should only be one since we enforce unique email
user = users[0]
message_ctx["user"] = user
# look for speaker profile
try:
speaker = user.speaker_profile
except ObjectDoesNotExist:
speaker, token = create_speaker_token(email_address)
message_ctx["token"] = token
# fire off email to user to create profile
send_email(
[email_address], "speaker_no_profile",
context = message_ctx
)
else:
# fire off email to user letting them they are loved.
send_email(
[email_address], "speaker_addition",
context = message_ctx
)
else:
speaker, token = create_speaker_token(email_address)
message_ctx["token"] = token
# fire off email letting user know about site and to create
# account and speaker profile
send_email(
[email_address], "speaker_invite",
context = message_ctx
)
invitation, created = AdditionalSpeaker.objects.get_or_create(proposalbase=proposal.proposalbase_ptr, speaker=speaker)
messages.success(request, "Speaker invited to proposal.")
return redirect("proposal_speaker_manage", proposal.pk)
else:
add_speaker_form = AddSpeakerForm(proposal=proposal)
ctx = {
"proposal": proposal,
"speakers": proposal.speakers(),
"add_speaker_form": add_speaker_form,
}
return render(request, "proposals/proposal_speaker_manage.html", ctx)
@login_required
def proposal_edit(request, pk):
queryset = ProposalBase.objects.select_related("speaker")
proposal = get_object_or_404(queryset, pk=pk)
proposal = ProposalBase.objects.get_subclass(pk=proposal.pk)
if request.user != proposal.speaker.user:
raise Http404()
if not proposal.can_edit():
ctx = {
"title": "Proposal editing closed",
"body": "Proposal editing is closed for this session type."
}
return render(request, "proposals/proposal_error.html", ctx)
form_class = get_form(settings.PROPOSAL_FORMS[proposal.kind.slug])
if request.method == "POST":
form = form_class(request.POST, instance=proposal)
if form.is_valid():
form.save()
messages.success(request, "Proposal updated.")
return redirect("proposal_detail", proposal.pk)
else:
form = form_class(instance=proposal)
return render(request, "proposals/proposal_edit.html", {
"proposal": proposal,
"form": form,
})
@login_required
def proposal_detail(request, pk):
queryset = ProposalBase.objects.select_related("speaker", "speaker__user")
proposal = get_object_or_404(queryset, pk=pk)
proposal = ProposalBase.objects.get_subclass(pk=proposal.pk)
if request.user not in [p.user for p in proposal.speakers()]:
raise Http404()
return render(request, "proposals/proposal_detail.html", {
"proposal": proposal,
})
@login_required
def proposal_cancel(request, pk):
queryset = ProposalBase.objects.select_related("speaker")
proposal = get_object_or_404(queryset, pk=pk)
proposal = ProposalBase.objects.get_subclass(pk=proposal.pk)
if proposal.speaker.user != request.user:
return HttpResponseForbidden()
if request.method == "POST":
proposal.cancelled = True
proposal.save()
# @@@ fire off email to submitter and other speakers
messages.success(request, "%s has been cancelled" % proposal.title)
return redirect("dashboard")
return render(request, "proposals/proposal_cancel.html", {
"proposal": proposal,
})
@login_required
def proposal_leave(request, pk):
queryset = ProposalBase.objects.select_related("speaker")
proposal = get_object_or_404(queryset, pk=pk)
proposal = ProposalBase.objects.get_subclass(pk=proposal.pk)
try:
speaker = proposal.additional_speakers.get(user=request.user)
except ObjectDoesNotExist:
return HttpResponseForbidden()
if request.method == "POST":
proposal.additional_speakers.remove(speaker)
# @@@ fire off email to submitter and other speakers
messages.success(request, "You are no longer speaking on %s" % proposal.title)
return redirect("speaker_dashboard")
ctx = {
"proposal": proposal,
}
return render(request, "proposals/proposal_leave.html", ctx)
@login_required
def proposal_pending_join(request, pk):
proposal = get_object_or_404(ProposalBase, pk=pk)
speaking = get_object_or_404(AdditionalSpeaker, speaker=request.user.speaker_profile, proposalbase=proposal)
if speaking.status == AdditionalSpeaker.SPEAKING_STATUS_PENDING:
speaking.status = AdditionalSpeaker.SPEAKING_STATUS_ACCEPTED
speaking.save()
messages.success(request, "You have accepted the invitation to join %s" % proposal.title)
return redirect("dashboard")
else:
return redirect("dashboard")
@login_required
def proposal_pending_decline(request, pk):
proposal = get_object_or_404(ProposalBase, pk=pk)
speaking = get_object_or_404(AdditionalSpeaker, speaker=request.user.speaker_profile, proposalbase=proposal)
if speaking.status == AdditionalSpeaker.SPEAKING_STATUS_PENDING:
speaking.status = AdditionalSpeaker.SPEAKING_STATUS_DECLINED
speaking.save()
messages.success(request, "You have declined to speak on %s" % proposal.title)
return redirect("dashboard")
else:
return redirect("dashboard")
@login_required
def document_create(request, proposal_pk):
queryset = ProposalBase.objects.select_related("speaker")
proposal = get_object_or_404(queryset, pk=proposal_pk)
proposal = ProposalBase.objects.get_subclass(pk=proposal.pk)
if request.method == "POST":
form = SupportingDocumentCreateForm(request.POST, request.FILES)
if form.is_valid():
document = form.save(commit=False)
document.proposal = proposal
document.uploaded_by = request.user
document.save()
return redirect("proposal_detail", proposal.pk)
else:
form = SupportingDocumentCreateForm()
return render(request, "proposals/document_create.html", {
"proposal": proposal,
"form": form,
})
@login_required
def document_download(request, pk, *args):
document = get_object_or_404(SupportingDocument, pk=pk)
if settings.USE_X_ACCEL_REDIRECT:
response = HttpResponse()
response["X-Accel-Redirect"] = document.file.url
# delete content-type to allow Gondor to determine the filetype and
# we definitely don't want Django's crappy default :-)
del response["content-type"]
else:
response = static.serve(request, document.file.name, document_root=settings.MEDIA_ROOT)
return response
@login_required
def document_delete(request, pk):
document = get_object_or_404(SupportingDocument, pk=pk, uploaded_by=request.user)
proposal_pk = document.proposal.pk
if request.method == "POST":
document.delete()
return redirect("proposal_detail", proposal_pk)