diff --git a/symposion/proposals/__init__.py b/symposion/proposals/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/symposion/proposals/actions.py b/symposion/proposals/actions.py new file mode 100644 index 0000000000000000000000000000000000000000..b48ac34fc327a0f8721e6f876c5b2dcd5acbaf42 --- /dev/null +++ b/symposion/proposals/actions.py @@ -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 diff --git a/symposion/proposals/admin.py b/symposion/proposals/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..af274aaa0bb87dd5e0fb6b152df77a3f117327d3 --- /dev/null +++ b/symposion/proposals/admin.py @@ -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) diff --git a/symposion/proposals/forms.py b/symposion/proposals/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..3f3fe115b4f009a52310d0fbe31c46c940191d9c --- /dev/null +++ b/symposion/proposals/forms.py @@ -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", + ] diff --git a/symposion/proposals/managers.py b/symposion/proposals/managers.py new file mode 100644 index 0000000000000000000000000000000000000000..b908c7b68edacd41b8bde56e20b4b8053b4430b3 --- /dev/null +++ b/symposion/proposals/managers.py @@ -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) \ No newline at end of file diff --git a/symposion/proposals/models.py b/symposion/proposals/models.py new file mode 100644 index 0000000000000000000000000000000000000000..f602147d1ca115d3dfbceb1a9854e368a3ad284f --- /dev/null +++ b/symposion/proposals/models.py @@ -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 Markdown." + ) + 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 Markdown." + ) + 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()]) diff --git a/symposion/proposals/templatetags/__init__.py b/symposion/proposals/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/symposion/proposals/templatetags/proposal_tags.py b/symposion/proposals/templatetags/proposal_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..2f728847e9d55a284999a9f0a032cd2f6ed7c651 --- /dev/null +++ b/symposion/proposals/templatetags/proposal_tags.py @@ -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) + diff --git a/symposion/proposals/urls.py b/symposion/proposals/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..e317dbe5d5ae6c5bb9ff6e228561c1db2450c1c7 --- /dev/null +++ b/symposion/proposals/urls.py @@ -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"), +) diff --git a/symposion/proposals/views.py b/symposion/proposals/views.py new file mode 100644 index 0000000000000000000000000000000000000000..380098f80eafd5b8cf80810b22aefa26f327fd85 --- /dev/null +++ b/symposion/proposals/views.py @@ -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)