diff --git a/symposion/proposals/__init__.py b/symposion/proposals/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/symposion/proposals/actions.py b/symposion/proposals/actions.py
new file mode 100644
index 00000000..b48ac34f
--- /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 00000000..af274aaa
--- /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 00000000..3f3fe115
--- /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 00000000..b908c7b6
--- /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 00000000..f602147d
--- /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 00000000..e69de29b
diff --git a/symposion/proposals/templatetags/proposal_tags.py b/symposion/proposals/templatetags/proposal_tags.py
new file mode 100644
index 00000000..2f728847
--- /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 00000000..e317dbe5
--- /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 00000000..380098f8
--- /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)