diff --git a/pycon/__init__.py b/pycon/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pycon/admin.py b/pycon/admin.py
new file mode 100644
index 00000000..66933a2e
--- /dev/null
+++ b/pycon/admin.py
@@ -0,0 +1,8 @@
+from django.contrib import admin
+
+from pycon.models import PyConProposalCategory, PyConTalkProposal, PyConTutorialProposal, PyConPosterProposal
+
+admin.site.register(PyConProposalCategory)
+admin.site.register(PyConTalkProposal)
+admin.site.register(PyConTutorialProposal)
+admin.site.register(PyConPosterProposal)
\ No newline at end of file
diff --git a/pycon/forms.py b/pycon/forms.py
new file mode 100644
index 00000000..74c1b0b3
--- /dev/null
+++ b/pycon/forms.py
@@ -0,0 +1,84 @@
+from django import forms
+
+from markitup.widgets import MarkItUpWidget
+
+from pycon.models import PyConProposalCategory, PyConTalkProposal, PyConTutorialProposal, PyConPosterProposal
+
+
+class PyConProposalForm(forms.ModelForm):
+
+ def __init__(self, *args, **kwargs):
+ super(PyConProposalForm, self).__init__(*args, **kwargs)
+ self.fields["category"] = forms.ModelChoiceField(
+ queryset = PyConProposalCategory.objects.order_by("name")
+ )
+
+ def clean_description(self):
+ value = self.cleaned_data["description"]
+ if len(value) > 400:
+ raise forms.ValidationError(
+ u"The description must be less than 400 characters"
+ )
+ return value
+
+
+class PyConTalkProposalForm(PyConProposalForm):
+
+ class Meta:
+ model = PyConTalkProposal
+ fields = [
+ "title",
+ "category",
+ "audience_level",
+ "extreme",
+ "duration",
+ "description",
+ "abstract",
+ "additional_notes",
+ "recording_release",
+ ]
+ widgets = {
+ "abstract": MarkItUpWidget(),
+ "additional_notes": MarkItUpWidget(),
+ }
+
+
+class PyConTutorialProposalForm(PyConProposalForm):
+
+ class Meta:
+ model = PyConTutorialProposal
+ fields = [
+ "title",
+ "category",
+ "audience_level",
+ "description",
+ "abstract",
+ "additional_notes",
+ "recording_release",
+
+ ]
+ widgets = {
+ "abstract": MarkItUpWidget(),
+ "additional_notes": MarkItUpWidget(),
+ }
+
+
+class PyConPosterProposalForm(PyConProposalForm):
+
+ class Meta:
+ model = PyConPosterProposal
+ fields = [
+ "title",
+ "category",
+ "audience_level",
+ "description",
+ "abstract",
+ "additional_notes",
+ "recording_release",
+
+ ]
+ widgets = {
+ "abstract": MarkItUpWidget(),
+ "additional_notes": MarkItUpWidget(),
+ }
+
diff --git a/pycon/models.py b/pycon/models.py
new file mode 100644
index 00000000..9e2299ea
--- /dev/null
+++ b/pycon/models.py
@@ -0,0 +1,68 @@
+from django.db import models
+
+from symposion.proposals.models import ProposalBase
+
+
+class PyConProposalCategory(models.Model):
+
+ name = models.CharField(max_length=100)
+ slug = models.SlugField()
+
+ def __unicode__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "PyCon proposal category"
+ verbose_name_plural = "PyCon proposal categories"
+
+
+class PyConProposal(ProposalBase):
+
+ AUDIENCE_LEVEL_NOVICE = 1
+ AUDIENCE_LEVEL_EXPERIENCED = 2
+ AUDIENCE_LEVEL_INTERMEDIATE = 3
+
+ AUDIENCE_LEVELS = [
+ (AUDIENCE_LEVEL_NOVICE, "Novice"),
+ (AUDIENCE_LEVEL_INTERMEDIATE, "Intermediate"),
+ (AUDIENCE_LEVEL_EXPERIENCED, "Experienced"),
+ ]
+
+ category = models.ForeignKey(PyConProposalCategory)
+ audience_level = models.IntegerField(choices=AUDIENCE_LEVELS)
+
+ recording_release = models.BooleanField(
+ default=True,
+ help_text="By submitting your talk proposal, you agree to give permission to the Python Software Foundation to record, edit, and release audio and/or video of your presentation. If you do not agree to this, please uncheck this box. See PyCon 2013 Recording Release for details."
+ )
+
+ class Meta:
+ abstract = True
+
+
+class PyConTalkProposal(PyConProposal):
+
+ DURATION_CHOICES = [
+ (0, "No preference"),
+ (1, "I prefer a 30 minute slot"),
+ (2, "I prefer a 45 minute slot"),
+ ]
+
+ extreme = models.BooleanField(
+ default=False,
+ help_text="'Extreme' talks are advanced talks with little or no introductory material. See Extreme Talks for details."
+ )
+ duration = models.IntegerField(choices=DURATION_CHOICES)
+
+ class Meta:
+ verbose_name = "PyCon talk proposal"
+
+
+class PyConTutorialProposal(PyConProposal):
+ class Meta:
+ verbose_name = "PyCon tutorial proposal"
+
+
+class PyConPosterProposal(PyConProposal):
+ class Meta:
+ verbose_name = "PyCon poster proposal"
diff --git a/pycon/sponsorship/__init__.py b/pycon/sponsorship/__init__.py
new file mode 100644
index 00000000..3cb4bc97
--- /dev/null
+++ b/pycon/sponsorship/__init__.py
@@ -0,0 +1,5 @@
+SPONSOR_COORDINATORS = "sponsor-coordinators"
+
+AUTH_GROUPS = [
+ SPONSOR_COORDINATORS
+]
diff --git a/pycon/sponsorship/admin.py b/pycon/sponsorship/admin.py
new file mode 100644
index 00000000..22096c77
--- /dev/null
+++ b/pycon/sponsorship/admin.py
@@ -0,0 +1,69 @@
+from django.contrib import admin
+
+from pycon.sponsorship.models import SponsorLevel, Sponsor, Benefit, BenefitLevel, SponsorBenefit
+
+
+class BenefitLevelInline(admin.TabularInline):
+ model = BenefitLevel
+ extra = 0
+
+
+class SponsorBenefitInline(admin.StackedInline):
+ model = SponsorBenefit
+ extra = 0
+ fieldsets = [
+ (None, {
+ "fields": [
+ ("benefit", "active"),
+ ("max_words", "other_limits"),
+ "text",
+ "upload",
+ ]
+ })
+ ]
+
+
+class SponsorAdmin(admin.ModelAdmin):
+
+ save_on_top = True
+ fieldsets = [
+ (None, {
+ "fields": [
+ ("name", "applicant"),
+ ("level", "active"),
+ "external_url",
+ "annotation",
+ ("contact_name", "contact_email")
+ ]
+ }),
+ ("Metadata", {
+ "fields": ["added"],
+ "classes": ["collapse"]
+ })
+ ]
+ inlines = [SponsorBenefitInline]
+
+ def get_form(self, *args, **kwargs):
+ # @@@ kinda ugly but using choices= on NullBooleanField is broken
+ form = super(SponsorAdmin, self).get_form(*args, **kwargs)
+ form.base_fields["active"].widget.choices = [
+ (u"1", "unreviewed"),
+ (u"2", "approved"),
+ (u"3", "rejected")
+ ]
+ return form
+
+
+class BenefitAdmin(admin.ModelAdmin):
+
+ inlines = [BenefitLevelInline]
+
+
+class SponsorLevelAdmin(admin.ModelAdmin):
+
+ inlines = [BenefitLevelInline]
+
+
+admin.site.register(SponsorLevel, SponsorLevelAdmin)
+admin.site.register(Sponsor, SponsorAdmin)
+admin.site.register(Benefit, BenefitAdmin)
\ No newline at end of file
diff --git a/pycon/sponsorship/forms.py b/pycon/sponsorship/forms.py
new file mode 100644
index 00000000..70866ccf
--- /dev/null
+++ b/pycon/sponsorship/forms.py
@@ -0,0 +1,72 @@
+from django import forms
+from django.forms.models import inlineformset_factory, BaseInlineFormSet
+
+from django.contrib.admin.widgets import AdminFileWidget
+
+from pycon.sponsorship.models import Sponsor, SponsorBenefit
+
+
+class SponsorApplicationForm(forms.ModelForm):
+ def __init__(self, *args, **kwargs):
+ self.user = kwargs.pop("user")
+ kwargs.update({
+ "initial": {
+ "contact_name": self.user.get_full_name,
+ "contact_email": self.user.email,
+ }
+ })
+ super(SponsorApplicationForm, self).__init__(*args, **kwargs)
+
+ class Meta:
+ model = Sponsor
+ fields = ["name", "contact_name", "contact_email", "level"]
+
+ def save(self, commit=True):
+ obj = super(SponsorApplicationForm, self).save(commit=False)
+ obj.applicant = self.user
+ if commit:
+ obj.save()
+ return obj
+
+
+class SponsorDetailsForm(forms.ModelForm):
+ class Meta:
+ model = Sponsor
+ fields = [
+ "name",
+ "external_url",
+ "contact_name",
+ "contact_email"
+ ]
+
+
+class SponsorBenefitsInlineFormSet(BaseInlineFormSet):
+
+ def _construct_form(self, i, **kwargs):
+ form = super(SponsorBenefitsInlineFormSet, self)._construct_form(i, **kwargs)
+
+ # only include the relevant data fields for this benefit type
+ fields = form.instance.data_fields()
+ form.fields = dict((k, v) for (k, v) in form.fields.items() if k in fields + ["id"])
+
+ for field in fields:
+ # don't need a label, the form template will label it with the benefit name
+ form.fields[field].label = ""
+
+ # provide word limit as help_text
+ if form.instance.benefit.type == "text" and form.instance.max_words:
+ form.fields[field].help_text = u"maximum %s words" % form.instance.max_words
+
+ # use admin file widget that shows currently uploaded file
+ if field == "upload":
+ form.fields[field].widget = AdminFileWidget()
+
+ return form
+
+
+SponsorBenefitsFormSet = inlineformset_factory(
+ Sponsor, SponsorBenefit,
+ formset=SponsorBenefitsInlineFormSet,
+ can_delete=False, extra=0,
+ fields=["text", "upload"]
+)
diff --git a/pycon/sponsorship/management/__init__.py b/pycon/sponsorship/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pycon/sponsorship/management/commands/__init__.py b/pycon/sponsorship/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pycon/sponsorship/management/commands/create_sponsors_groups.py b/pycon/sponsorship/management/commands/create_sponsors_groups.py
new file mode 100644
index 00000000..2a389e6a
--- /dev/null
+++ b/pycon/sponsorship/management/commands/create_sponsors_groups.py
@@ -0,0 +1,12 @@
+from django.core.management.base import BaseCommand
+
+from django.contrib.auth.models import Group
+
+from pycon.sponsorship import AUTH_GROUPS
+
+
+class Command(BaseCommand):
+
+ def handle(self, *args, **options):
+ for group in AUTH_GROUPS:
+ Group.objects.get_or_create(name=group)
diff --git a/pycon/sponsorship/management/commands/export_sponsors_data.py b/pycon/sponsorship/management/commands/export_sponsors_data.py
new file mode 100644
index 00000000..f7df698c
--- /dev/null
+++ b/pycon/sponsorship/management/commands/export_sponsors_data.py
@@ -0,0 +1,80 @@
+import csv
+import os
+import shutil
+import zipfile
+
+from contextlib import closing
+
+from django.core.management.base import BaseCommand, CommandError
+from django.template.defaultfilters import slugify
+
+from pycon.sponsorship.models import Sponsor
+
+
+def zipdir(basedir, archivename):
+ assert os.path.isdir(basedir)
+ with closing(zipfile.ZipFile(archivename, "w", zipfile.ZIP_DEFLATED)) as z:
+ for root, dirs, files in os.walk(basedir):
+ #NOTE: ignore empty directories
+ for fn in files:
+ absfn = os.path.join(root, fn)
+ zfn = absfn[len(basedir)+len(os.sep):] #XXX: relative path
+ z.write(absfn, zfn)
+
+
+class Command(BaseCommand):
+
+ def handle(self, *args, **options):
+ try:
+ os.makedirs(os.path.join(os.getcwd(), "build"))
+ except:
+ pass
+
+ csv_file = csv.writer(
+ open(os.path.join(os.getcwd(), "build", "sponsors.csv"), "wb")
+ )
+ csv_file.writerow(["Name", "URL", "Level", "Description"])
+
+ for sponsor in Sponsor.objects.all():
+ path = os.path.join(os.getcwd(), "build", slugify(sponsor.name))
+ try:
+ os.makedirs(path)
+ except:
+ pass
+
+ data = {
+ "name": sponsor.name,
+ "url": sponsor.external_url,
+ "level": sponsor.level.name,
+ "description": "",
+ }
+ for sponsor_benefit in sponsor.sponsor_benefits.all():
+ if sponsor_benefit.benefit_id == 2:
+ data["description"] = sponsor_benefit.text
+ if sponsor_benefit.benefit_id == 1:
+ if sponsor_benefit.upload:
+ data["ad"] = sponsor_benefit.upload.path
+ if sponsor_benefit.benefit_id == 7:
+ if sponsor_benefit.upload:
+ data["logo"] = sponsor_benefit.upload.path
+
+ if "ad" in data:
+ ad_path = data.pop("ad")
+ shutil.copy(ad_path, path)
+ if "logo" in data:
+ logo_path = data.pop("logo")
+ shutil.copy(logo_path, path)
+
+ csv_file.writerow([
+ data["name"].encode("utf-8"),
+ data["url"].encode("utf-8"),
+ data["level"].encode("utf-8"),
+ data["description"].encode("utf-8")
+ ])
+
+ zipdir(
+ os.path.join(
+ os.getcwd(), "build"),
+ os.path.join(os.getcwd(), "sponsors.zip"
+ )
+ )
diff --git a/pycon/sponsorship/management/commands/reset_sponsor_benefits.py b/pycon/sponsorship/management/commands/reset_sponsor_benefits.py
new file mode 100644
index 00000000..cb2e833f
--- /dev/null
+++ b/pycon/sponsorship/management/commands/reset_sponsor_benefits.py
@@ -0,0 +1,38 @@
+from django.core.management.base import BaseCommand
+
+from django.contrib.auth.models import Group
+
+from pycon.sponsorship.models import Sponsor, SponsorBenefit
+
+
+class Command(BaseCommand):
+
+ def handle(self, *args, **options):
+ for sponsor in Sponsor.objects.all():
+ level = None
+ try:
+ level = sponsor.level
+ except SponsorLevel.DoesNotExist:
+ pass
+ if level:
+ for benefit_level in level.benefit_levels.all():
+ # Create all needed benefits if they don't exist already
+ sponsor_benefit, created = SponsorBenefit.objects.get_or_create(
+ sponsor=sponsor, benefit=benefit_level.benefit)
+
+ if created:
+ print "created", sponsor_benefit, "for", sponsor
+
+ # and set to default limits for this level.
+ sponsor_benefit.max_words = benefit_level.max_words
+ sponsor_benefit.other_limits = benefit_level.other_limits
+
+ # and set to active
+ sponsor_benefit.active = True
+
+ # @@@ We don't call sponsor_benefit.clean here. This means
+ # that if the sponsorship level for a sponsor is adjusted
+ # downwards, an existing too-long text entry can remain,
+ # and won't raise a validation error until it's next
+ # edited.
+ sponsor_benefit.save()
diff --git a/pycon/sponsorship/managers.py b/pycon/sponsorship/managers.py
new file mode 100644
index 00000000..637c311b
--- /dev/null
+++ b/pycon/sponsorship/managers.py
@@ -0,0 +1,38 @@
+from django.db import models
+
+
+class SponsorManager(models.Manager):
+
+ def active(self):
+ return self.get_query_set().filter(active=True).order_by("level")
+
+ def with_weblogo(self):
+ queryset = self.raw("""
+ SELECT DISTINCT
+ "sponsorship_sponsor"."id",
+ "sponsorship_sponsor"."applicant_id",
+ "sponsorship_sponsor"."name",
+ "sponsorship_sponsor"."external_url",
+ "sponsorship_sponsor"."annotation",
+ "sponsorship_sponsor"."contact_name",
+ "sponsorship_sponsor"."contact_email",
+ "sponsorship_sponsor"."level_id",
+ "sponsorship_sponsor"."added",
+ "sponsorship_sponsor"."active",
+ "sponsorship_sponsorlevel"."order"
+ FROM
+ "sponsorship_sponsor"
+ INNER JOIN
+ "sponsorship_sponsorbenefit" ON ("sponsorship_sponsor"."id" = "sponsorship_sponsorbenefit"."sponsor_id")
+ INNER JOIN
+ "sponsorship_benefit" ON ("sponsorship_sponsorbenefit"."benefit_id" = "sponsorship_benefit"."id")
+ LEFT OUTER JOIN
+ "sponsorship_sponsorlevel" ON ("sponsorship_sponsor"."level_id" = "sponsorship_sponsorlevel"."id")
+ WHERE (
+ "sponsorship_sponsor"."active" = 't' AND
+ "sponsorship_benefit"."type" = 'weblogo' AND
+ "sponsorship_sponsorbenefit"."upload" != ''
+ )
+ ORDER BY "sponsorship_sponsorlevel"."order" ASC, "sponsorship_sponsor"."added" ASC
+ """)
+ return queryset
diff --git a/pycon/sponsorship/models.py b/pycon/sponsorship/models.py
new file mode 100644
index 00000000..8320b049
--- /dev/null
+++ b/pycon/sponsorship/models.py
@@ -0,0 +1,266 @@
+import datetime
+
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse
+from django.db import models
+from django.db.models.signals import post_init, post_save
+from django.utils.translation import ugettext_lazy as _
+
+from django.contrib.auth.models import User
+
+from symposion.conference.models import Conference
+
+from pycon.sponsorship import SPONSOR_COORDINATORS
+from pycon.sponsorship.managers import SponsorManager
+# from symposion.utils.mail import send_email
+
+
+class SponsorLevel(models.Model):
+
+ conference = models.ForeignKey(Conference, verbose_name=_("conference"))
+ name = models.CharField(_("name"), max_length=100)
+ order = models.IntegerField(_("order"), default=0)
+ cost = models.PositiveIntegerField(_("cost"))
+ description = models.TextField(_("description"), blank=True, help_text=_("This is private."))
+
+ class Meta:
+ ordering = ["conference", "order"]
+ verbose_name = _("sponsor level")
+ verbose_name_plural = _("sponsor levels")
+
+ def __unicode__(self):
+ return u"%s %s" % (self.conference, self.name)
+
+ def sponsors(self):
+ return self.sponsor_set.filter(active=True).order_by("added")
+
+
+class Sponsor(models.Model):
+
+ applicant = models.ForeignKey(User, related_name="sponsorships", verbose_name=_("applicant"), null=True)
+
+ name = models.CharField(_("Sponsor Name"), max_length=100)
+ external_url = models.URLField(_("external URL"))
+ annotation = models.TextField(_("annotation"), blank=True)
+ contact_name = models.CharField(_("Contact Name"), max_length=100)
+ contact_email = models.EmailField(_(u"Contact Email"))
+ level = models.ForeignKey(SponsorLevel, verbose_name=_("level"))
+ added = models.DateTimeField(_("added"), default=datetime.datetime.now)
+ active = models.BooleanField(_("active"), default=False)
+
+ # Denormalization (this assumes only one logo)
+ sponsor_logo = models.ForeignKey("SponsorBenefit", related_name="+", null=True, blank=True, editable=False)
+
+ objects = SponsorManager()
+
+ def __unicode__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = _("sponsor")
+ verbose_name_plural = _("sponsors")
+
+ def get_absolute_url(self):
+ if self.active:
+ return reverse("sponsor_detail", kwargs={"pk": self.pk})
+ return reverse("sponsor_list")
+
+ @property
+ def website_logo_url(self):
+ if not hasattr(self, "_website_logo_url"):
+ self._website_logo_url = None
+ benefits = self.sponsor_benefits.filter(benefit__type="weblogo", upload__isnull=False)
+ if benefits.exists():
+ # @@@ smarter handling of multiple weblogo benefits?
+ # shouldn't happen
+ if benefits[0].upload:
+ self._website_logo_url = benefits[0].upload.url
+ return self._website_logo_url
+
+ @property
+ def listing_text(self):
+ if not hasattr(self, "_listing_text"):
+ self._listing_text = None
+ benefits = self.sponsor_benefits.filter(benefit__id=7)
+ if benefits.count():
+ self._listing_text = benefits[0].text
+ return self._listing_text
+
+ @property
+ def joblisting_text(self):
+ if not hasattr(self, "_joblisting_text"):
+ self._joblisting_text = None
+ benefits = self.sponsor_benefits.filter(benefit__id=21)
+ if benefits.count():
+ self._joblisting_text = benefits[0].text
+ return self._joblisting_text
+
+ @property
+ def website_logo(self):
+ if self.sponsor_logo is None:
+ benefits = self.sponsor_benefits.filter(benefit__type="weblogo", upload__isnull=False)[:1]
+ if benefits.count():
+ if benefits[0].upload:
+ self.sponsor_logo = benefits[0]
+ self.save()
+ return self.sponsor_logo.upload
+
+ def reset_benefits(self):
+ """
+ Reset all benefits for this sponsor to the defaults for their
+ sponsorship level.
+ """
+ level = None
+
+ try:
+ level = self.level
+ except SponsorLevel.DoesNotExist:
+ pass
+
+ allowed_benefits = []
+ if level:
+ for benefit_level in level.benefit_levels.all():
+ # Create all needed benefits if they don't exist already
+ sponsor_benefit, created = SponsorBenefit.objects.get_or_create(
+ sponsor=self, benefit=benefit_level.benefit)
+
+ # and set to default limits for this level.
+ sponsor_benefit.max_words = benefit_level.max_words
+ sponsor_benefit.other_limits = benefit_level.other_limits
+
+ # and set to active
+ sponsor_benefit.active = True
+
+ # @@@ We don't call sponsor_benefit.clean here. This means
+ # that if the sponsorship level for a sponsor is adjusted
+ # downwards, an existing too-long text entry can remain,
+ # and won't raise a validation error until it's next
+ # edited.
+ sponsor_benefit.save()
+
+ allowed_benefits.append(sponsor_benefit.pk)
+
+ # Any remaining sponsor benefits that don't normally belong to
+ # this level are set to inactive
+ self.sponsor_benefits.exclude(pk__in=allowed_benefits).update(active=False, max_words=None, other_limits="")
+
+ # @@@ should this just be done centrally?
+ def send_coordinator_emails(self):
+ for user in User.objects.filter(groups__name=SPONSOR_COORDINATORS):
+ send_email(
+ [user.email], "sponsor_signup",
+ context = {"sponsor": self}
+ )
+
+
+def _store_initial_level(sender, instance, **kwargs):
+ if instance:
+ instance._initial_level_id = instance.level_id
+post_init.connect(_store_initial_level, sender=Sponsor)
+
+
+def _check_level_change(sender, instance, created, **kwargs):
+ if instance and (created or instance.level_id != instance._initial_level_id):
+ instance.reset_benefits()
+post_save.connect(_check_level_change, sender=Sponsor)
+
+
+def _send_sponsor_notification_emails(sender, instance, created, **kwargs):
+ if instance and created:
+ instance.send_coordinator_emails()
+post_save.connect(_send_sponsor_notification_emails, sender=Sponsor)
+
+
+class Benefit(models.Model):
+
+ name = models.CharField(_("name"), max_length=100)
+ description = models.TextField(_("description"), blank=True)
+ type = models.CharField(
+ _("type"),
+ choices=[
+ ("text", "Text"),
+ ("file", "File"),
+ ("weblogo", "Web Logo"),
+ ("simple", "Simple")
+ ],
+ max_length=10,
+ default="simple"
+ )
+
+ def __unicode__(self):
+ return self.name
+
+
+class BenefitLevel(models.Model):
+
+ benefit = models.ForeignKey(
+ Benefit,
+ related_name="benefit_levels",
+ verbose_name=_("benefit")
+ )
+ level = models.ForeignKey(
+ SponsorLevel,
+ related_name="benefit_levels",
+ verbose_name=_("level")
+ )
+ max_words = models.PositiveIntegerField(_("max words"), blank=True, null=True)
+ other_limits = models.CharField(_("other limits"), max_length=200, blank=True)
+
+ class Meta:
+ ordering = ["level"]
+
+ def __unicode__(self):
+ return u"%s - %s" % (self.level, self.benefit)
+
+
+class SponsorBenefit(models.Model):
+
+ sponsor = models.ForeignKey(
+ Sponsor,
+ related_name="sponsor_benefits",
+ verbose_name=_("sponsor")
+ )
+ benefit = models.ForeignKey(Benefit,
+ related_name="sponsor_benefits",
+ verbose_name=_("benefit")
+ )
+ active = models.BooleanField(default=True)
+
+ # Limits: will initially be set to defaults from corresponding BenefitLevel
+ max_words = models.PositiveIntegerField(_("max words"), blank=True, null=True)
+ other_limits = models.CharField(_("other limits"), max_length=200, blank=True)
+
+ # Data: zero or one of these fields will be used, depending on the
+ # type of the Benefit (text, file, or simple)
+ text = models.TextField(_("text"), blank=True)
+ upload = models.FileField(_("file"), blank=True, upload_to="sponsor_files")
+
+ class Meta:
+ ordering = ['-active']
+
+ def __unicode__(self):
+ return u"%s - %s" % (self.sponsor, self.benefit)
+
+ def clean(self):
+ if self.max_words and len(self.text.split()) > self.max_words:
+ raise ValidationError("Sponsorship level only allows for %s words." % self.max_words)
+
+ def data_fields(self):
+ """
+ Return list of data field names which should be editable for
+ this ``SponsorBenefit``, depending on its ``Benefit`` type.
+ """
+ if self.benefit.type == "file" or self.benefit.type == "weblogo":
+ return ["upload"]
+ elif self.benefit.type == "text":
+ return ["text"]
+ return []
+
+
+def _denorm_weblogo(sender, instance, created, **kwargs):
+ if instance:
+ if instance.benefit.type == "weblogo" and instance.upload:
+ sponsor = instance.sponsor
+ sponsor.sponsor_logo = instance
+ sponsor.save()
+post_save.connect(_denorm_weblogo, sender=SponsorBenefit)
\ No newline at end of file
diff --git a/pycon/sponsorship/templatetags/__init__.py b/pycon/sponsorship/templatetags/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pycon/sponsorship/templatetags/sponsorship_tags.py b/pycon/sponsorship/templatetags/sponsorship_tags.py
new file mode 100644
index 00000000..9a4ba982
--- /dev/null
+++ b/pycon/sponsorship/templatetags/sponsorship_tags.py
@@ -0,0 +1,74 @@
+from django import template
+
+from symposion.conference.models import current_conference
+from pycon.sponsorship.models import Sponsor, SponsorLevel
+
+
+register = template.Library()
+
+
+class SponsorsNode(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])
+ elif len(bits) == 4 and bits[2] == "as":
+ return cls(bits[3], bits[1])
+ else:
+ raise template.TemplateSyntaxError("%r takes 'as var' or 'level as var'" % bits[0])
+
+ def __init__(self, context_var, level=None):
+ if level:
+ self.level = template.Variable(level)
+ else:
+ self.level = None
+ self.context_var = context_var
+
+ def render(self, context):
+ conference = current_conference()
+ if self.level:
+ level = self.level.resolve(context)
+ queryset = Sponsor.objects.filter(level__conference = conference, level__name__iexact = level, active = True).order_by("added")
+ else:
+ queryset = Sponsor.objects.filter(level__conference = conference, active = True).order_by("level__order", "added")
+ context[self.context_var] = queryset
+ return u""
+
+
+class SponsorLevelNode(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):
+ conference = current_conference()
+ context[self.context_var] = SponsorLevel.objects.filter(conference=conference)
+ return u""
+
+
+@register.tag
+def sponsors(parser, token):
+ """
+ {% sponsors as all_sponsors %}
+ or
+ {% sponsors "gold" as gold_sponsors %}
+ """
+ return SponsorsNode.handle_token(parser, token)
+
+
+@register.tag
+def sponsor_levels(parser, token):
+ """
+ {% sponsor_levels as levels %}
+ """
+ return SponsorLevelNode.handle_token(parser, token)
diff --git a/pycon/sponsorship/urls.py b/pycon/sponsorship/urls.py
new file mode 100644
index 00000000..7776d200
--- /dev/null
+++ b/pycon/sponsorship/urls.py
@@ -0,0 +1,10 @@
+from django.conf.urls.defaults import patterns, url
+from django.views.generic.simple import direct_to_template
+
+
+urlpatterns = patterns("pycon.sponsorship.views",
+ url(r"^$", direct_to_template, {"template": "sponsorship/list.html"}, name="sponsor_list"),
+ # url(r"^jobs/$", direct_to_template, {"template": "sponsors/jobs.html"}, name="sponsor_jobs"),
+ url(r"^apply/$", "sponsor_apply", name="sponsor_apply"),
+ url(r"^(?P\d+)/$", "sponsor_detail", name="sponsor_detail"),
+)
diff --git a/pycon/sponsorship/views.py b/pycon/sponsorship/views.py
new file mode 100644
index 00000000..12896b22
--- /dev/null
+++ b/pycon/sponsorship/views.py
@@ -0,0 +1,115 @@
+import itertools
+
+from functools import wraps
+
+from django.http import HttpResponse
+from django.shortcuts import render_to_response, redirect, get_object_or_404
+from django.template import RequestContext
+
+from django.contrib import messages
+from django.contrib.admin.views.decorators import staff_member_required
+from django.contrib.auth.decorators import login_required
+
+from pycon.sponsorship.forms import SponsorApplicationForm, SponsorDetailsForm, SponsorBenefitsFormSet
+from pycon.sponsorship.models import Sponsor, SponsorBenefit
+
+
+@login_required
+def sponsor_apply(request):
+ if request.method == "POST":
+ form = SponsorApplicationForm(request.POST, user=request.user)
+ if form.is_valid():
+ form.save()
+ return redirect("dashboard")
+ else:
+ form = SponsorApplicationForm(user=request.user)
+
+ return render_to_response("sponsorship/apply.html", {
+ "form": form,
+ }, context_instance=RequestContext(request))
+
+
+@login_required
+def sponsor_detail(request, pk):
+ sponsor = get_object_or_404(Sponsor, pk=pk)
+
+ if not sponsor.active or sponsor.applicant != request.user:
+ return redirect("sponsor_list")
+
+ formset_kwargs = {
+ "instance": sponsor,
+ "queryset": SponsorBenefit.objects.filter(active=True)
+ }
+
+ if request.method == "POST":
+
+ form = SponsorDetailsForm(request.POST, instance=sponsor)
+ formset = SponsorBenefitsFormSet(request.POST, request.FILES, **formset_kwargs)
+
+ if form.is_valid() and formset.is_valid():
+ form.save()
+ formset.save()
+
+ messages.success(request, "Your sponsorship application has been submitted!")
+
+ return redirect(request.path)
+ else:
+ form = SponsorDetailsForm(instance=sponsor)
+ formset = SponsorBenefitsFormSet(**formset_kwargs)
+
+ return render_to_response("sponsorship/detail.html", {
+ "sponsor": sponsor,
+ "form": form,
+ "formset": formset,
+ }, context_instance=RequestContext(request))
+
+
+@staff_member_required
+def sponsor_export_data(request):
+ sponsors = []
+ data = ""
+
+ for sponsor in Sponsor.objects.order_by("added"):
+ d = {
+ "name": sponsor.name,
+ "url": sponsor.external_url,
+ "level": (sponsor.level.order, sponsor.level.name),
+ "description": "",
+ }
+ for sponsor_benefit in sponsor.sponsor_benefits.all():
+ if sponsor_benefit.benefit_id == 2:
+ d["description"] = sponsor_benefit.text
+ sponsors.append(d)
+
+ def izip_longest(*args):
+ fv = None
+ def sentinel(counter=([fv]*(len(args)-1)).pop):
+ yield counter()
+ iters = [itertools.chain(it, sentinel(), itertools.repeat(fv)) for it in args]
+ try:
+ for tup in itertools.izip(*iters):
+ yield tup
+ except IndexError:
+ pass
+ def pairwise(iterable):
+ a, b = itertools.tee(iterable)
+ b.next()
+ return izip_longest(a, b)
+
+ def level_key(s):
+ return s["level"]
+
+ for level, level_sponsors in itertools.groupby(sorted(sponsors, key=level_key), level_key):
+ data += "%s\n" % ("-" * (len(level[1])+4))
+ data += "| %s |\n" % level[1]
+ data += "%s\n\n" % ("-" * (len(level[1])+4))
+ for sponsor, next in pairwise(level_sponsors):
+ description = sponsor["description"].strip()
+ description = description if description else "-- NO DESCRIPTION FOR THIS SPONSOR --"
+ data += "%s\n\n%s" % (sponsor["name"], description)
+ if next is not None:
+ data += "\n\n%s\n\n" % ("-"*80)
+ else:
+ data += "\n\n"
+
+ return HttpResponse(data, content_type="text/plain;charset=utf-8")