diff --git a/vendor/symposion/LICENSE b/vendor/symposion/LICENSE new file mode 100644 index 00000000..d352bdf0 --- /dev/null +++ b/vendor/symposion/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2010-2014, Eldarion, Inc. and contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of Eldarion, Inc. nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/symposion/__init__.py b/vendor/symposion/__init__.py new file mode 100644 index 00000000..72612951 --- /dev/null +++ b/vendor/symposion/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0b2.dev3" diff --git a/vendor/symposion/conf.py b/vendor/symposion/conf.py new file mode 100644 index 00000000..b2b729f7 --- /dev/null +++ b/vendor/symposion/conf.py @@ -0,0 +1,8 @@ +from django.conf import settings # noqa + +from appconf import AppConf + + +class SymposionAppConf(AppConf): + + VOTE_THRESHOLD = 3 diff --git a/vendor/symposion/conference/__init__.py b/vendor/symposion/conference/__init__.py new file mode 100644 index 00000000..1f126f31 --- /dev/null +++ b/vendor/symposion/conference/__init__.py @@ -0,0 +1 @@ +default_app_config = "symposion.conference.apps.ConferenceConfig" diff --git a/vendor/symposion/conference/admin.py b/vendor/symposion/conference/admin.py new file mode 100644 index 00000000..f7145303 --- /dev/null +++ b/vendor/symposion/conference/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from symposion.conference.models import Conference, Section + + +class SectionInline(admin.TabularInline): + model = Section + prepopulated_fields = {"slug": ("name",)} + extra = 1 + + +class ConferenceAdmin(admin.ModelAdmin): + list_display = ("title", "start_date", "end_date") + inlines = [SectionInline, ] + + +admin.site.register(Conference, ConferenceAdmin) +admin.site.register( + Section, + prepopulated_fields={"slug": ("name",)}, + list_display=("name", "conference", "start_date", "end_date") +) diff --git a/vendor/symposion/conference/apps.py b/vendor/symposion/conference/apps.py new file mode 100644 index 00000000..7a330359 --- /dev/null +++ b/vendor/symposion/conference/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class ConferenceConfig(AppConfig): + name = "symposion.conference" + label = "symposion_conference" + verbose_name = _("Symposion Conference") diff --git a/vendor/symposion/conference/migrations/0001_initial.py b/vendor/symposion/conference/migrations/0001_initial.py new file mode 100644 index 00000000..c9922906 --- /dev/null +++ b/vendor/symposion/conference/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-09-17 03:34 +from django.db import migrations, models +import django.db.models.deletion +import timezone_field.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Conference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='Title')), + ('start_date', models.DateField(blank=True, null=True, verbose_name='Start date')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='End date')), + ('timezone', timezone_field.fields.TimeZoneField(blank=True, verbose_name='timezone')), + ], + options={ + 'verbose_name': 'conference', + 'verbose_name_plural': 'conferences', + }, + ), + migrations.CreateModel( + name='Section', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ('slug', models.SlugField(verbose_name='Slug')), + ('start_date', models.DateField(blank=True, null=True, verbose_name='Start date')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='End date')), + ('conference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_conference.Conference', verbose_name='Conference')), + ], + options={ + 'ordering': ['start_date'], + 'verbose_name': 'section', + 'verbose_name_plural': 'sections', + }, + ), + ] diff --git a/vendor/symposion/conference/migrations/__init__.py b/vendor/symposion/conference/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/conference/models.py b/vendor/symposion/conference/models.py new file mode 100644 index 00000000..14e5ad3f --- /dev/null +++ b/vendor/symposion/conference/models.py @@ -0,0 +1,82 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from timezone_field import TimeZoneField + + +CONFERENCE_CACHE = {} + + +class Conference(models.Model): + """ + the full conference for a specific year, e.g. US PyCon 2012. + """ + + title = models.CharField(_("Title"), max_length=100) + + # when the conference runs + start_date = models.DateField(_("Start date"), null=True, blank=True) + end_date = models.DateField(_("End date"), null=True, blank=True) + + # timezone the conference is in + timezone = TimeZoneField(blank=True, verbose_name=_("timezone")) + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + super(Conference, self).save(*args, **kwargs) + if self.id in CONFERENCE_CACHE: + del CONFERENCE_CACHE[self.id] + + def delete(self): + pk = self.pk + super(Conference, self).delete() + try: + del CONFERENCE_CACHE[pk] + except KeyError: + pass + + class Meta(object): + verbose_name = _("conference") + verbose_name_plural = _("conferences") + + +class Section(models.Model): + """ + a section of the conference such as "Tutorials", "Workshops", + "Talks", "Expo", "Sprints", that may have its own review and + scheduling process. + """ + + conference = models.ForeignKey(Conference, verbose_name=_("Conference")) + + name = models.CharField(_("Name"), max_length=100) + slug = models.SlugField(verbose_name=_("Slug")) + + # when the section runs + start_date = models.DateField(_("Start date"), null=True, blank=True) + end_date = models.DateField(_("End date"), null=True, blank=True) + + def __str__(self): + return "%s %s" % (self.conference, self.name) + + class Meta(object): + verbose_name = _("section") + verbose_name_plural = _("sections") + ordering = ["start_date"] + + +def current_conference(): + from django.conf import settings + try: + conf_id = settings.CONFERENCE_ID + except AttributeError: + from django.core.exceptions import ImproperlyConfigured + raise ImproperlyConfigured("You must set the CONFERENCE_ID setting.") + try: + current_conf = CONFERENCE_CACHE[conf_id] + except KeyError: + current_conf = Conference.objects.get(pk=conf_id) + CONFERENCE_CACHE[conf_id] = current_conf + return current_conf diff --git a/vendor/symposion/conference/urls.py b/vendor/symposion/conference/urls.py new file mode 100644 index 00000000..b9080681 --- /dev/null +++ b/vendor/symposion/conference/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls import url + +from .views import user_list + +urlpatterns = [ + url(r"^users/$", user_list, name="user_list"), +] diff --git a/vendor/symposion/conference/views.py b/vendor/symposion/conference/views.py new file mode 100644 index 00000000..33e3b097 --- /dev/null +++ b/vendor/symposion/conference/views.py @@ -0,0 +1,16 @@ +from django.http import Http404 +from django.shortcuts import render + +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User + + +@login_required +def user_list(request): + + if not request.user.is_staff: + raise Http404() + + return render(request, "symposion/conference/user_list.html", { + "users": User.objects.all(), + }) diff --git a/vendor/symposion/constants.py b/vendor/symposion/constants.py new file mode 100644 index 00000000..f49455c5 --- /dev/null +++ b/vendor/symposion/constants.py @@ -0,0 +1,5 @@ +TEXT_FIELD_MONOSPACE_NOTE=( + "This field is rendered with the monospace font " + "Hack with " + "whitespace preserved") + diff --git a/vendor/symposion/locale/en/LC_MESSAGES/django.mo b/vendor/symposion/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 00000000..9cdd2446 Binary files /dev/null and b/vendor/symposion/locale/en/LC_MESSAGES/django.mo differ diff --git a/vendor/symposion/locale/en/LC_MESSAGES/django.po b/vendor/symposion/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..0f07ab78 --- /dev/null +++ b/vendor/symposion/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,537 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-07-31 14:47-0600\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: cms/models.py:23 +msgid "Draft" +msgstr "" + +#: cms/models.py:24 +msgid "Public" +msgstr "" + +#: cms/models.py:57 +msgid "Path can only contain letters, numbers and hyphens and end with /" +msgstr "" + +#: conference/models.py:15 +msgid "title" +msgstr "" + +#: conference/models.py:18 conference/models.py:58 +msgid "start date" +msgstr "" + +#: conference/models.py:19 conference/models.py:59 +msgid "end date" +msgstr "" + +#: conference/models.py:22 +msgid "timezone" +msgstr "" + +#: conference/models.py:41 conference/models.py:52 sponsorship/models.py:18 +msgid "conference" +msgstr "" + +#: conference/models.py:42 +msgid "conferences" +msgstr "" + +#: conference/models.py:54 sponsorship/models.py:19 sponsorship/models.py:155 +msgid "name" +msgstr "" + +#: conference/models.py:65 +msgid "section" +msgstr "" + +#: conference/models.py:66 +msgid "sections" +msgstr "" + +#: proposals/models.py:71 templates/conference/user_list.html:60 +msgid "Name" +msgstr "" + +#: proposals/models.py:86 +msgid "Brief Description" +msgstr "" + +#: proposals/models.py:88 +msgid "" +"If your proposal is accepted this will be made public and printed in the " +"program. Should be one paragraph, maximum 400 characters." +msgstr "" + +#: proposals/models.py:92 +msgid "Detailed Abstract" +msgstr "" + +#: proposals/models.py:93 +msgid "" +"Detailed outline. Will be made public if your proposal is accepted. Edit " +"using Markdown." +msgstr "" + +#: proposals/models.py:99 +msgid "" +"Anything else you'd like the program committee to know when making their " +"selection: your past experience, etc. This is not made public. Edit using Markdown." +msgstr "" + +#: proposals/models.py:153 +msgid "Pending" +msgstr "" + +#: proposals/models.py:154 templates/proposals/_pending_proposal_row.html:16 +msgid "Accepted" +msgstr "" + +#: proposals/models.py:155 +msgid "Declined" +msgstr "" + +#: sponsorship/models.py:20 +msgid "order" +msgstr "" + +#: sponsorship/models.py:21 +msgid "cost" +msgstr "" + +#: sponsorship/models.py:22 sponsorship/models.py:156 +msgid "description" +msgstr "" + +#: sponsorship/models.py:22 +msgid "This is private." +msgstr "" + +#: sponsorship/models.py:26 +msgid "sponsor level" +msgstr "" + +#: sponsorship/models.py:27 +msgid "sponsor levels" +msgstr "" + +#: sponsorship/models.py:38 +msgid "applicant" +msgstr "" + +#: sponsorship/models.py:41 +msgid "Sponsor Name" +msgstr "" + +#: sponsorship/models.py:42 +msgid "external URL" +msgstr "" + +#: sponsorship/models.py:43 +msgid "annotation" +msgstr "" + +#: sponsorship/models.py:44 +msgid "Contact Name" +msgstr "" + +#: sponsorship/models.py:45 +msgid "Contact Email" +msgstr "" + +#: sponsorship/models.py:46 sponsorship/models.py:167 +msgid "level" +msgstr "" + +#: sponsorship/models.py:47 +msgid "added" +msgstr "" + +#: sponsorship/models.py:48 +msgid "active" +msgstr "" + +#: sponsorship/models.py:60 sponsorship/models.py:182 +msgid "sponsor" +msgstr "" + +#: sponsorship/models.py:61 +msgid "sponsors" +msgstr "" + +#: sponsorship/models.py:157 +msgid "type" +msgstr "" + +#: sponsorship/models.py:166 sponsorship/models.py:183 +msgid "benefit" +msgstr "" + +#: sponsorship/models.py:170 sponsorship/models.py:187 +msgid "max words" +msgstr "" + +#: sponsorship/models.py:171 sponsorship/models.py:188 +msgid "other limits" +msgstr "" + +#: sponsorship/models.py:192 +msgid "text" +msgstr "" + +#: sponsorship/models.py:193 +msgid "file" +msgstr "" + +#: templates/dashboard.html:16 +msgid "Speaking" +msgstr "" + +#: templates/dashboard.html:92 templates/sponsorship/detail.html:8 +msgid "Sponsorship" +msgstr "" + +#: templates/dashboard.html:132 templates/reviews/review_detail.html:75 +msgid "Reviews" +msgstr "" + +#: templates/dashboard.html:177 +msgid "Teams" +msgstr "" + +#: templates/boxes/box.html:9 +msgid "Editing content:" +msgstr "" + +#: templates/cms/page_edit.html:11 +msgid "Edit page at:" +msgstr "" + +#: templates/conference/user_list.html:59 +msgid "Email" +msgstr "" + +#: templates/conference/user_list.html:61 +msgid "Speaker Profile?" +msgstr "" + +#: templates/emails/teams_user_applied/message.html:3 +#, python-format +msgid "" +"\n" +"

\n" +" User \"%(username)s\" has applied to join %(team_name)s on " +"%(site_name)s.\n" +"

\n" +"\n" +"

\n" +" To accept this application and see any other pending applications, " +"visit the following url:\n" +" http://%(site_url)s" +"%(team_url)s\n" +"

\n" +msgstr "" + +#: templates/emails/teams_user_applied/subject.txt:1 +#, python-format +msgid "%(username)s has applied to to join \"%(team)s\"" +msgstr "" + +#: templates/emails/teams_user_invited/message.html:3 +#, python-format +msgid "" +"\n" +"

\n" +" You have been invited to join %(team_name)s on " +"%(site_name)s.\n" +"

\n" +"\n" +"

\n" +" To accept this invitation, visit the following url:\n" +" http://%(site_url)s" +"%(team_url)s\n" +"

\n" +msgstr "" + +#: templates/emails/teams_user_invited/subject.txt:1 +#, python-format +msgid "You have been invited to join \"%(team)s\"" +msgstr "" + +#: templates/proposals/_pending_proposal_row.html:12 +msgid "Cancelled" +msgstr "" + +#: templates/proposals/_pending_proposal_row.html:18 +msgid "Submitted" +msgstr "" + +#: templates/proposals/_pending_proposal_row.html:21 +msgid "Invited" +msgstr "" + +#: templates/proposals/_pending_proposal_row.html:30 +msgid "Choose Response" +msgstr "" + +#: templates/proposals/_pending_proposal_row.html:35 +msgid "Accept invitation" +msgstr "" + +#: templates/proposals/_pending_proposal_row.html:37 +msgid "Decline invitation" +msgstr "" + +#: templates/proposals/_proposal_fields.html:4 +msgid "Submitted by" +msgstr "" + +#: templates/proposals/_proposal_fields.html:7 +msgid "Track" +msgstr "" + +#: templates/proposals/_proposal_fields.html:10 +msgid "Audience Level" +msgstr "" + +#: templates/proposals/_proposal_fields.html:14 +msgid "Additional Speakers" +msgstr "" + +#: templates/proposals/_proposal_fields.html:21 +msgid "Invitation Sent" +msgstr "" + +#: templates/proposals/_proposal_fields.html:28 +msgid "Description" +msgstr "" + +#: templates/proposals/_proposal_fields.html:31 +msgid "Abstract" +msgstr "" + +#: templates/proposals/_proposal_fields.html:34 +msgid "Notes" +msgstr "" + +#: templates/proposals/_proposal_fields.html:37 +msgid "Speaker Bio" +msgstr "" + +#: templates/proposals/_proposal_fields.html:40 +msgid "Documents" +msgstr "" + +#: templates/proposals/proposal_cancel.html:7 +msgid "Cancel Proposal" +msgstr "" + +#: templates/proposals/proposal_cancel.html:16 +msgid "No, keep it for now" +msgstr "" + +#: templates/proposals/proposal_detail.html:14 +msgid "Edit this proposal" +msgstr "" + +#: templates/proposals/proposal_detail.html:17 +msgid "Cancel this proposal" +msgstr "" + +#: templates/proposals/proposal_detail.html:21 +msgid "Remove me from this proposal" +msgstr "" + +#: templates/proposals/proposal_detail.html:33 +#: templates/reviews/review_detail.html:74 +msgid "Proposal Details" +msgstr "" + +#: templates/proposals/proposal_detail.html:35 +#: templates/proposals/proposal_detail.html:47 +msgid "Supporting Documents" +msgstr "" + +#: templates/proposals/proposal_detail.html:38 +msgid "Reviewer Feedback" +msgstr "" + +#: templates/proposals/proposal_detail.html:57 +msgid "delete" +msgstr "" + +#: templates/proposals/proposal_detail.html:64 +msgid "No supporting documents attached to this proposal." +msgstr "" + +#: templates/proposals/proposal_detail.html:66 +msgid "Add Document" +msgstr "" + +#: templates/proposals/proposal_detail.html:73 +msgid "Conversation with Reviewers" +msgstr "" + +#: templates/proposals/proposal_detail.html:83 +msgid "Leave a Message" +msgstr "" + +#: templates/proposals/proposal_detail.html:85 +msgid "You can leave a message for the reviewers here." +msgstr "" + +#: templates/proposals/proposal_detail.html:94 +msgid "Submit" +msgstr "" + +#: templates/proposals/proposal_speaker_manage.html:7 +msgid "Proposal:" +msgstr "" + +#: templates/proposals/proposal_speaker_manage.html:10 +msgid "Edit proposal" +msgstr "" + +#: templates/proposals/proposal_speaker_manage.html:14 +msgid "Current Speakers" +msgstr "" + +#: templates/proposals/proposal_speaker_manage.html:20 +msgid "pending invitation" +msgstr "" + +#: templates/proposals/proposal_speaker_manage.html:24 +msgid "Add another speaker" +msgstr "" + +#: templates/proposals/proposal_submit.html:6 +msgid "Submit A Proposal" +msgstr "" + +#: templates/reviews/_review_table.html:6 +#: templates/reviews/result_notification.html:45 +msgid "Speaker / Title" +msgstr "" + +#: templates/reviews/_review_table.html:7 +#: templates/reviews/result_notification.html:46 +msgid "Category" +msgstr "" + +#: templates/reviews/_review_table.html:9 +msgid "+1" +msgstr "" + +#: templates/reviews/_review_table.html:10 +msgid "+0" +msgstr "" + +#: templates/reviews/_review_table.html:11 +msgid "-0" +msgstr "" + +#: templates/reviews/_review_table.html:12 +msgid "-1" +msgstr "" + +#: templates/reviews/_review_table.html:13 +msgid "Your Rating" +msgstr "" + +#: templates/reviews/base.html:64 +msgid "All Reviews" +msgstr "" + +#: templates/reviews/base.html:77 +msgid "Voting Status" +msgstr "" + +#: templates/reviews/result_notification.html:47 +msgid "Status" +msgstr "" + +#: templates/reviews/result_notification.html:48 +msgid "Notified?" +msgstr "" + +#: templates/reviews/review_detail.html:76 +msgid "Speaker Feedback" +msgstr "" + +#: templates/reviews/review_detail.html:84 +msgid "Current Results" +msgstr "" + +#: templates/reviews/review_detail.html:91 +msgid "Total Responses" +msgstr "" + +#: templates/reviews/review_detail.html:108 +msgid "Submit Review" +msgstr "" + +#: templates/reviews/review_detail.html:148 +msgid "Conversation with the submitter" +msgstr "" + +#: templates/reviews/review_detail.html:162 +msgid "Send a message" +msgstr "" + +#: templates/reviews/review_detail.html:164 +msgid "" +"\n" +" If you'd like to communicate with the submitter, " +"use the following form and he or she will be\n" +" notified and given the opportunity to respond.\n" +" " +msgstr "" + +#: templates/schedule/_slot_edit.html:5 +msgid "Edit Slot" +msgstr "" + +#: templates/speakers/speaker_create.html:7 +#: templates/speakers/speaker_create.html:14 +msgid "Create Speaker Profile" +msgstr "" + +#: templates/speakers/speaker_edit.html:7 +#: templates/speakers/speaker_edit.html:14 +msgid "Edit Speaker Profile" +msgstr "" + +#: templates/sponsorship/add.html:7 templates/sponsorship/add.html.py:14 +msgid "Add a Sponsor" +msgstr "" + +#: templates/sponsorship/apply.html:7 +msgid "Apply to be a Sponsor" +msgstr "" + +#: templates/sponsorship/apply.html:17 +msgid "Apply to Be a Sponsor" +msgstr "" + +#: templates/sponsorship/list.html:7 templates/sponsorship/list.html.py:14 +msgid "About Our Sponsors" +msgstr "" diff --git a/vendor/symposion/locale/ja/LC_MESSAGES/django.mo b/vendor/symposion/locale/ja/LC_MESSAGES/django.mo new file mode 100644 index 00000000..d3c829d1 Binary files /dev/null and b/vendor/symposion/locale/ja/LC_MESSAGES/django.mo differ diff --git a/vendor/symposion/locale/ja/LC_MESSAGES/django.po b/vendor/symposion/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 00000000..ae9c6ea7 --- /dev/null +++ b/vendor/symposion/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,573 @@ +# Symposion japanese translation. +# Copyright (C) 2014-2015, pinax team +# This file is distributed under the same license as the symposion package. +# Hiroshi Miura , 2015. +# +msgid "" +msgstr "" +"Project-Id-Version: symposion\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-07-31 14:47-0600\n" +"PO-Revision-Date: 2015-06-18 10:04+0900\n" +"Last-Translator: Hiroshi Miura \n" +"Language-Team: Japanese translation team \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 1.5.4\n" +"Language: ja\n" + +#: cms/models.py:23 +msgid "Draft" +msgstr "草稿" + +#: cms/models.py:24 +msgid "Public" +msgstr "公開" + +#: cms/models.py:57 +msgid "Path can only contain letters, numbers and hyphens and end with /" +msgstr "" +"パス名には、英数字、ハイフン(-)のみが使えます。また末尾は'/'の必要がありま" +"す。" + +#: conference/models.py:15 +msgid "title" +msgstr "タイトル" + +#: conference/models.py:18 conference/models.py:58 +msgid "start date" +msgstr "開始日" + +#: conference/models.py:19 conference/models.py:59 +msgid "end date" +msgstr "終了日" + +#: conference/models.py:22 +msgid "timezone" +msgstr "タイムゾーン" + +#: conference/models.py:41 conference/models.py:52 sponsorship/models.py:18 +msgid "conference" +msgstr "カンファレンス" + +#: conference/models.py:42 +msgid "conferences" +msgstr "カンファレンス" + +#: conference/models.py:54 sponsorship/models.py:19 sponsorship/models.py:155 +msgid "name" +msgstr "名前" + +#: conference/models.py:65 +msgid "section" +msgstr "セクション" + +#: conference/models.py:66 +msgid "sections" +msgstr "セクション" + +#: proposals/models.py:71 templates/conference/user_list.html:60 +msgid "Name" +msgstr "名前" + +#: proposals/models.py:86 +msgid "Brief Description" +msgstr "概要" + +#: proposals/models.py:88 +msgid "" +"If your proposal is accepted this will be made public and printed in the " +"program. Should be one paragraph, maximum 400 characters." +msgstr "" +"もし提案が受諾されたら、これは公開され、配布されるプログラムに印刷されます。" +"段落は一つのみで、400文字以内で記載してください。" + +#: proposals/models.py:92 +msgid "Detailed Abstract" +msgstr "提案の詳細" + +#: proposals/models.py:93 +msgid "" +"Detailed outline. Will be made public if your proposal is accepted. Edit " +"using Markdown." +msgstr "" +"講演詳細:提案が受諾された場合に公開されます。Markdownを用いて記述してください。" + +#: proposals/models.py:99 +msgid "" +"Anything else you'd like the program committee to know when making their " +"selection: your past experience, etc. This is not made public. Edit using Markdown." +msgstr "" +"プログラム委員に、講演者の過去の経験など、とくに伝えたいことがあれば、記述し" +"てください。これは公開されることはありません。Markdownを用いて記述してください。" + +#: proposals/models.py:153 +msgid "Pending" +msgstr "ペンディング" + +#: proposals/models.py:154 templates/proposals/_pending_proposal_row.html:16 +msgid "Accepted" +msgstr "アクセプト" + +#: proposals/models.py:155 +msgid "Declined" +msgstr "リジェクト" + +#: sponsorship/models.py:20 +msgid "order" +msgstr "順序" + +#: sponsorship/models.py:21 +msgid "cost" +msgstr "経費" + +#: sponsorship/models.py:22 sponsorship/models.py:156 +msgid "description" +msgstr "記述" + +#: sponsorship/models.py:22 +msgid "This is private." +msgstr "これは非公開です。" + +#: sponsorship/models.py:26 +msgid "sponsor level" +msgstr "スポンサーのグレード" + +#: sponsorship/models.py:27 +msgid "sponsor levels" +msgstr "スポンサーのグレード" + +#: sponsorship/models.py:38 +msgid "applicant" +msgstr "応募者" + +#: sponsorship/models.py:41 +msgid "Sponsor Name" +msgstr "スポンサー名" + +#: sponsorship/models.py:42 +msgid "external URL" +msgstr "外部URL" + +#: sponsorship/models.py:43 +msgid "annotation" +msgstr "" + +#: sponsorship/models.py:44 +msgid "Contact Name" +msgstr "連絡先の名前" + +#: sponsorship/models.py:45 +msgid "Contact Email" +msgstr "連絡先のEmail" + +#: sponsorship/models.py:46 sponsorship/models.py:167 +msgid "level" +msgstr "グレード" + +#: sponsorship/models.py:47 +msgid "added" +msgstr "" + +#: sponsorship/models.py:48 +msgid "active" +msgstr "有効" + +#: sponsorship/models.py:60 sponsorship/models.py:182 +msgid "sponsor" +msgstr "スポンサー" + +#: sponsorship/models.py:61 +msgid "sponsors" +msgstr "スポンサー" + +#: sponsorship/models.py:157 +msgid "type" +msgstr "タイプ" + +#: sponsorship/models.py:166 sponsorship/models.py:183 +msgid "benefit" +msgstr "" + +#: sponsorship/models.py:170 sponsorship/models.py:187 +msgid "max words" +msgstr "" + +#: sponsorship/models.py:171 sponsorship/models.py:188 +msgid "other limits" +msgstr "他の制限" + +#: sponsorship/models.py:192 +msgid "text" +msgstr "テキスト" + +#: sponsorship/models.py:193 +msgid "file" +msgstr "ファイル" + +#: templates/dashboard.html:16 +msgid "Speaking" +msgstr "講演" + +#: templates/dashboard.html:92 templates/sponsorship/detail.html:8 +msgid "Sponsorship" +msgstr "スポンサー" + +#: templates/dashboard.html:132 templates/reviews/review_detail.html:75 +msgid "Reviews" +msgstr "レビュー" + +#: templates/dashboard.html:177 +msgid "Teams" +msgstr "チーム" + +#: templates/boxes/box.html:9 +msgid "Editing content:" +msgstr "コンテンツ編集:" + +#: templates/cms/page_edit.html:11 +msgid "Edit page at:" +msgstr "" + +#: templates/conference/user_list.html:59 +msgid "Email" +msgstr "電子メール" + +#: templates/conference/user_list.html:61 +msgid "Speaker Profile?" +msgstr "講演者のプロフィール" + +#: templates/emails/teams_user_applied/message.html:3 +#, python-format +msgid "" +"\n" +"

\n" +" User \"%(username)s\" has applied to join %(team_name)s on " +"%(site_name)s.\n" +"

\n" +"\n" +"

\n" +" To accept this application and see any other pending applications, " +"visit the following url:\n" +" http://%(site_url)s" +"%(team_url)s\n" +"

\n" +msgstr "" +"\n" +"

\n" +" ユーザ \"%(username)s\" は、%(site_name)s の%(team_name)s に" +"応募しました。 \n" +"

\n" +"\n" +"

\n" +" この応募を受諾したり、他の保留されている応募者を確認するには、つぎの" +"URLを参照してください:\n" +" http://%(site_url)s" +"%(team_url)s\n" +"

\n" + +#: templates/emails/teams_user_applied/subject.txt:1 +#, python-format +msgid "%(username)s has applied to to join \"%(team)s\"" +msgstr "%(username)s は、 \"%(team)s\"に応募しました。" + +#: templates/emails/teams_user_invited/message.html:3 +#, python-format +msgid "" +"\n" +"

\n" +" You have been invited to join %(team_name)s on " +"%(site_name)s.\n" +"

\n" +"\n" +"

\n" +" To accept this invitation, visit the following url:\n" +" http://%(site_url)s" +"%(team_url)s\n" +"

\n" +msgstr "" +"\n" +"

\n" +" %(site_name)sの%(team_name)sに参加するよう招待されました。\n" +"

\n" +"\n" +"

\n" +" 招待を受諾するには、つぎのURLをクリックしてください:\n" +" http://%(site_url)s" +"%(team_url)s\n" +"

\n" + +#: templates/emails/teams_user_invited/subject.txt:1 +#, python-format +msgid "You have been invited to join \"%(team)s\"" +msgstr " \"%(team)s\"に参加するよう招待されました。" + +#: templates/proposals/_pending_proposal_row.html:12 +msgid "Cancelled" +msgstr "キャンセル済み" + +#: templates/proposals/_pending_proposal_row.html:18 +msgid "Submitted" +msgstr "投稿済み" + +#: templates/proposals/_pending_proposal_row.html:21 +msgid "Invited" +msgstr "招待された" + +#: templates/proposals/_pending_proposal_row.html:30 +msgid "Choose Response" +msgstr "応答の選択" + +#: templates/proposals/_pending_proposal_row.html:35 +msgid "Accept invitation" +msgstr "招待を受諾" + +#: templates/proposals/_pending_proposal_row.html:37 +msgid "Decline invitation" +msgstr "招待を拒否" + +#: templates/proposals/_proposal_fields.html:4 +msgid "Submitted by" +msgstr "" + +#: templates/proposals/_proposal_fields.html:7 +msgid "Track" +msgstr "トラック" + +#: templates/proposals/_proposal_fields.html:10 +msgid "Audience Level" +msgstr "受講者のレベル" + +#: templates/proposals/_proposal_fields.html:14 +msgid "Additional Speakers" +msgstr "追加の講演者" + +#: templates/proposals/_proposal_fields.html:21 +msgid "Invitation Sent" +msgstr "送信された招待" + +#: templates/proposals/_proposal_fields.html:28 +msgid "Description" +msgstr "記述" + +#: templates/proposals/_proposal_fields.html:31 +msgid "Abstract" +msgstr "講演概要" + +#: templates/proposals/_proposal_fields.html:34 +msgid "Notes" +msgstr "備考" + +#: templates/proposals/_proposal_fields.html:37 +msgid "Speaker Bio" +msgstr "講演者略歴" + +#: templates/proposals/_proposal_fields.html:40 +msgid "Documents" +msgstr "資料" + +#: templates/proposals/proposal_cancel.html:7 +msgid "Cancel Proposal" +msgstr "講演提案のキャンセル" + +#: templates/proposals/proposal_cancel.html:16 +msgid "No, keep it for now" +msgstr "いいえ、このままにします。" + +#: templates/proposals/proposal_detail.html:14 +msgid "Edit this proposal" +msgstr "この提案を編集" + +#: templates/proposals/proposal_detail.html:17 +msgid "Cancel this proposal" +msgstr "この提案をキャンセル" + +#: templates/proposals/proposal_detail.html:21 +msgid "Remove me from this proposal" +msgstr "この提案から自分を削除する。" + +#: templates/proposals/proposal_detail.html:33 +#: templates/reviews/review_detail.html:74 +msgid "Proposal Details" +msgstr "提案の詳細" + +#: templates/proposals/proposal_detail.html:35 +#: templates/proposals/proposal_detail.html:47 +msgid "Supporting Documents" +msgstr "補足資料" + +#: templates/proposals/proposal_detail.html:38 +msgid "Reviewer Feedback" +msgstr "レビュアーからのフィードバック" + +#: templates/proposals/proposal_detail.html:57 +msgid "delete" +msgstr "削除" + +#: templates/proposals/proposal_detail.html:64 +msgid "No supporting documents attached to this proposal." +msgstr "この提案に補足資料は添付されていません。" + +#: templates/proposals/proposal_detail.html:66 +msgid "Add Document" +msgstr "資料の追加" + +#: templates/proposals/proposal_detail.html:73 +msgid "Conversation with Reviewers" +msgstr "レビュアーとの会話記録" + +#: templates/proposals/proposal_detail.html:83 +msgid "Leave a Message" +msgstr "メッセージを残す" + +#: templates/proposals/proposal_detail.html:85 +msgid "You can leave a message for the reviewers here." +msgstr "メッセージをレビューアに残すことができます。" + +#: templates/proposals/proposal_detail.html:94 +msgid "Submit" +msgstr "投稿する" + +#: templates/proposals/proposal_speaker_manage.html:7 +msgid "Proposal:" +msgstr "講演提案:" + +#: templates/proposals/proposal_speaker_manage.html:10 +msgid "Edit proposal" +msgstr "講演提案の編集" + +#: templates/proposals/proposal_speaker_manage.html:14 +msgid "Current Speakers" +msgstr "現在登録されている講演者" + +#: templates/proposals/proposal_speaker_manage.html:20 +msgid "pending invitation" +msgstr "保留されている招待" + +#: templates/proposals/proposal_speaker_manage.html:24 +msgid "Add another speaker" +msgstr "他の講演者を追加" + +#: templates/proposals/proposal_submit.html:6 +msgid "Submit A Proposal" +msgstr "提案を投稿" + +#: templates/reviews/_review_table.html:6 +#: templates/reviews/result_notification.html:45 +msgid "Speaker / Title" +msgstr "講演者/タイトル" + +#: templates/reviews/_review_table.html:7 +#: templates/reviews/result_notification.html:46 +msgid "Category" +msgstr "カテゴリ" + +#: templates/reviews/_review_table.html:9 +msgid "+1" +msgstr "" + +#: templates/reviews/_review_table.html:10 +msgid "+0" +msgstr "" + +#: templates/reviews/_review_table.html:11 +msgid "-0" +msgstr "" + +#: templates/reviews/_review_table.html:12 +msgid "-1" +msgstr "" + +#: templates/reviews/_review_table.html:13 +msgid "Your Rating" +msgstr "あなたの投票" + +#: templates/reviews/base.html:64 +msgid "All Reviews" +msgstr "全てのレビュアー" + +#: templates/reviews/base.html:77 +msgid "Voting Status" +msgstr "投票の状態" + +#: templates/reviews/result_notification.html:47 +msgid "Status" +msgstr "状態" + +#: templates/reviews/result_notification.html:48 +msgid "Notified?" +msgstr "連絡済み?" + +#: templates/reviews/review_detail.html:76 +msgid "Speaker Feedback" +msgstr "講演者へのフィードバック" + +#: templates/reviews/review_detail.html:84 +msgid "Current Results" +msgstr "現在のところの結果" + +#: templates/reviews/review_detail.html:91 +msgid "Total Responses" +msgstr "全応答数" + +#: templates/reviews/review_detail.html:108 +msgid "Submit Review" +msgstr "レビューの投稿" + +#: templates/reviews/review_detail.html:148 +msgid "Conversation with the submitter" +msgstr "投稿者への連絡" + +#: templates/reviews/review_detail.html:162 +msgid "Send a message" +msgstr "メッセージ送信" + +#: templates/reviews/review_detail.html:164 +msgid "" +"\n" +" If you'd like to communicate with the submitter, " +"use the following form and he or she will be\n" +" notified and given the opportunity to respond.\n" +" " +msgstr "" +"\n" +" もし、投稿者と連絡したい場合は、次の投稿フォームを" +"用いて知らせることができます。\n" +" そして、回答の機会を与えることができます。\n" +" " + +#: templates/schedule/_slot_edit.html:5 +msgid "Edit Slot" +msgstr "スロットの編集" + +#: templates/speakers/speaker_create.html:7 +#: templates/speakers/speaker_create.html:14 +msgid "Create Speaker Profile" +msgstr "講演者プロフィールの作成" + +#: templates/speakers/speaker_edit.html:7 +#: templates/speakers/speaker_edit.html:14 +msgid "Edit Speaker Profile" +msgstr "講演者プロフィールの編集" + +#: templates/sponsorship/add.html:7 templates/sponsorship/add.html.py:14 +msgid "Add a Sponsor" +msgstr "スポンサーの追加" + +#: templates/sponsorship/apply.html:7 +msgid "Apply to be a Sponsor" +msgstr "スポンサーに応募する" + +#: templates/sponsorship/apply.html:17 +msgid "Apply to Be a Sponsor" +msgstr "スポンサーに応募する" + +#: templates/sponsorship/list.html:7 templates/sponsorship/list.html.py:14 +msgid "About Our Sponsors" +msgstr "スポンサーについて" diff --git a/vendor/symposion/models.py b/vendor/symposion/models.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/proposals/__init__.py b/vendor/symposion/proposals/__init__.py new file mode 100644 index 00000000..ac65f575 --- /dev/null +++ b/vendor/symposion/proposals/__init__.py @@ -0,0 +1 @@ +default_app_config = "symposion.proposals.apps.ProposalsConfig" diff --git a/vendor/symposion/proposals/actions.py b/vendor/symposion/proposals/actions.py new file mode 100644 index 00000000..7317f88e --- /dev/null +++ b/vendor/symposion/proposals/actions.py @@ -0,0 +1,38 @@ +import csv + +from django.http import HttpResponse +from django.utils.translation import ugettext_lazy as _ + + +def export_as_csv_action(description=None, 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(content_type="text/csv") + response["Content-Disposition"] = "attachment; filename=%s.csv" % str(opts).replace(".", "_") + writer = csv.writer(response) + if header: + writer.writerow(list(field_names)) + for obj in queryset: + writer.writerow( + [str(getattr(obj, field)).encode("utf-8", "replace") for field in field_names]) + return response + if description is None: + description = _("Export selected objects as CSV file") + export_as_csv.short_description = description + return export_as_csv diff --git a/vendor/symposion/proposals/admin.py b/vendor/symposion/proposals/admin.py new file mode 100644 index 00000000..af274aaa --- /dev/null +++ b/vendor/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/vendor/symposion/proposals/apps.py b/vendor/symposion/proposals/apps.py new file mode 100644 index 00000000..c35c024c --- /dev/null +++ b/vendor/symposion/proposals/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class ProposalsConfig(AppConfig): + name = "symposion.proposals" + label = "symposion_proposals" + verbose_name = _("Symposion Proposals") diff --git a/vendor/symposion/proposals/forms.py b/vendor/symposion/proposals/forms.py new file mode 100644 index 00000000..ea0699c8 --- /dev/null +++ b/vendor/symposion/proposals/forms.py @@ -0,0 +1,45 @@ +from django import forms +from django.db.models import Q +from django.utils.translation import ugettext_lazy as _ + +from symposion.proposals.models import SupportingDocument + + +# @@@ generic proposal form + + +class AddSpeakerForm(forms.Form): + + required_css_class = 'label-required' + + 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): + + required_css_class = 'label-required' + + class Meta: + model = SupportingDocument + fields = [ + "file", + "description", + ] diff --git a/vendor/symposion/proposals/migrations/0001_initial.py b/vendor/symposion/proposals/migrations/0001_initial.py new file mode 100644 index 00000000..318becc2 --- /dev/null +++ b/vendor/symposion/proposals/migrations/0001_initial.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-09-17 03:35 +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import symposion.proposals.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('symposion_speakers', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('symposion_conference', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AdditionalSpeaker', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.IntegerField(choices=[(1, 'Pending'), (2, 'Accepted'), (3, 'Declined')], default=1, verbose_name='Status')), + ], + options={ + 'verbose_name': 'Addtional speaker', + 'verbose_name_plural': 'Additional speakers', + }, + ), + migrations.CreateModel( + name='ProposalBase', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='Title')), + ('abstract', models.TextField(help_text="This will appear in the conference programme. Up to about 500 words. Edit using Markdown.", verbose_name='Abstract')), + ('abstract_html', models.TextField(blank=True)), + ('private_abstract', models.TextField(help_text="This will only be shown to organisers and reviewers. You should provide any details about your proposal that you don't want to be public here. Edit using Markdown.", verbose_name='Private Abstract')), + ('private_abstract_html', models.TextField(blank=True)), + ('technical_requirements', models.TextField(blank=True, help_text="Speakers will be provided with: Internet access, power, projector, audio. If you require anything in addition, please list your technical requirements here. Such as: a static IP address, A/V equipment or will be demonstrating security-related techniques on the conference network. Edit using Markdown.", verbose_name='Special Requirements')), + ('technical_requirements_html', models.TextField(blank=True)), + ('project', models.CharField(blank=True, help_text='The name of the project you will be talking about.', max_length=100)), + ('project_url', models.URLField(blank=True, help_text='If your project has a webpage, specify the URL here so the committee can find out more about your proposal.', verbose_name='Project URL')), + ('video_url', models.URLField(blank=True, help_text="You may optionally provide us with a link to a video of you speaking at another event, or of a short 'elevator pitch' of your proposed talk.", verbose_name='Video')), + ('submitted', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Submitted')), + ('cancelled', models.BooleanField(default=False, verbose_name='Cancelled')), + ('additional_speakers', models.ManyToManyField(blank=True, through='symposion_proposals.AdditionalSpeaker', to='symposion_speakers.Speaker', verbose_name='Addtional speakers')), + ], + ), + migrations.CreateModel( + name='ProposalKind', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ('slug', models.SlugField(verbose_name='Slug')), + ('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='proposal_kinds', to='symposion_conference.Section', verbose_name='Section')), + ], + ), + migrations.CreateModel( + name='ProposalSection', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start', models.DateTimeField(blank=True, null=True, verbose_name='Start')), + ('end', models.DateTimeField(blank=True, null=True, verbose_name='End')), + ('closed', models.NullBooleanField(verbose_name='Closed')), + ('published', models.NullBooleanField(verbose_name='Published')), + ('section', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='symposion_conference.Section', verbose_name='Section')), + ], + ), + migrations.CreateModel( + name='SupportingDocument', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Created at')), + ('file', models.FileField(upload_to=symposion.proposals.models.uuid_filename, verbose_name='File')), + ('description', models.CharField(max_length=140, verbose_name='Description')), + ('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supporting_documents', to='symposion_proposals.ProposalBase', verbose_name='Proposal')), + ('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded by')), + ], + ), + migrations.AddField( + model_name='proposalbase', + name='kind', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_proposals.ProposalKind', verbose_name='Kind'), + ), + migrations.AddField( + model_name='proposalbase', + name='speaker', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='proposals', to='symposion_speakers.Speaker', verbose_name='Speaker'), + ), + migrations.AddField( + model_name='additionalspeaker', + name='proposalbase', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_proposals.ProposalBase', verbose_name='Proposalbase'), + ), + migrations.AddField( + model_name='additionalspeaker', + name='speaker', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_speakers.Speaker', verbose_name='Speaker'), + ), + migrations.AlterUniqueTogether( + name='additionalspeaker', + unique_together=set([('speaker', 'proposalbase')]), + ), + ] diff --git a/vendor/symposion/proposals/migrations/__init__.py b/vendor/symposion/proposals/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/proposals/models.py b/vendor/symposion/proposals/models.py new file mode 100644 index 00000000..4a8650a0 --- /dev/null +++ b/vendor/symposion/proposals/models.py @@ -0,0 +1,251 @@ +import os +import uuid + +from django.core.urlresolvers import reverse +from django.db import models +from django.db.models import Q +from django.utils.translation import ugettext_lazy as _ +from django.utils.timezone import now + +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError + +from model_utils.managers import InheritanceManager +from reversion import revisions as reversion + +from symposion import constants +from symposion.text_parser import parse +from symposion.conference.models import Section +from symposion.speakers.models import Speaker + + +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, verbose_name=_("Section")) + + start = models.DateTimeField(null=True, blank=True, verbose_name=_("Start")) + end = models.DateTimeField(null=True, blank=True, verbose_name=_("End")) + closed = models.NullBooleanField(verbose_name=_("Closed")) + published = models.NullBooleanField(verbose_name=_("Published")) + + @classmethod + def available(cls): + 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 is_available(self): + if self.closed: + return False + if self.start and self.start > now(): + return False + if self.end and self.end < now(): + return False + return True + + def __str__(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", verbose_name=_("Section")) + + name = models.CharField(_("Name"), max_length=100) + slug = models.SlugField(verbose_name=_("Slug")) + + def __str__(self): + return self.name + + +class ProposalBase(models.Model): + + objects = InheritanceManager() + + kind = models.ForeignKey(ProposalKind, verbose_name=_("Kind")) + + title = models.CharField(max_length=100, verbose_name=_("Title")) + abstract = models.TextField( + _("Abstract"), + help_text=_("This will appear in the conference programme. Up to about " + "500 words. " + constants.TEXT_FIELD_MONOSPACE_NOTE) + ) + abstract_html = models.TextField(blank=True) + + private_abstract = models.TextField( + _("Private Abstract"), + help_text=_("This will only be shown to organisers and reviewers. You " + "should provide any details about your proposal that you " + "don't want to be public here. " + + constants.TEXT_FIELD_MONOSPACE_NOTE) + ) + private_abstract_html = models.TextField(blank=True) + + technical_requirements = models.TextField( + _("Special Requirements"), + blank=True, + help_text=_("Speakers will be provided with: Internet access, power, " + "projector, audio. If you require anything in addition, " + "please list your technical requirements here. Such as: a " + "static IP address, A/V equipment or will be demonstrating " + "security-related techniques on the conference network. " + + constants.TEXT_FIELD_MONOSPACE_NOTE) + ) + technical_requirements_html = models.TextField(blank=True) + + project = models.CharField( + max_length=100, + blank=True, + help_text=_("The name of the project you will be talking about."), + ) + project_url = models.URLField( + _("Project URL"), + blank=True, + help_text=_("If your project has a webpage, specify the URL here so " + "the committee can find out more about your proposal.") + ) + video_url = models.URLField( + _("Video"), + blank=True, + help_text=_("You may optionally provide us with a link to a video of " + "you speaking at another event, or of a short 'elevator " + "pitch' of your proposed talk.") + ) + + submitted = models.DateTimeField( + default=now, + editable=False, + verbose_name=_("Submitted") + ) + speaker = models.ForeignKey(Speaker, related_name="proposals", verbose_name=_("Speaker")) + + # @@@ this validation used to exist as a validators keyword on additional_speakers + # M2M field but that is no longer supported by Django. Should be moved to + # the form level + def additional_speaker_validator(self, a_speaker): + if a_speaker.speaker.email == self.speaker.email: + raise ValidationError(_("%s is same as primary speaker.") % a_speaker.speaker.email) + if a_speaker in [self.additional_speakers]: + raise ValidationError(_("%s has already been in speakers.") % a_speaker.speaker.email) + + additional_speakers = models.ManyToManyField(Speaker, through="AdditionalSpeaker", + blank=True, verbose_name=_("Addtional speakers")) + cancelled = models.BooleanField(default=False, verbose_name=_("Cancelled")) + + def save(self, *args, **kwargs): + self.abstract_html = parse(self.abstract) + self.private_abstract_html = parse(self.private_abstract) + self.technical_requirements_html = parse(self.technical_requirements) + return super(ProposalBase, self).save(*args, **kwargs) + + def can_edit(self): + return True + + @property + def section(self): + return self.kind.section + + @property + def speaker_email(self): + return self.speaker.email + + @property + def number(self): + return str(self.pk).zfill(3) + + @property + def status(self): + try: + return self.result.status + except ObjectDoesNotExist: + return _('Undecided') + + def speakers(self): + yield self.speaker + speakers = self.additional_speakers.exclude( + additionalspeaker__status=AdditionalSpeaker.SPEAKING_STATUS_DECLINED) + for speaker in speakers: + yield speaker + + def notification_email_context(self): + return { + "title": self.title, + "main_speaker": self.speaker, + "speakers": ', '.join([x.name for x in self.speakers()]), + "additional_speakers": self.additional_speakers, + "kind": self.kind.name, + } + + def __str__(self): + return self.title + + +reversion.register(ProposalBase) + + +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(Speaker, verbose_name=_("Speaker")) + proposalbase = models.ForeignKey(ProposalBase, verbose_name=_("Proposalbase")) + status = models.IntegerField(choices=SPEAKING_STATUS, default=SPEAKING_STATUS_PENDING, verbose_name=_("Status")) + + class Meta: + unique_together = ("speaker", "proposalbase") + verbose_name = _("Addtional speaker") + verbose_name_plural = _("Additional speakers") + + def __str__(self): + if self.status is self.SPEAKING_STATUS_PENDING: + return _(u"pending speaker (%s)") % self.speaker.email + elif self.status is self.SPEAKING_STATUS_DECLINED: + return _(u"declined speaker (%s)") % self.speaker.email + else: + return self.speaker.name + + +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", verbose_name=_("Proposal")) + + uploaded_by = models.ForeignKey(User, verbose_name=_("Uploaded by")) + + created_at = models.DateTimeField(default=now, verbose_name=_("Created at")) + + file = models.FileField(upload_to=uuid_filename, verbose_name=_("File")) + description = models.CharField(max_length=140, verbose_name=_("Description")) + + def download_url(self): + return self.file.url diff --git a/vendor/symposion/proposals/templatetags/__init__.py b/vendor/symposion/proposals/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/proposals/templatetags/proposal_tags.py b/vendor/symposion/proposals/templatetags/proposal_tags.py new file mode 100644 index 00000000..f9581d35 --- /dev/null +++ b/vendor/symposion/proposals/templatetags/proposal_tags.py @@ -0,0 +1,72 @@ +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/vendor/symposion/proposals/urls.py b/vendor/symposion/proposals/urls.py new file mode 100644 index 00000000..03b2993c --- /dev/null +++ b/vendor/symposion/proposals/urls.py @@ -0,0 +1,32 @@ +from django.conf.urls import url + +from .views import ( + proposal_submit, + proposal_submit_kind, + proposal_detail, + proposal_edit, + proposal_speaker_manage, + proposal_cancel, + proposal_leave, + proposal_pending_join, + proposal_pending_decline, + document_create, + document_delete, + document_download, +) + +urlpatterns = [ + 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/vendor/symposion/proposals/views.py b/vendor/symposion/proposals/views.py new file mode 100644 index 00000000..051a794b --- /dev/null +++ b/vendor/symposion/proposals/views.py @@ -0,0 +1,414 @@ +import hashlib +import random +import sys + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned +from django.core.urlresolvers import reverse +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.views import static + +from django.contrib import messages +from django.contrib.auth.models import User + +from django.utils.translation import ugettext_lazy as _ + +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(): + messages.info(request, _("To submit a proposal, please " + "log in and create a speaker profile " + "via the dashboard.".format(settings.LOGIN_URL))) + return redirect("home") # @@@ unauth'd speaker info page? + else: + try: + request.user.speaker_profile + except ObjectDoesNotExist: + url = reverse("speaker_create") + messages.info(request, _("To submit a proposal, first " + "create a speaker " + "profile.".format(url))) + 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, "symposion/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") + + if not kind.section.proposalsection.is_available(): + return redirect("proposal_submit") + + 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, "symposion/proposals/proposal_submit_kind.html", { + "kind": kind, + "proposal_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 = hashlib.sha1(str(random.random()).encode('UTF-8')).hexdigest()[:5] + saltedemail = (salt + email_address).encode('UTF-8') + token = hashlib.sha1(saltedemail).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 + try: + user = User.objects.get(email=email_address) + except ObjectDoesNotExist: + user = None + except MultipleObjectsReturned: + # FIXME: This is not handled in the previous code, so I'm not + # going to frett on this now, but should be handled as it is + # an occourance that really, really shouldn't occour. + # Previously, code took [0] from the list and continued. + raise NotImplementedError("Non unique email should not occour") + if user: + # should only be one since we enforce unique email + 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, "symposion/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, "symposion/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() + if hasattr(proposal, "reviews"): + # Miniconf updates should only email the admins + if proposal.kind.slug == 'miniconf': + users = User.objects.filter(username__in=settings.ADMIN_USERNAMES) + else: + users = User.objects.filter( + Q(review__proposal=proposal) | + Q(proposalmessage__proposal=proposal) + ) + users = users.exclude(id=request.user.id).distinct() + for user in users: + ctx = { + "user": request.user, + "proposal": proposal, + } + send_email( + [user.email], "proposal_updated", + context=ctx + ) + messages.success(request, "Proposal updated.") + return redirect("proposal_detail", proposal.pk) + else: + form = form_class(instance=proposal) + + return render(request, "symposion/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() + + if "symposion.reviews" in settings.INSTALLED_APPS: + from symposion.reviews.forms import SpeakerCommentForm + message_form = SpeakerCommentForm() + if request.method == "POST": + message_form = SpeakerCommentForm(request.POST) + if message_form.is_valid(): + + message = message_form.save(commit=False) + message.user = request.user + message.proposal = proposal + message.save() + + ProposalMessage = SpeakerCommentForm.Meta.model + reviewers = User.objects.filter( + id__in=ProposalMessage.objects.filter( + proposal=proposal + ).exclude( + user=request.user + ).distinct().values_list("user", flat=True) + ) + + for reviewer in reviewers: + ctx = { + "proposal": proposal, + "message": message, + "reviewer": True, + } + send_email( + [reviewer.email], "proposal_new_message", + context=ctx + ) + + return redirect(request.path) + else: + message_form = SpeakerCommentForm() + else: + message_form = None + + return render(request, "symposion/proposals/proposal_detail.html", { + "proposal": proposal, + "message_form": message_form + }) + + +@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, "symposion/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("dashboard") + ctx = { + "proposal": proposal, + } + return render(request, "symposion/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 proposal.cancelled: + return HttpResponseForbidden() + + 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, "symposion/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 getattr(settings, "USE_X_ACCEL_REDIRECT", False): + 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) diff --git a/vendor/symposion/reviews/__init__.py b/vendor/symposion/reviews/__init__.py new file mode 100644 index 00000000..8c9647e8 --- /dev/null +++ b/vendor/symposion/reviews/__init__.py @@ -0,0 +1 @@ +default_app_config = "symposion.reviews.apps.ReviewsConfig" diff --git a/vendor/symposion/reviews/admin.py b/vendor/symposion/reviews/admin.py new file mode 100644 index 00000000..5144c2ca --- /dev/null +++ b/vendor/symposion/reviews/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from symposion.reviews.models import NotificationTemplate, ProposalResult, ResultNotification + + +admin.site.register( + NotificationTemplate, + list_display=[ + 'label', + 'from_address', + 'subject' + ] +) + +admin.site.register( + ProposalResult, + list_display=['proposal', 'status', 'score', 'vote_count', 'accepted'] +) + +admin.site.register(ResultNotification) diff --git a/vendor/symposion/reviews/apps.py b/vendor/symposion/reviews/apps.py new file mode 100644 index 00000000..80fab5c3 --- /dev/null +++ b/vendor/symposion/reviews/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class ReviewsConfig(AppConfig): + name = "symposion.reviews" + label = "symposion_reviews" + verbose_name = _("Symposion Reviews") diff --git a/vendor/symposion/reviews/context_processors.py b/vendor/symposion/reviews/context_processors.py new file mode 100644 index 00000000..02850f15 --- /dev/null +++ b/vendor/symposion/reviews/context_processors.py @@ -0,0 +1,11 @@ +from symposion.proposals.models import ProposalSection + + +def reviews(request): + sections = [] + for section in ProposalSection.objects.all(): + if request.user.has_perm("reviews.can_review_%s" % section.section.slug): + sections.append(section) + return { + "review_sections": sections, + } diff --git a/vendor/symposion/reviews/forms.py b/vendor/symposion/reviews/forms.py new file mode 100644 index 00000000..426bab88 --- /dev/null +++ b/vendor/symposion/reviews/forms.py @@ -0,0 +1,49 @@ +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from symposion.reviews.models import Review, Comment, ProposalMessage, VOTES + + +class ReviewForm(forms.ModelForm): + + required_css_class = 'label-required' + + class Meta: + model = Review + fields = ["vote", "comment"] + + def __init__(self, *args, **kwargs): + super(ReviewForm, self).__init__(*args, **kwargs) + self.fields["vote"] = forms.ChoiceField( + widget=forms.RadioSelect(), + choices=VOTES.CHOICES + ) + + +class ReviewCommentForm(forms.ModelForm): + + required_css_class = 'label-required' + + class Meta: + model = Comment + fields = ["text"] + + +class SpeakerCommentForm(forms.ModelForm): + + required_css_class = 'label-required' + + class Meta: + model = ProposalMessage + fields = ["message"] + + +class BulkPresentationForm(forms.Form): + + required_css_class = 'label-required' + + talk_ids = forms.CharField( + label=_("Talk ids"), + max_length=500, + help_text=_("Provide a comma seperated list of talk ids to accept.") + ) diff --git a/vendor/symposion/reviews/management/__init__.py b/vendor/symposion/reviews/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/reviews/management/commands/__init__.py b/vendor/symposion/reviews/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/reviews/management/commands/assign_reviewers.py b/vendor/symposion/reviews/management/commands/assign_reviewers.py new file mode 100644 index 00000000..6364e0fc --- /dev/null +++ b/vendor/symposion/reviews/management/commands/assign_reviewers.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand + +from symposion.reviews.models import ReviewAssignment +from symposion.proposals.models import ProposalBase + + +class Command(BaseCommand): + + def handle(self, *args, **options): + for proposal in ProposalBase.objects.filter(cancelled=0): + print("Creating assignments for %s" % proposal.title) + ReviewAssignment.create_assignments(proposal) diff --git a/vendor/symposion/reviews/management/commands/calculate_results.py b/vendor/symposion/reviews/management/commands/calculate_results.py new file mode 100644 index 00000000..6a06ce8a --- /dev/null +++ b/vendor/symposion/reviews/management/commands/calculate_results.py @@ -0,0 +1,9 @@ +from django.core.management.base import BaseCommand + +from symposion.reviews.models import ProposalResult + + +class Command(BaseCommand): + + def handle(self, *args, **options): + ProposalResult.full_calculate() diff --git a/vendor/symposion/reviews/management/commands/create_review_permissions.py b/vendor/symposion/reviews/management/commands/create_review_permissions.py new file mode 100644 index 00000000..2f21754a --- /dev/null +++ b/vendor/symposion/reviews/management/commands/create_review_permissions.py @@ -0,0 +1,25 @@ +from django.core.management.base import BaseCommand + +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType + +from symposion.proposals.models import ProposalSection + + +class Command(BaseCommand): + + def handle(self, *args, **options): + ct, created = ContentType.objects.get_or_create( + model="", + app_label="reviews", + defaults={"name": "reviews"} + ) + + for ps in ProposalSection.objects.all(): + for action in ["review", "manage"]: + perm, created = Permission.objects.get_or_create( + codename="can_%s_%s" % (action, ps.section.slug), + content_type__pk=ct.id, + defaults={"name": "Can %s %s" % (action, ps), "content_type": ct} + ) + print(perm) diff --git a/vendor/symposion/reviews/management/commands/promoteproposals.py b/vendor/symposion/reviews/management/commands/promoteproposals.py new file mode 100644 index 00000000..e62c6903 --- /dev/null +++ b/vendor/symposion/reviews/management/commands/promoteproposals.py @@ -0,0 +1,16 @@ +from django.core.management.base import BaseCommand +from django.db import connections + +from symposion.reviews.models import ProposalResult, promote_proposal + + +class Command(BaseCommand): + + def handle(self, *args, **options): + accepted_proposals = ProposalResult.objects.filter(status="accepted") + accepted_proposals = accepted_proposals.order_by("proposal") + + for result in accepted_proposals: + promote_proposal(result.proposal) + connections["default"].cursor().execute( + "SELECT setval('schedule_session_id_seq', (SELECT max(id) FROM schedule_session))") diff --git a/vendor/symposion/reviews/migrations/0001_initial.py b/vendor/symposion/reviews/migrations/0001_initial.py new file mode 100644 index 00000000..39af922a --- /dev/null +++ b/vendor/symposion/reviews/migrations/0001_initial.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-09-17 03:35 +import datetime +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('symposion_proposals', '__first__'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(verbose_name='Text')), + ('text_html', models.TextField(blank=True)), + ('public', models.BooleanField(choices=[(True, 'public'), (False, 'private')], default=False, verbose_name='Public')), + ('commented_at', models.DateTimeField(default=datetime.datetime.now, verbose_name='Commented at')), + ('commenter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Commenter')), + ('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='symposion_proposals.ProposalBase', verbose_name='Proposal')), + ], + options={ + 'verbose_name': 'comment', + 'verbose_name_plural': 'comments', + }, + ), + migrations.CreateModel( + name='LatestVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vote', models.CharField(choices=[('+2', '+2 \u2014 Good proposal and I will argue for it to be accepted.'), ('+1', '+1 \u2014 OK proposal, but I will not argue for it to be accepted.'), ('-1', '\u22121 \u2014 Weak proposal, but I will not argue strongly against acceptance.'), ('-2', '\u22122 \u2014 Serious issues and I will argue to reject this proposal.'), ('0', 'Abstain - I do not want to review this proposal and I do not want to see it again.')], max_length=2, verbose_name='Vote')), + ('submitted_at', models.DateTimeField(default=datetime.datetime.now, editable=False, verbose_name='Submitted at')), + ('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='symposion_proposals.ProposalBase', verbose_name='Proposal')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'latest vote', + 'verbose_name_plural': 'latest votes', + }, + ), + migrations.CreateModel( + name='NotificationTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=100, verbose_name='Label')), + ('from_address', models.EmailField(max_length=254, verbose_name='From address')), + ('subject', models.CharField(max_length=100, verbose_name='Subject')), + ('body', models.TextField(verbose_name='Body')), + ], + options={ + 'verbose_name': 'notification template', + 'verbose_name_plural': 'notification templates', + }, + ), + migrations.CreateModel( + name='ProposalMessage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField(verbose_name='Message')), + ('message_html', models.TextField(blank=True)), + ('submitted_at', models.DateTimeField(default=datetime.datetime.now, editable=False, verbose_name='Submitted at')), + ('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='symposion_proposals.ProposalBase', verbose_name='Proposal')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'ordering': ['submitted_at'], + 'verbose_name': 'proposal message', + 'verbose_name_plural': 'proposal messages', + }, + ), + migrations.CreateModel( + name='ProposalResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=5, verbose_name='Score')), + ('comment_count', models.PositiveIntegerField(default=0, verbose_name='Comment count')), + ('vote_count', models.PositiveIntegerField(default=0, verbose_name='Vote count')), + ('abstain', models.PositiveIntegerField(default=0, verbose_name='Abstain')), + ('plus_two', models.PositiveIntegerField(default=0, verbose_name='Plus two')), + ('plus_one', models.PositiveIntegerField(default=0, verbose_name='Plus one')), + ('minus_one', models.PositiveIntegerField(default=0, verbose_name='Minus one')), + ('minus_two', models.PositiveIntegerField(default=0, verbose_name='Minus two')), + ('accepted', models.NullBooleanField(choices=[(True, 'accepted'), (False, 'rejected'), (None, 'undecided')], default=None, verbose_name='Accepted')), + ('status', models.CharField(choices=[('accepted', 'accepted'), ('rejected', 'rejected'), ('undecided', 'undecided'), ('standby', 'standby')], default='undecided', max_length=20, verbose_name='Status')), + ('proposal', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='result', to='symposion_proposals.ProposalBase', verbose_name='Proposal')), + ], + options={ + 'verbose_name': 'proposal_result', + 'verbose_name_plural': 'proposal_results', + }, + ), + migrations.CreateModel( + name='ResultNotification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(default=datetime.datetime.now, verbose_name='Timestamp')), + ('to_address', models.EmailField(max_length=254, verbose_name='To address')), + ('from_address', models.EmailField(max_length=254, verbose_name='From address')), + ('subject', models.CharField(max_length=255, verbose_name='Subject')), + ('body', models.TextField(verbose_name='Body')), + ('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='symposion_proposals.ProposalBase', verbose_name='Proposal')), + ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='symposion_reviews.NotificationTemplate', verbose_name='Template')), + ], + ), + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vote', models.CharField(blank=True, choices=[('+2', '+2 \u2014 Good proposal and I will argue for it to be accepted.'), ('+1', '+1 \u2014 OK proposal, but I will not argue for it to be accepted.'), ('-1', '\u22121 \u2014 Weak proposal, but I will not argue strongly against acceptance.'), ('-2', '\u22122 \u2014 Serious issues and I will argue to reject this proposal.'), ('0', 'Abstain - I do not want to review this proposal and I do not want to see it again.')], max_length=2, verbose_name='Vote')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('comment_html', models.TextField(blank=True)), + ('submitted_at', models.DateTimeField(default=datetime.datetime.now, editable=False, verbose_name='Submitted at')), + ('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='symposion_proposals.ProposalBase', verbose_name='Proposal')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'review', + 'verbose_name_plural': 'reviews', + }, + ), + migrations.CreateModel( + name='ReviewAssignment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('origin', models.IntegerField(choices=[(0, 'auto-assigned, initial'), (1, 'opted-in'), (2, 'auto-assigned, later')], verbose_name='Origin')), + ('assigned_at', models.DateTimeField(default=datetime.datetime.now, verbose_name='Assigned at')), + ('opted_out', models.BooleanField(default=False, verbose_name='Opted out')), + ('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_proposals.ProposalBase', verbose_name='Proposal')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + ), + migrations.AlterUniqueTogether( + name='latestvote', + unique_together=set([('proposal', 'user')]), + ), + ] diff --git a/vendor/symposion/reviews/migrations/__init__.py b/vendor/symposion/reviews/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/reviews/models.py b/vendor/symposion/reviews/models.py new file mode 100644 index 00000000..1fc1667b --- /dev/null +++ b/vendor/symposion/reviews/models.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +from datetime import datetime +from decimal import Decimal + +from django.core.exceptions import ValidationError + +from django.db import models +from django.db.models import Q, F +from django.db.models import Case, When, Value +from django.db.models import Count +from django.db.models.signals import post_save + +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ + +from symposion import constants +from symposion.text_parser import parse +from symposion.proposals.models import ProposalBase +from symposion.schedule.models import Presentation + + +def score_expression(): + score = ( + (2 * F("plus_two") + F("plus_one")) - + (F("minus_one") + 2 * F("minus_two")) + ) / ( + F("vote_count") * 1.0 + ) + + return Case( + When(vote_count=0, then=Value("0")), # no divide by zero + default=score, + ) + + +class Votes(object): + ABSTAIN = "0" + PLUS_TWO = "+2" + PLUS_ONE = "+1" + MINUS_ONE = "-1" + MINUS_TWO = "-2" + + CHOICES = [ + (PLUS_TWO, _("+2 — Good proposal and I will argue for it to be accepted.")), + (PLUS_ONE, _("+1 — OK proposal, but I will not argue for it to be accepted.")), + (MINUS_ONE, _("−1 — Weak proposal, but I will not argue strongly against acceptance.")), + (MINUS_TWO, _("−2 — Serious issues and I will argue to reject this proposal.")), + (ABSTAIN, _("Abstain - I do not want to review this proposal and I do not want to see it again.")), + ] + + +VOTES = Votes() + + +class ReviewAssignment(models.Model): + AUTO_ASSIGNED_INITIAL = 0 + OPT_IN = 1 + AUTO_ASSIGNED_LATER = 2 + + NUM_REVIEWERS = 3 + + ORIGIN_CHOICES = [ + (AUTO_ASSIGNED_INITIAL, _("auto-assigned, initial")), + (OPT_IN, _("opted-in")), + (AUTO_ASSIGNED_LATER, _("auto-assigned, later")), + ] + + proposal = models.ForeignKey(ProposalBase, verbose_name=_("Proposal")) + user = models.ForeignKey(User, verbose_name=_("User")) + + origin = models.IntegerField(choices=ORIGIN_CHOICES, verbose_name=_("Origin")) + + assigned_at = models.DateTimeField(default=datetime.now, verbose_name=_("Assigned at")) + opted_out = models.BooleanField(default=False, verbose_name=_("Opted out")) + + @classmethod + def create_assignments(cls, proposal, origin=AUTO_ASSIGNED_INITIAL): + speakers = [proposal.speaker] + list(proposal.additional_speakers.all()) + reviewers = User.objects.exclude( + pk__in=[ + speaker.user_id + for speaker in speakers + if speaker.user_id is not None + ] + [ + assignment.user_id + for assignment in ReviewAssignment.objects.filter( + proposal_id=proposal.id)] + ).filter( + groups__name="reviewers", + ).filter( + Q(reviewassignment__opted_out=False) | Q(reviewassignment=None) + ).annotate( + num_assignments=models.Count("reviewassignment") + ).order_by( + "num_assignments", "?", + ) + num_assigned_reviewers = ReviewAssignment.objects.filter( + proposal_id=proposal.id, opted_out=0).count() + for reviewer in reviewers[:max(0, cls.NUM_REVIEWERS - num_assigned_reviewers)]: + cls._default_manager.create( + proposal=proposal, + user=reviewer, + origin=origin, + ) + + +class ProposalMessage(models.Model): + proposal = models.ForeignKey(ProposalBase, related_name="messages", verbose_name=_("Proposal")) + user = models.ForeignKey(User, verbose_name=_("User")) + + message = models.TextField(verbose_name=_("Message")) + message_html = models.TextField(blank=True) + submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at")) + + def save(self, *args, **kwargs): + self.message_html = parse(self.message) + return super(ProposalMessage, self).save(*args, **kwargs) + + class Meta: + ordering = ["submitted_at"] + verbose_name = _("proposal message") + verbose_name_plural = _("proposal messages") + + +class Review(models.Model): + VOTES = VOTES + + proposal = models.ForeignKey(ProposalBase, related_name="reviews", verbose_name=_("Proposal")) + user = models.ForeignKey(User, verbose_name=_("User")) + + # No way to encode "-0" vs. "+0" into an IntegerField, and I don't feel + # like some complicated encoding system. + vote = models.CharField(max_length=2, blank=True, choices=VOTES.CHOICES, verbose_name=_("Vote")) + comment = models.TextField( + blank=True, + verbose_name=_("Comment") + ) + comment_html = models.TextField(blank=True) + submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at")) + + def clean(self): + err = {} + if self.vote != VOTES.ABSTAIN and not self.comment.strip(): + err["comment"] = ValidationError(_("You must provide a comment")) + + if err: + raise ValidationError(err) + + def save(self, **kwargs): + self.comment_html = parse(self.comment) + if self.vote: + vote, created = LatestVote.objects.get_or_create( + proposal=self.proposal, + user=self.user, + defaults=dict( + vote=self.vote, + submitted_at=self.submitted_at, + ) + ) + if not created: + LatestVote.objects.filter(pk=vote.pk).update(vote=self.vote) + self.proposal.result.update_vote(self.vote, previous=vote.vote) + else: + self.proposal.result.update_vote(self.vote) + super(Review, self).save(**kwargs) + + def delete(self): + model = self.__class__ + user_reviews = model._default_manager.filter( + proposal=self.proposal, + user=self.user, + ) + try: + # find the latest review + latest = user_reviews.exclude(pk=self.pk).order_by("-submitted_at")[0] + except IndexError: + # did not find a latest which means this must be the only one. + # treat it as a last, but delete the latest vote. + self.proposal.result.update_vote(self.vote, removal=True) + lv = LatestVote.objects.filter(proposal=self.proposal, user=self.user) + lv.delete() + else: + # handle that we've found a latest vote + # check if self is the lastest vote + if self == latest: + # self is the latest review; revert the latest vote to the + # previous vote + previous = user_reviews.filter(submitted_at__lt=self.submitted_at)\ + .order_by("-submitted_at")[0] + self.proposal.result.update_vote(self.vote, previous=previous.vote, removal=True) + lv = LatestVote.objects.filter(proposal=self.proposal, user=self.user) + lv.update( + vote=previous.vote, + submitted_at=previous.submitted_at, + ) + else: + # self is not the latest review so we just need to decrement + # the comment count + self.proposal.result.comment_count = models.F("comment_count") - 1 + self.proposal.result.save() + # in all cases we need to delete the review; let's do it! + super(Review, self).delete() + + def css_class(self): + return { + self.VOTES.ABSTAIN: "abstain", + self.VOTES.PLUS_TWO: "plus-two", + self.VOTES.PLUS_ONE: "plus-one", + self.VOTES.MINUS_ONE: "minus-one", + self.VOTES.MINUS_TWO: "minus-two", + }[self.vote] + + @property + def section(self): + return self.proposal.kind.section.slug + + class Meta: + verbose_name = _("review") + verbose_name_plural = _("reviews") + + +class LatestVote(models.Model): + VOTES = VOTES + + proposal = models.ForeignKey(ProposalBase, related_name="votes", verbose_name=_("Proposal")) + user = models.ForeignKey(User, verbose_name=_("User")) + + # No way to encode "-0" vs. "+0" into an IntegerField, and I don't feel + # like some complicated encoding system. + vote = models.CharField(max_length=2, choices=VOTES.CHOICES, verbose_name=_("Vote")) + submitted_at = models.DateTimeField(default=datetime.now, editable=False, verbose_name=_("Submitted at")) + + class Meta: + unique_together = [("proposal", "user")] + verbose_name = _("latest vote") + verbose_name_plural = _("latest votes") + + def css_class(self): + return { + self.VOTES.ABSTAIN: "abstain", + self.VOTES.PLUS_TWO: "plus-two", + self.VOTES.PLUS_ONE: "plus-one", + self.VOTES.MINUS_ONE: "minus-one", + self.VOTES.MINUS_TWO: "minus-two", + }[self.vote] + + +class ProposalResult(models.Model): + proposal = models.OneToOneField(ProposalBase, related_name="result", verbose_name=_("Proposal")) + score = models.DecimalField(max_digits=5, decimal_places=2, default=Decimal("0.00"), verbose_name=_("Score")) + comment_count = models.PositiveIntegerField(default=0, verbose_name=_("Comment count")) + # vote_count only counts non-abstain votes. + vote_count = models.PositiveIntegerField(default=0, verbose_name=_("Vote count")) + abstain = models.PositiveIntegerField(default=0, verbose_name=_("Abstain")) + plus_two = models.PositiveIntegerField(default=0, verbose_name=_("Plus two")) + plus_one = models.PositiveIntegerField(default=0, verbose_name=_("Plus one")) + minus_one = models.PositiveIntegerField(default=0, verbose_name=_("Minus one")) + minus_two = models.PositiveIntegerField(default=0, verbose_name=_("Minus two")) + accepted = models.NullBooleanField(choices=[ + (True, "accepted"), + (False, "rejected"), + (None, "undecided"), + ], default=None, verbose_name=_("Accepted")) + status = models.CharField(max_length=20, choices=[ + ("accepted", _("accepted")), + ("rejected", _("rejected")), + ("undecided", _("undecided")), + ("standby", _("standby")), + ], default="undecided", verbose_name=_("Status")) + + @classmethod + def full_calculate(cls): + for proposal in ProposalBase.objects.all(): + result, created = cls._default_manager.get_or_create(proposal=proposal) + result.update_vote() + + def update_vote(self, *a, **k): + proposal = self.proposal + self.comment_count = Review.objects.filter(proposal=proposal).count() + agg = LatestVote.objects.filter(proposal=proposal).values( + "vote" + ).annotate( + count=Count("vote") + ) + vote_count = {} + # Set the defaults + for option in VOTES.CHOICES: + vote_count[option[0]] = 0 + # Set the actual values if present + for d in agg: + vote_count[d["vote"]] = d["count"] + + self.abstain = vote_count[VOTES.ABSTAIN] + self.plus_two = vote_count[VOTES.PLUS_TWO] + self.plus_one = vote_count[VOTES.PLUS_ONE] + self.minus_one = vote_count[VOTES.MINUS_ONE] + self.minus_two = vote_count[VOTES.MINUS_TWO] + self.vote_count = sum(i[1] for i in vote_count.items()) - self.abstain + self.save() + model = self.__class__ + model._default_manager.filter(pk=self.pk).update(score=score_expression()) + + class Meta: + verbose_name = _("proposal_result") + verbose_name_plural = _("proposal_results") + + +class Comment(models.Model): + proposal = models.ForeignKey(ProposalBase, related_name="comments", verbose_name=_("Proposal")) + commenter = models.ForeignKey(User, verbose_name=_("Commenter")) + text = models.TextField(verbose_name=_("Text")) + text_html = models.TextField(blank=True) + + # Or perhaps more accurately, can the user see this comment. + public = models.BooleanField(choices=[(True, _("public")), (False, _("private"))], default=False, verbose_name=_("Public")) + commented_at = models.DateTimeField(default=datetime.now, verbose_name=_("Commented at")) + + class Meta: + verbose_name = _("comment") + verbose_name_plural = _("comments") + + def save(self, *args, **kwargs): + self.text_html = parse(self.text) + return super(Comment, self).save(*args, **kwargs) + + +class NotificationTemplate(models.Model): + + label = models.CharField(max_length=100, verbose_name=_("Label")) + from_address = models.EmailField(verbose_name=_("From address")) + subject = models.CharField(max_length=100, verbose_name=_("Subject")) + body = models.TextField(verbose_name=_("Body")) + + class Meta: + verbose_name = _("notification template") + verbose_name_plural = _("notification templates") + + +class ResultNotification(models.Model): + + proposal = models.ForeignKey(ProposalBase, related_name="notifications", verbose_name=_("Proposal")) + template = models.ForeignKey(NotificationTemplate, null=True, blank=True, + on_delete=models.SET_NULL, verbose_name=_("Template")) + timestamp = models.DateTimeField(default=datetime.now, verbose_name=_("Timestamp")) + to_address = models.EmailField(verbose_name=_("To address")) + from_address = models.EmailField(verbose_name=_("From address")) + subject = models.CharField(max_length=255, verbose_name=_("Subject")) + body = models.TextField(verbose_name=_("Body")) + + def recipients(self): + for speaker in self.proposal.speakers(): + yield speaker.email + + def __unicode__(self): + return self.proposal.title + ' ' + self.timestamp.strftime('%Y-%m-%d %H:%M:%S') + + @property + def email_args(self): + return (self.subject, self.body, self.from_address, self.recipients()) + + +def promote_proposal(proposal): + if hasattr(proposal, "presentation") and proposal.presentation: + # already promoted + presentation = proposal.presentation + presentation.title = proposal.title + presentation.abstract = proposal.abstract + presentation.speaker = proposal.speaker + presentation.proposal_base = proposal + presentation.save() + presentation.additional_speakers.clear() + else: + presentation = Presentation( + title=proposal.title, + abstract=proposal.abstract, + speaker=proposal.speaker, + section=proposal.section, + proposal_base=proposal, + ) + presentation.save() + for speaker in proposal.additional_speakers.all(): + presentation.additional_speakers.add(speaker) + presentation.save() + + return presentation + + +def unpromote_proposal(proposal): + if hasattr(proposal, "presentation") and proposal.presentation: + proposal.presentation.delete() + + +def accepted_proposal(sender, instance=None, **kwargs): + if instance is None: + return + if instance.status == "accepted": + promote_proposal(instance.proposal) + else: + unpromote_proposal(instance.proposal) + + +post_save.connect(accepted_proposal, sender=ProposalResult) diff --git a/vendor/symposion/reviews/templatetags/__init__.py b/vendor/symposion/reviews/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/reviews/templatetags/review_tags.py b/vendor/symposion/reviews/templatetags/review_tags.py new file mode 100644 index 00000000..c646ca96 --- /dev/null +++ b/vendor/symposion/reviews/templatetags/review_tags.py @@ -0,0 +1,13 @@ +from django import template + +from symposion.reviews.models import ReviewAssignment + + +register = template.Library() + + +@register.assignment_tag(takes_context=True) +def review_assignments(context): + request = context["request"] + assignments = ReviewAssignment.objects.filter(user=request.user) + return assignments diff --git a/vendor/symposion/reviews/urls.py b/vendor/symposion/reviews/urls.py new file mode 100644 index 00000000..6f8e9c43 --- /dev/null +++ b/vendor/symposion/reviews/urls.py @@ -0,0 +1,44 @@ +from django.conf.urls import url + +from .views import ( + review_section, + review_status, + review_list, + review_admin, + review_bulk_accept, + result_notification, + result_notification_prepare, + result_notification_send, + review_random_proposal, + review_detail, + review_delete, + review_assignments, + review_assignment_opt_out, + review_all_proposals_csv, +) + +urlpatterns = [ + url(r"^section/(?P[\w\-]+)/all/$", review_section, {"reviewed": "all"}, name="review_section"), + url(r"^section/(?P[\w\-]+)/reviewed/$", review_section, {"reviewed": "reviewed"}, name="user_reviewed"), + url(r"^section/(?P[\w\-]+)/not_reviewed/$", review_section, {"reviewed": "not_reviewed"}, name="user_not_reviewed"), + url(r"^section/(?P[\w\-]+)/random/$", review_random_proposal, name="user_random"), + url(r"^section/(?P[\w\-]+)/assignments/$", review_section, {"assigned": True}, name="review_section_assignments"), + url(r"^section/(?P[\w\-]+)/status/$", review_status, name="review_status"), + url(r"^section/(?P[\w\-]+)/status/(?P\w+)/$", review_status, name="review_status"), + url(r"^section/(?P[\w\-]+)/list_reviewer/(?P\d+)/$", review_list, name="review_list_user"), + url(r"^section/(?P[\w\-]+)/admin/$", review_admin, name="review_admin"), + url(r"^section/(?P[\w\-]+)/admin/accept/$", review_bulk_accept, name="review_bulk_accept"), + url(r"^section/(?P[\w\-]+)/notification/(?P\w+)/$", result_notification, name="result_notification"), + url(r"^section/(?P[\w\-]+)/notification/(?P\w+)/prepare/$", result_notification_prepare, name="result_notification_prepare"), + url(r"^section/(?P[\w\-]+)/notification/(?P\w+)/send/$", result_notification_send, name="result_notification_send"), + + + url(r"^review/(?P\d+)/$", review_detail, name="review_detail"), + + url(r"^(?P\d+)/delete/$", review_delete, name="review_delete"), + url(r"^assignments/$", review_assignments, name="review_assignments"), + url(r"^assignment/(?P\d+)/opt-out/$", review_assignment_opt_out, name="review_assignment_opt_out"), + + url(r"^csv$", review_all_proposals_csv, name="review_all_proposals_csv"), + +] diff --git a/vendor/symposion/reviews/utils.py b/vendor/symposion/reviews/utils.py new file mode 100644 index 00000000..80e87e49 --- /dev/null +++ b/vendor/symposion/reviews/utils.py @@ -0,0 +1,19 @@ +def has_permission(user, proposal, speaker=False, reviewer=False): + """ + Returns whether or not ther user has permission to review this proposal, + with the specified requirements. + + If ``speaker`` is ``True`` then the user can be one of the speakers for the + proposal. If ``reviewer`` is ``True`` the speaker can be a part of the + reviewer group. + """ + if user.is_superuser: + return True + if speaker: + if user == proposal.speaker.user or \ + proposal.additional_speakers.filter(user=user).exists(): + return True + if reviewer: + if user.groups.filter(name="reviewers").exists(): + return True + return False diff --git a/vendor/symposion/reviews/views.py b/vendor/symposion/reviews/views.py new file mode 100644 index 00000000..c45dbf4a --- /dev/null +++ b/vendor/symposion/reviews/views.py @@ -0,0 +1,658 @@ +import csv +import random + +from django.contrib.auth.decorators import login_required +from django.core.mail import send_mass_mail +from django.db.models import Q +from django.http import HttpResponse +from django.http import HttpResponseBadRequest, HttpResponseNotAllowed +from django.shortcuts import render, redirect, get_object_or_404 +from django.template import Context, Template +from django.views.decorators.http import require_POST + +# @@@ switch to pinax-teams +from symposion.teams.models import Team + +from symposion.conf import settings +from symposion.proposals.models import ProposalBase, ProposalSection +from symposion.utils.mail import send_email + +from symposion.reviews.forms import ReviewForm, SpeakerCommentForm +from symposion.reviews.forms import BulkPresentationForm +from symposion.reviews.models import ( + ReviewAssignment, Review, LatestVote, ProposalResult, NotificationTemplate, + ResultNotification, promote_proposal +) + + +def access_not_permitted(request): + return render(request, "symposion/reviews/access_not_permitted.html") + + +def proposals_generator(request, queryset, user_pk=None, check_speaker=True): + + for obj in queryset: + # @@@ this sucks; we can do better + if check_speaker: + if request.user in [s.user for s in obj.speakers()]: + continue + + try: + obj.result + except ProposalResult.DoesNotExist: + ProposalResult.objects.get_or_create(proposal=obj) + + obj.comment_count = obj.result.comment_count + obj.score = obj.result.score + obj.total_votes = obj.result.vote_count + obj.plus_two = obj.result.plus_two + obj.plus_one = obj.result.plus_one + obj.minus_one = obj.result.minus_one + obj.minus_two = obj.result.minus_two + lookup_params = dict(proposal=obj) + + if user_pk: + lookup_params["user__pk"] = user_pk + else: + lookup_params["user"] = request.user + + try: + obj.user_vote = LatestVote.objects.get(**lookup_params).vote + obj.user_vote_css = LatestVote.objects.get(**lookup_params).css_class() + except LatestVote.DoesNotExist: + obj.user_vote = None + obj.user_vote_css = "no-vote" + + yield obj + + +VOTE_THRESHOLD = settings.SYMPOSION_VOTE_THRESHOLD + +POSITIVE = "positive" +NEGATIVE = "negative" +INDIFFERENT = "indifferent" +CONTROVERSIAL = "controversial" +TOO_FEW = "too_few" + +REVIEW_STATUS_FILTERS = { + # proposals with at least VOTE_THRESHOLD reviews and at least one +2 and no -2s, sorted by + # the 'score' + POSITIVE: lambda qs: qs.filter(result__vote_count__gte=VOTE_THRESHOLD, result__plus_two__gt=0, + result__minus_two=0).order_by("-result__score"), + # proposals with at least VOTE_THRESHOLD reviews and at least one -2 and no +2s, reverse + # sorted by the 'score' + NEGATIVE: lambda qs: qs.filter(result__vote_count__gte=VOTE_THRESHOLD, result__minus_two__gt=0, + result__plus_two=0).order_by("result__score"), + # proposals with at least VOTE_THRESHOLD reviews and neither a +2 or a -2, sorted by total + # votes (lowest first) + INDIFFERENT: lambda qs: qs.filter(result__vote_count__gte=VOTE_THRESHOLD, result__minus_two=0, + result__plus_two=0).order_by("result__vote_count"), + # proposals with at least VOTE_THRESHOLD reviews and both a +2 and -2, sorted by total + # votes (highest first) + CONTROVERSIAL: lambda qs: qs.filter( + result__vote_count__gte=VOTE_THRESHOLD, result__plus_two__gt=0, + result__minus_two__gt=0).order_by("-result__vote_count"), + # proposals with fewer than VOTE_THRESHOLD reviews + TOO_FEW: lambda qs: qs.filter( + result__vote_count__lt=VOTE_THRESHOLD).order_by("result__vote_count"), +} + + +# Returns a list of all proposals, proposals reviewed by the user, or the proposals the user has +# yet to review depending on the link user clicks in dashboard +@login_required +def review_section(request, section_slug, assigned=False, reviewed="all"): + + if not request.user.has_perm("reviews.can_review_%s" % section_slug): + return access_not_permitted(request) + + section = get_object_or_404(ProposalSection, section__slug=section_slug) + queryset = ProposalBase.objects.filter(kind__section=section.section) + + if assigned: + assignments = ReviewAssignment.objects.filter(user=request.user)\ + .values_list("proposal__id") + queryset = queryset.filter(id__in=assignments) + + # passing reviewed in from reviews.urls and out to review_list for + # appropriate template header rendering + if reviewed == "all": + queryset = queryset.select_related("result").select_subclasses() + reviewed = "all_reviews" + elif reviewed == "reviewed": + queryset = queryset.filter(reviews__user=request.user) + reviewed = "user_reviewed" + else: + queryset = queryset.exclude(reviews__user=request.user).exclude( + speaker__user=request.user) + reviewed = "user_not_reviewed" + + # lca2017 #21 -- chairs want to be able to see their own proposals in the list + check_speaker = not request.user.has_perm("reviews.can_manage_%s" % section_slug) + proposals = proposals_generator(request, queryset, check_speaker=check_speaker) + + ctx = { + "proposals": proposals, + "section": section, + "reviewed": reviewed, + } + + return render(request, "symposion/reviews/review_list.html", ctx) + + +@login_required +def review_all_proposals_csv(request): + ''' Returns a CSV representation of all of the proposals this user has + permisison to review. ''' + + response = HttpResponse("text/csv") + response['Content-Disposition'] = 'attachment; filename="proposals.csv"' + writer = csv.writer(response, quoting=csv.QUOTE_NONNUMERIC) + + queryset = ProposalBase.objects.filter() + + # The fields from each proposal object to report in the csv + fields = [ + "id", "proposal_type", "speaker_name", "speaker_email", "title", + "submitted", "other_speakers", "speaker_travel", + "speaker_accommodation", "cancelled", "status", "score", "total_votes", + "minus_two", "minus_one", "plus_one", "plus_two", + ] + + # Fields are the heading + writer.writerow(fields) + + for proposal in proposals_generator(request, queryset, check_speaker=False): + + proposal.speaker_name = proposal.speaker.name + section_slug = proposal.kind.section.slug + kind_slug = proposal.kind.slug + proposal.proposal_type = kind_slug + + proposal.other_speakers = ", ".join( + speaker.name + for speaker in proposal.additional_speakers.all() + ) + + proposal.speaker_travel = ", ".join( + str(bool(speaker.travel_assistance)) + for speaker in proposal.speakers() + ) + + proposal.speaker_accommodation = ", ".join( + str(bool(speaker.accommodation_assistance)) + for speaker in proposal.speakers() + ) + + if not request.user.has_perm("reviews.can_review_%s" % section_slug): + continue + + csv_line = [getattr(proposal, field) for field in fields] + + writer.writerow(csv_line) + + return response + + +@login_required +def review_random_proposal(request, section_slug): + # lca2017 #16 view for random proposal + + if not request.user.has_perm("reviews.can_review_%s" % section_slug): + return access_not_permitted(request) + + section = get_object_or_404(ProposalSection, section__slug=section_slug) + queryset = ProposalBase.objects.filter(kind__section=section.section) + # Remove ones already reviewed + queryset = queryset.exclude(reviews__user=request.user) + # Remove talks the reviewer can't vote on -- their own. + queryset = queryset.exclude(speaker__user=request.user) + queryset = queryset.exclude(additional_speakers__user=request.user) + + if len(queryset) == 0: + return redirect("review_section", section_slug=section_slug, reviewed="all") + + # Direct reviewers to underreviewed proposals + too_few_set = REVIEW_STATUS_FILTERS[TOO_FEW](queryset) + controversial_set = REVIEW_STATUS_FILTERS[CONTROVERSIAL](queryset) + + if len(too_few_set) > 0: + proposals = too_few_set.all() + elif len(controversial_set) > 0 and random.random() < 0.2: + proposals = controversial_set.all() + else: + # Select a proposal with less than the median number of total votes + proposals = proposals_generator(request, queryset, check_speaker=False) + proposals = list(proposals) + proposals.sort(key=lambda proposal: proposal.total_votes) + # The first half is the median or less. + # The +1 means we round _up_. + proposals = proposals[:(len(proposals) + 1) / 2] + + # Realistically, there shouldn't be all that many proposals to choose + # from, so this should be cheap. + chosen = random.choice(proposals) + return redirect("review_detail", pk=chosen.pk) + + +@login_required +def review_list(request, section_slug, user_pk): + + # if they're not a reviewer admin and they aren't the person whose + # review list is being asked for, don't let them in + if not request.user.has_perm("reviews.can_manage_%s" % section_slug): + if not request.user.pk == user_pk: + return access_not_permitted(request) + + queryset = ProposalBase.objects.select_related("speaker__user", "result") + reviewed = LatestVote.objects.filter(user__pk=user_pk).values_list("proposal", flat=True) + queryset = queryset.filter(kind__section__slug=section_slug) + queryset = queryset.filter(pk__in=reviewed) + proposals = queryset.order_by("submitted") + + admin = request.user.has_perm("reviews.can_manage_%s" % section_slug) + + proposals = proposals_generator(request, proposals, user_pk=user_pk, check_speaker=not admin) + + ctx = { + "proposals": proposals, + } + return render(request, "symposion/reviews/review_list.html", ctx) + + +@login_required +def review_admin(request, section_slug): + + if not request.user.has_perm("reviews.can_manage_%s" % section_slug): + return access_not_permitted(request) + + def reviewers(): + already_seen = set() + + for team in Team.objects.filter(permissions__codename="can_review_%s" % section_slug): + for membership in team.memberships.filter(Q(state="member") | Q(state="manager")): + user = membership.user + if user.pk in already_seen: + continue + already_seen.add(user.pk) + + user.comment_count = Review.objects.filter( + user=user, + proposal__kind__section__slug=section_slug, + ).count() + + user_votes = LatestVote.objects.filter( + user=user, + proposal__kind__section__slug=section_slug, + ) + user.total_votes = user_votes.exclude( + vote=LatestVote.VOTES.ABSTAIN, + ).count() + user.plus_two = user_votes.filter( + vote=LatestVote.VOTES.PLUS_TWO, + ).count() + user.plus_one = user_votes.filter( + vote=LatestVote.VOTES.PLUS_ONE, + ).count() + user.minus_one = user_votes.filter( + vote=LatestVote.VOTES.MINUS_ONE, + ).count() + user.minus_two = user_votes.filter( + vote=LatestVote.VOTES.MINUS_TWO, + ).count() + user.abstain = user_votes.filter( + vote=LatestVote.VOTES.ABSTAIN, + ).count() + if user.total_votes == 0: + user.average = "-" + else: + user.average = ( + ((user.plus_two * 2) + user.plus_one) - + ((user.minus_two * 2) + user.minus_one) + ) / (user.total_votes * 1.0) + + yield user + + reviewers_sorted = list(reviewers()) + reviewers_sorted.sort(key=lambda reviewer: 0 - reviewer.total_votes) + + ctx = { + "section_slug": section_slug, + "reviewers": reviewers_sorted, + } + return render(request, "symposion/reviews/review_admin.html", ctx) + + +# FIXME: This view is too complex according to flake8 +@login_required +def review_detail(request, pk): + + proposals = ProposalBase.objects.select_related("result").select_subclasses() + proposal = get_object_or_404(proposals, pk=pk) + + if not request.user.has_perm("reviews.can_review_%s" % proposal.kind.section.slug): + return access_not_permitted(request) + + speakers = [s.user for s in proposal.speakers()] + + if not request.user.is_superuser and request.user in speakers: + return access_not_permitted(request) + + admin = request.user.has_perm("reviews.can_manage_%s" % proposal.kind.section.slug) + + try: + latest_vote = LatestVote.objects.get(proposal=proposal, user=request.user) + except LatestVote.DoesNotExist: + latest_vote = None + + if request.method == "POST": + if request.user in speakers: + return access_not_permitted(request) + + if "vote_submit" in request.POST or "vote_submit_and_random" in request.POST: + review_form = ReviewForm(request.POST) + if review_form.is_valid(): + + review = review_form.save(commit=False) + review.user = request.user + review.proposal = proposal + review.save() + + if "vote_submit_and_random" in request.POST: + next_page = redirect("user_random", proposal.kind.section.slug) + next_page["Location"] += "#invalid_fragment" # Hack. + else: + next_page = redirect(request.path) + + return next_page + else: + message_form = SpeakerCommentForm() + elif "message_submit" in request.POST and admin: + message_form = SpeakerCommentForm(request.POST) + if message_form.is_valid(): + + message = message_form.save(commit=False) + message.user = request.user + message.proposal = proposal + message.save() + + for speaker in speakers: + if speaker and speaker.email: + ctx = { + "proposal": proposal, + "message": message, + "reviewer": False, + } + send_email( + [speaker.email], "proposal_new_message", + context=ctx + ) + + return redirect(request.path) + else: + initial = {} + if latest_vote: + initial["vote"] = latest_vote.vote + if request.user in speakers: + review_form = None + else: + review_form = ReviewForm(initial=initial) + elif "result_submit" in request.POST: + if admin: + result = request.POST["result_submit"] + + if result == "accept": + proposal.result.status = "accepted" + proposal.result.save() + elif result == "reject": + proposal.result.status = "rejected" + proposal.result.save() + elif result == "undecide": + proposal.result.status = "undecided" + proposal.result.save() + elif result == "standby": + proposal.result.status = "standby" + proposal.result.save() + return redirect(request.path) + elif "publish_changes" in request.POST: + if admin and proposal.result.status == "accepted": + promote_proposal(proposal) + return redirect(request.path) + else: + initial = {} + if latest_vote: + initial["vote"] = latest_vote.vote + if request.user in speakers: + review_form = None + else: + review_form = ReviewForm(initial=initial) + message_form = SpeakerCommentForm() + + proposal.comment_count = proposal.result.comment_count + proposal.total_votes = proposal.result.vote_count + proposal.plus_two = proposal.result.plus_two + proposal.plus_one = proposal.result.plus_one + proposal.minus_one = proposal.result.minus_one + proposal.minus_two = proposal.result.minus_two + + reviews = Review.objects.filter(proposal=proposal).order_by("-submitted_at") + messages = proposal.messages.order_by("submitted_at") + + return render(request, "symposion/reviews/review_detail.html", { + "proposal": proposal, + "latest_vote": latest_vote, + "reviews": reviews, + "review_messages": messages, + "review_form": review_form, + "message_form": message_form, + "is_manager": admin + }) + + +@login_required +@require_POST +def review_delete(request, pk): + review = get_object_or_404(Review, pk=pk) + section_slug = review.section + + if not request.user.has_perm("reviews.can_manage_%s" % section_slug): + return access_not_permitted(request) + + review = get_object_or_404(Review, pk=pk) + review.delete() + + return redirect("review_detail", pk=review.proposal.pk) + + +@login_required +def review_status(request, section_slug=None, key=None): + + if not request.user.has_perm("reviews.can_review_%s" % section_slug): + return access_not_permitted(request) + + ctx = { + "section_slug": section_slug, + "vote_threshold": VOTE_THRESHOLD, + } + + queryset = ProposalBase.objects.select_related("speaker__user", "result").select_subclasses() + if section_slug: + queryset = queryset.filter(kind__section__slug=section_slug) + + proposals = dict((key, filt(queryset)) for key, filt in REVIEW_STATUS_FILTERS.items()) + + admin = request.user.has_perm("reviews.can_manage_%s" % section_slug) + + for status in proposals: + if key and key != status: + continue + proposals[status] = list(proposals_generator(request, proposals[status], check_speaker=not admin)) + + if key: + ctx.update({ + "key": key, + "proposals": proposals[key], + }) + else: + ctx["proposals"] = proposals + + return render(request, "symposion/reviews/review_stats.html", ctx) + + +@login_required +def review_assignments(request): + if not request.user.groups.filter(name="reviewers").exists(): + return access_not_permitted(request) + assignments = ReviewAssignment.objects.filter( + user=request.user, + opted_out=False + ) + return render(request, "symposion/reviews/review_assignment.html", { + "assignments": assignments, + }) + + +@login_required +@require_POST +def review_assignment_opt_out(request, pk): + review_assignment = get_object_or_404( + ReviewAssignment, pk=pk, user=request.user) + if not review_assignment.opted_out: + review_assignment.opted_out = True + review_assignment.save() + ReviewAssignment.create_assignments( + review_assignment.proposal, origin=ReviewAssignment.AUTO_ASSIGNED_LATER) + return redirect("review_assignments") + + +@login_required +def review_bulk_accept(request, section_slug): + if not request.user.has_perm("reviews.can_manage_%s" % section_slug): + return access_not_permitted(request) + if request.method == "POST": + form = BulkPresentationForm(request.POST) + if form.is_valid(): + talk_ids = form.cleaned_data["talk_ids"].split(",") + talks = ProposalBase.objects.filter(id__in=talk_ids).select_related("result") + for talk in talks: + talk.result.status = "accepted" + talk.result.save() + return redirect("review_section", section_slug=section_slug) + else: + form = BulkPresentationForm() + + return render(request, "symposion/reviews/review_bulk_accept.html", { + "form": form, + }) + + +@login_required +def result_notification(request, section_slug, status): + if not request.user.has_perm("reviews.can_manage_%s" % section_slug): + return access_not_permitted(request) + + proposals = ProposalBase.objects.filter(kind__section__slug=section_slug, result__status=status).select_related("speaker__user", "result").select_subclasses() + notification_templates = NotificationTemplate.objects.all() + + ctx = { + "section_slug": section_slug, + "status": status, + "proposals": proposals, + "notification_templates": notification_templates, + } + return render(request, "symposion/reviews/result_notification.html", ctx) + + +@login_required +def result_notification_prepare(request, section_slug, status): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + if not request.user.has_perm("reviews.can_manage_%s" % section_slug): + return access_not_permitted(request) + + proposal_pks = [] + try: + for pk in request.POST.getlist("_selected_action"): + proposal_pks.append(int(pk)) + except ValueError: + return HttpResponseBadRequest() + proposals = ProposalBase.objects.filter( + kind__section__slug=section_slug, + result__status=status, + ) + proposals = proposals.filter(pk__in=proposal_pks) + proposals = proposals.select_related("speaker__user", "result") + proposals = proposals.select_subclasses() + + notification_template_pk = request.POST.get("notification_template", "") + if notification_template_pk: + notification_template = NotificationTemplate.objects.get(pk=notification_template_pk) + else: + notification_template = None + + ctx = { + "section_slug": section_slug, + "status": status, + "notification_template": notification_template, + "proposals": proposals, + "proposal_pks": ",".join([str(pk) for pk in proposal_pks]), + } + return render(request, "symposion/reviews/result_notification_prepare.html", ctx) + + +@login_required +def result_notification_send(request, section_slug, status): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + if not request.user.has_perm("reviews.can_manage_%s" % section_slug): + return access_not_permitted(request) + + if not all([k in request.POST for k in ["proposal_pks", "from_address", "subject", "body"]]): + return HttpResponseBadRequest() + + try: + proposal_pks = [int(pk) for pk in request.POST["proposal_pks"].split(",")] + except ValueError: + return HttpResponseBadRequest() + + proposals = ProposalBase.objects.filter( + kind__section__slug=section_slug, + result__status=status, + ) + proposals = proposals.filter(pk__in=proposal_pks) + proposals = proposals.select_related("speaker__user", "result") + proposals = proposals.select_subclasses() + + notification_template_pk = request.POST.get("notification_template", "") + if notification_template_pk: + notification_template = NotificationTemplate.objects.get(pk=notification_template_pk) + else: + notification_template = None + + emails = [] + + for proposal in proposals: + rn = ResultNotification() + rn.proposal = proposal + rn.template = notification_template + rn.to_address = proposal.speaker_email + rn.from_address = request.POST["from_address"] + proposal_context = proposal.notification_email_context() + rn.subject = Template(request.POST["subject"]).render( + Context({ + "proposal": proposal_context + }) + ) + rn.body = Template(request.POST["body"]).render( + Context({ + "proposal": proposal_context + }) + ) + rn.save() + emails.append(rn.email_args) + + send_mass_mail(emails) + + return redirect("result_notification", section_slug=section_slug, status=status) diff --git a/vendor/symposion/schedule/__init__.py b/vendor/symposion/schedule/__init__.py new file mode 100644 index 00000000..d59b84df --- /dev/null +++ b/vendor/symposion/schedule/__init__.py @@ -0,0 +1 @@ +default_app_config = "symposion.schedule.apps.ScheduleConfig" diff --git a/vendor/symposion/schedule/admin.py b/vendor/symposion/schedule/admin.py new file mode 100644 index 00000000..148b611a --- /dev/null +++ b/vendor/symposion/schedule/admin.py @@ -0,0 +1,57 @@ +from django.contrib import admin + +from symposion.schedule.models import Schedule, Day, Room, SlotKind, Slot, SlotRoom, Presentation, Session, SessionRole, Track + + +class DayInline(admin.StackedInline): + model = Day + extra = 2 + + +class SlotKindInline(admin.StackedInline): + model = SlotKind + + +class ScheduleAdmin(admin.ModelAdmin): + model = Schedule + inlines = [DayInline, SlotKindInline, ] + + +class SlotRoomInline(admin.TabularInline): + model = SlotRoom + extra = 1 + + +class SlotAdmin(admin.ModelAdmin): + list_filter = ("day", "kind") + list_display = ("day", "start", "end", "kind", "content_override") + inlines = [SlotRoomInline] + + +class RoomAdmin(admin.ModelAdmin): + list_display = ["name", "order", "schedule"] + list_filter = ["schedule"] + inlines = [SlotRoomInline] + + +class PresentationAdmin(admin.ModelAdmin): + model = Presentation + list_filter = ("section", "cancelled", "slot") + + +admin.site.register(Day) +admin.site.register( + SlotKind, + list_display=["label", "schedule"], +) +admin.site.register( + SlotRoom, + list_display=["slot", "room"] +) +admin.site.register(Schedule, ScheduleAdmin) +admin.site.register(Room, RoomAdmin) +admin.site.register(Slot, SlotAdmin) +admin.site.register(Session) +admin.site.register(SessionRole) +admin.site.register(Presentation, PresentationAdmin) +admin.site.register(Track) diff --git a/vendor/symposion/schedule/apps.py b/vendor/symposion/schedule/apps.py new file mode 100644 index 00000000..03773bdc --- /dev/null +++ b/vendor/symposion/schedule/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ScheduleConfig(AppConfig): + name = "symposion.schedule" + label = "symposion_schedule" + verbose_name = "Symposion Schedule" diff --git a/vendor/symposion/schedule/forms.py b/vendor/symposion/schedule/forms.py new file mode 100644 index 00000000..fd549cd3 --- /dev/null +++ b/vendor/symposion/schedule/forms.py @@ -0,0 +1,163 @@ +import csv +import time + +from datetime import datetime + +from django import forms +from django.contrib import messages +from django.db import IntegrityError, transaction +from django.db.models import Q + +from symposion.schedule.models import (Day, Presentation, Room, SlotKind, Slot, + SlotRoom) + + +class SlotEditForm(forms.Form): + + required_css_class = 'label-required' + + def __init__(self, *args, **kwargs): + self.slot = kwargs.pop("slot") + super(SlotEditForm, self).__init__(*args, **kwargs) + # @@@ TODO - Make this configurable + if self.slot.kind.label in ["talk", "tutorial", "keynote"]: + self.fields["presentation"] = self.build_presentation_field() + else: + self.fields["content_override"] = self.build_content_override_field() + + def build_presentation_field(self): + kwargs = {} + queryset = Presentation.objects.all() + queryset = queryset.exclude(cancelled=True) + queryset = queryset.order_by("proposal_base__pk") + if self.slot.content: + queryset = queryset.filter(Q(slot=None) | Q(pk=self.slot.content.pk)) + kwargs["required"] = False + kwargs["initial"] = self.slot.content + else: + queryset = queryset.filter(slot=None) + kwargs["required"] = True + kwargs["queryset"] = queryset + return forms.ModelChoiceField(**kwargs) + + def build_content_override_field(self): + kwargs = { + "label": "Content", + "required": False, + "initial": self.slot.content_override, + } + return forms.CharField(**kwargs) + + +class ScheduleSectionForm(forms.Form): + + required_css_class = 'label-required' + + ROOM_KEY = 'room' + DATE_KEY = 'date' + START_KEY = 'time_start' + END_KEY = 'time_end' + KIND = 'kind' + + filename = forms.FileField( + label='Select a CSV file to import:', + required=False + ) + + def __init__(self, *args, **kwargs): + self.schedule = kwargs.pop("schedule") + super(ScheduleSectionForm, self).__init__(*args, **kwargs) + + def clean_filename(self): + if 'submit' in self.data: + fname = self.cleaned_data.get('filename') + if not fname or not fname.name.endswith('.csv'): + raise forms.ValidationError(u'Please upload a .csv file') + return fname + + def _get_start_end_times(self, data): + "Return start and end time objects" + times = [] + for x in [data[self.START_KEY], data[self.END_KEY]]: + try: + time_obj = time.strptime(x, '%I:%M %p') + except: + return messages.ERROR, u'Malformed time found: %s.' % x + time_obj = datetime(100, 1, 1, time_obj.tm_hour, time_obj.tm_min, 00) + times.append(time_obj.time()) + return times + + def _build_rooms(self, data): + "Get or Create Rooms based on schedule type and set of Tracks" + created_rooms = [] + rooms = sorted(set([x[self.ROOM_KEY] for x in data])) + for i, room in enumerate(rooms): + room, created = Room.objects.get_or_create( + schedule=self.schedule, name=room, order=i + ) + if created: + created_rooms.append(room) + return created_rooms + + def _build_days(self, data): + "Get or Create Days based on schedule type and set of Days" + created_days = [] + days = set([x[self.DATE_KEY] for x in data]) + for day in days: + try: + date = datetime.strptime(day, "%m/%d/%Y") + except ValueError: + [x.delete() for x in created_days] + return messages.ERROR, u'Malformed data found: %s.' % day + day, created = Day.objects.get_or_create( + schedule=self.schedule, date=date + ) + if created: + created_days.append(day) + return created_days + + def build_schedule(self): + created_items = [] + reader = csv.DictReader(self.cleaned_data.get('filename')) + data = [dict((k.strip(), v.strip()) for k, v in x.items()) for x in reader] + # build rooms + created_items.extend(self._build_rooms(data)) + # build_days + created_items.extend(self._build_days(data)) + # build Slot -> SlotRoom + for row in data: + room = Room.objects.get( + schedule=self.schedule, name=row[self.ROOM_KEY] + ) + date = datetime.strptime(row[self.DATE_KEY], "%m/%d/%Y") + day = Day.objects.get(schedule=self.schedule, date=date) + start, end = self._get_start_end_times(row) + slot_kind, created = SlotKind.objects.get_or_create( + label=row[self.KIND], schedule=self.schedule + ) + if created: + created_items.append(slot_kind) + if row[self.KIND] == 'plenary': + slot, created = Slot.objects.get_or_create( + kind=slot_kind, day=day, start=start, end=end + ) + if created: + created_items.append(slot) + else: + slot = Slot.objects.create( + kind=slot_kind, day=day, start=start, end=end + ) + created_items.append(slot) + try: + with transaction.atomic(): + SlotRoom.objects.create(slot=slot, room=room) + except IntegrityError: + # delete all created objects and report error + for x in created_items: + x.delete() + return messages.ERROR, u'An overlap occurred; the import was cancelled.' + return messages.SUCCESS, u'Your schedule has been imported.' + + def delete_schedule(self): + self.schedule.day_set.all().delete() + return messages.SUCCESS, u'Your schedule has been deleted.' diff --git a/vendor/symposion/schedule/helpers.py b/vendor/symposion/schedule/helpers.py new file mode 100644 index 00000000..02e964a7 --- /dev/null +++ b/vendor/symposion/schedule/helpers.py @@ -0,0 +1,30 @@ +""" +This file contains functions that are useful to humans at the shell for +manipulating the database in more natural ways. +""" +from django.db import transaction + +from .models import Schedule, Day, Room, Slot, SlotKind, SlotRoom + + +@transaction.commit_on_success +def create_slot(section_slug, date, kind, start, end, rooms): + schedule = Schedule.objects.get(section__slug=section_slug) + slot = Slot() + slot.day = Day.objects.get(schedule=schedule, date=date) + slot.kind = SlotKind.objects.get(schedule=schedule, label=kind) + slot.start = start + slot.end = end + slot.save(force_insert=True) + if rooms == "all": + rooms_qs = Room.objects.filter(schedule=schedule).order_by("order") + else: + rooms_qs = Room.objects.filter(schedule=schedule, name__in=rooms).order_by("order") + if rooms_qs.count() != len(rooms): + raise Exception("input rooms do not match queried rooms; typo?") + for room in rooms_qs: + slot_room = SlotRoom() + slot_room.slot = slot + slot_room.room = room + slot_room.save(force_insert=True) + print("created {} [start={}; end={}]".format(slot.kind.label, slot.start, slot.end)) diff --git a/vendor/symposion/schedule/migrations/0001_initial.py b/vendor/symposion/schedule/migrations/0001_initial.py new file mode 100644 index 00000000..fb2c3629 --- /dev/null +++ b/vendor/symposion/schedule/migrations/0001_initial.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-09-17 03:35 +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('symposion_proposals', '__first__'), + ('symposion_speakers', '__first__'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('symposion_conference', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Day', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(verbose_name='Date')), + ], + options={ + 'ordering': ['date'], + 'verbose_name': 'date', + 'verbose_name_plural': 'dates', + }, + ), + migrations.CreateModel( + name='Presentation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='Title')), + ('abstract', models.TextField(verbose_name='Abstract')), + ('abstract_html', models.TextField(blank=True)), + ('cancelled', models.BooleanField(default=False, verbose_name='Cancelled')), + ('additional_speakers', models.ManyToManyField(blank=True, related_name='copresentations', to='symposion_speakers.Speaker', verbose_name='Additional speakers')), + ('proposal_base', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presentation', to='symposion_proposals.ProposalBase', verbose_name='Proposal base')), + ('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='presentations', to='symposion_conference.Section', verbose_name='Section')), + ], + options={ + 'ordering': ['slot'], + 'verbose_name': 'presentation', + 'verbose_name_plural': 'presentations', + }, + ), + migrations.CreateModel( + name='Room', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=65, verbose_name='Name')), + ('order', models.PositiveIntegerField(verbose_name='Order')), + ], + options={ + 'verbose_name': 'Room', + 'verbose_name_plural': 'Rooms', + }, + ), + migrations.CreateModel( + name='Schedule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('published', models.BooleanField(default=True, verbose_name='Published')), + ('hidden', models.BooleanField(default=False, verbose_name='Hide schedule from overall conference view')), + ('section', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='symposion_conference.Section', verbose_name='Section')), + ], + options={ + 'ordering': ['section'], + 'verbose_name': 'Schedule', + 'verbose_name_plural': 'Schedules', + }, + ), + migrations.CreateModel( + name='Session', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('day', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='symposion_schedule.Day', verbose_name='Day')), + ], + options={ + 'verbose_name': 'Session', + 'verbose_name_plural': 'Sessions', + }, + ), + migrations.CreateModel( + name='SessionRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.IntegerField(choices=[(1, 'Session Chair'), (2, 'Session Runner')], verbose_name='Role')), + ('status', models.NullBooleanField(verbose_name='Status')), + ('submitted', models.DateTimeField(default=datetime.datetime.now)), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Session', verbose_name='Session')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Session role', + 'verbose_name_plural': 'Session roles', + }, + ), + migrations.CreateModel( + name='Slot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(editable=False, max_length=100)), + ('start', models.TimeField(verbose_name='Start')), + ('end', models.TimeField(verbose_name='End')), + ('content_override', models.TextField(blank=True, verbose_name='Content override')), + ('content_override_html', models.TextField(blank=True)), + ('day', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Day', verbose_name='Day')), + ], + options={ + 'ordering': ['day', 'start', 'end'], + 'verbose_name': 'slot', + 'verbose_name_plural': 'slots', + }, + ), + migrations.CreateModel( + name='SlotKind', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=50, verbose_name='Label')), + ('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Schedule', verbose_name='schedule')), + ], + options={ + 'verbose_name': 'Slot kind', + 'verbose_name_plural': 'Slot kinds', + }, + ), + migrations.CreateModel( + name='SlotRoom', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Room', verbose_name='Room')), + ('slot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Slot', verbose_name='Slot')), + ], + options={ + 'ordering': ['slot', 'room__order'], + 'verbose_name': 'Slot room', + 'verbose_name_plural': 'Slot rooms', + }, + ), + migrations.AddField( + model_name='slot', + name='kind', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.SlotKind', verbose_name='Kind'), + ), + migrations.AddField( + model_name='session', + name='slots', + field=models.ManyToManyField(related_name='sessions', to='symposion_schedule.Slot', verbose_name='Slots'), + ), + migrations.AddField( + model_name='room', + name='schedule', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Schedule', verbose_name='Schedule'), + ), + migrations.AddField( + model_name='presentation', + name='slot', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content_ptr', to='symposion_schedule.Slot', verbose_name='Slot'), + ), + migrations.AddField( + model_name='presentation', + name='speaker', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='presentations', to='symposion_speakers.Speaker', verbose_name='Speaker'), + ), + migrations.AddField( + model_name='day', + name='schedule', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Schedule', verbose_name='Schedule'), + ), + migrations.AlterUniqueTogether( + name='slotroom', + unique_together=set([('slot', 'room')]), + ), + migrations.AlterUniqueTogether( + name='sessionrole', + unique_together=set([('session', 'user', 'role')]), + ), + migrations.AlterUniqueTogether( + name='day', + unique_together=set([('schedule', 'date')]), + ), + ] diff --git a/vendor/symposion/schedule/migrations/0002_presentation_unpublish.py b/vendor/symposion/schedule/migrations/0002_presentation_unpublish.py new file mode 100644 index 00000000..fdf576b7 --- /dev/null +++ b/vendor/symposion/schedule/migrations/0002_presentation_unpublish.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-09-18 00:43 +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_schedule', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='presentation', + name='unpublish', + field=models.BooleanField(default=False, verbose_name='Do not publish'), + ), + ] diff --git a/vendor/symposion/schedule/migrations/0003_auto_20161113_1530.py b/vendor/symposion/schedule/migrations/0003_auto_20161113_1530.py new file mode 100644 index 00000000..9277148f --- /dev/null +++ b/vendor/symposion/schedule/migrations/0003_auto_20161113_1530.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-11-13 04:30 +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_schedule', '0002_presentation_unpublish'), + ] + + operations = [ + migrations.AlterField( + model_name='slot', + name='name', + field=models.CharField(editable=False, max_length=150), + ), + ] diff --git a/vendor/symposion/schedule/migrations/0003_slot_exclusive.py b/vendor/symposion/schedule/migrations/0003_slot_exclusive.py new file mode 100644 index 00000000..359376d3 --- /dev/null +++ b/vendor/symposion/schedule/migrations/0003_slot_exclusive.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-12-09 20:53 +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_schedule', '0002_presentation_unpublish'), + ] + + operations = [ + migrations.AddField( + model_name='slot', + name='exclusive', + field=models.BooleanField(default=False, help_text='Set to true if this is the only event during this timeslot'), + ), + ] diff --git a/vendor/symposion/schedule/migrations/0004_merge.py b/vendor/symposion/schedule/migrations/0004_merge.py new file mode 100644 index 00000000..36096261 --- /dev/null +++ b/vendor/symposion/schedule/migrations/0004_merge.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-12-10 06:05 +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_schedule', '0003_slot_exclusive'), + ('symposion_schedule', '0003_auto_20161113_1530'), + ] + + operations = [ + ] diff --git a/vendor/symposion/schedule/migrations/0005_auto_20161210_1736.py b/vendor/symposion/schedule/migrations/0005_auto_20161210_1736.py new file mode 100644 index 00000000..f05a41d0 --- /dev/null +++ b/vendor/symposion/schedule/migrations/0005_auto_20161210_1736.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-12-10 06:36 +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_schedule', '0004_merge'), + ] + + operations = [ + migrations.AlterField( + model_name='slot', + name='name', + field=models.CharField(editable=False, max_length=512), + ), + ] diff --git a/vendor/symposion/schedule/migrations/0006_room_track.py b/vendor/symposion/schedule/migrations/0006_room_track.py new file mode 100644 index 00000000..67a70b08 --- /dev/null +++ b/vendor/symposion/schedule/migrations/0006_room_track.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-12-24 00:10 +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_schedule', '0005_auto_20161210_1736'), + ] + + operations = [ + migrations.AddField( + model_name='room', + name='track', + field=models.CharField(blank=True, default=None, max_length=80, null=True, verbose_name='Track'), + ), + ] diff --git a/vendor/symposion/schedule/migrations/0007_auto_20161224_1709.py b/vendor/symposion/schedule/migrations/0007_auto_20161224_1709.py new file mode 100644 index 00000000..d6eeb236 --- /dev/null +++ b/vendor/symposion/schedule/migrations/0007_auto_20161224_1709.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-12-24 06:09 +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_schedule', '0006_room_track'), + ] + + operations = [ + migrations.CreateModel( + name='Track', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=80, verbose_name='Track')), + ('day', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Day')), + ], + options={ + 'verbose_name': 'Track', + 'verbose_name_plural': 'Tracks', + }, + ), + migrations.RemoveField( + model_name='room', + name='track', + ), + migrations.AddField( + model_name='track', + name='room', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_schedule.Room'), + ), + migrations.AlterUniqueTogether( + name='track', + unique_together=set([('room', 'day')]), + ), + ] diff --git a/vendor/symposion/schedule/migrations/__init__.py b/vendor/symposion/schedule/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/schedule/models.py b/vendor/symposion/schedule/models.py new file mode 100644 index 00000000..9c738c20 --- /dev/null +++ b/vendor/symposion/schedule/models.py @@ -0,0 +1,307 @@ +import datetime + +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth.models import User +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from symposion.text_parser import parse +from symposion.proposals.models import ProposalBase +from symposion.conference.models import Section +from symposion.speakers.models import Speaker + + +class Schedule(models.Model): + + section = models.OneToOneField(Section, verbose_name=_("Section")) + published = models.BooleanField(default=True, verbose_name=_("Published")) + hidden = models.BooleanField(_("Hide schedule from overall conference view"), default=False) + + def __str__(self): + return "%s Schedule" % self.section + + class Meta: + ordering = ["section"] + verbose_name = _('Schedule') + verbose_name_plural = _('Schedules') + + +class Day(models.Model): + + schedule = models.ForeignKey(Schedule, verbose_name=_("Schedule")) + date = models.DateField(verbose_name=_("Date")) + + def __str__(self): + return "%s" % self.date + + class Meta: + unique_together = [("schedule", "date")] + ordering = ["date"] + verbose_name = _("date") + verbose_name_plural = _("dates") + + +class Room(models.Model): + + schedule = models.ForeignKey(Schedule, verbose_name=_("Schedule")) + name = models.CharField(max_length=65, verbose_name=_("Name")) + order = models.PositiveIntegerField(verbose_name=_("Order")) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("Room") + verbose_name_plural = _("Rooms") + + +class Track(models.Model): + name = models.CharField(max_length=80, verbose_name=_("Track")) + room = models.ForeignKey(Room) + day = models.ForeignKey(Day) + + def __str__(self): + return self.name + + class Meta: + unique_together = [('room', 'day')] + verbose_name = _("Track") + verbose_name_plural = _("Tracks") + + +class SlotKind(models.Model): + """ + A slot kind represents what kind a slot is. For example, a slot can be a + break, lunch, or X-minute talk. + """ + + schedule = models.ForeignKey(Schedule, verbose_name=_("schedule")) + label = models.CharField(max_length=50, verbose_name=_("Label")) + + def __str__(self): + return self.label + + class Meta: + verbose_name = _("Slot kind") + verbose_name_plural = _("Slot kinds") + + +class Slot(models.Model): + + name = models.CharField(max_length=512, editable=False) + day = models.ForeignKey(Day, verbose_name=_("Day")) + kind = models.ForeignKey(SlotKind, verbose_name=_("Kind")) + start = models.TimeField(verbose_name=_("Start")) + end = models.TimeField(verbose_name=_("End")) + exclusive = models.BooleanField( + default=False, + help_text=_("Set to true if this is the only event during this " + "timeslot"), + ) + content_override = models.TextField(blank=True, verbose_name=_("Content override")) + content_override_html = models.TextField(blank=True) + + def assign(self, content): + """ + Assign the given content to this slot and if a previous slot content + was given we need to unlink it to avoid integrity errors. + """ + self.unassign() + content.slot = self + content.save() + + def unassign(self): + """ + Unassign the associated content with this slot. + """ + content = self.content + if content and content.slot_id: + content.slot = None + content.save() + + @property + def content(self): + """ + Return the content this slot represents. + @@@ hard-coded for presentation for now + """ + try: + return self.content_ptr + except ObjectDoesNotExist: + return None + + @property + def start_datetime(self): + return datetime.datetime( + self.day.date.year, + self.day.date.month, + self.day.date.day, + self.start.hour, + self.start.minute) + + @property + def end_datetime(self): + return datetime.datetime( + self.day.date.year, + self.day.date.month, + self.day.date.day, + self.end.hour, + self.end.minute) + + @property + def length_in_minutes(self): + return int( + (self.end_datetime - self.start_datetime).total_seconds() / 60) + + @property + def rooms(self): + return Room.objects.filter(pk__in=self.slotroom_set.values("room")) + + def save(self, *args, **kwargs): + roomlist = ' '.join(map(lambda r: r.__unicode__(), self.rooms)) + self.name = "%s %s (%s - %s) %s" % (self.day, self.kind, self.start, self.end, roomlist) + self.content_override_html = parse(self.content_override) + super(Slot, self).save(*args, **kwargs) + + def __str__(self): + return self.name + + class Meta: + ordering = ["day", "start", "end"] + verbose_name = _("slot") + verbose_name_plural = _("slots") + + +class SlotRoom(models.Model): + """ + Links a slot with a room. + """ + + slot = models.ForeignKey(Slot, verbose_name=_("Slot")) + room = models.ForeignKey(Room, verbose_name=_("Room")) + + def __str__(self): + return "%s %s" % (self.room, self.slot) + + class Meta: + unique_together = [("slot", "room")] + ordering = ["slot", "room__order"] + verbose_name = _("Slot room") + verbose_name_plural = _("Slot rooms") + + +class Presentation(models.Model): + + slot = models.OneToOneField(Slot, null=True, blank=True, related_name="content_ptr", verbose_name=_("Slot")) + title = models.CharField(max_length=100, verbose_name=_("Title")) + abstract = models.TextField(verbose_name=_("Abstract")) + abstract_html = models.TextField(blank=True) + speaker = models.ForeignKey(Speaker, related_name="presentations", verbose_name=_("Speaker")) + additional_speakers = models.ManyToManyField(Speaker, related_name="copresentations", + blank=True, verbose_name=_("Additional speakers")) + unpublish = models.BooleanField( + default=False, + verbose_name=_("Do not publish"), + ) + cancelled = models.BooleanField(default=False, verbose_name=_("Cancelled")) + proposal_base = models.OneToOneField(ProposalBase, related_name="presentation", verbose_name=_("Proposal base")) + section = models.ForeignKey(Section, related_name="presentations", verbose_name=_("Section")) + + def save(self, *args, **kwargs): + self.abstract_html = parse(self.abstract) + return super(Presentation, self).save(*args, **kwargs) + + @property + def number(self): + return self.proposal.number + + @property + def proposal(self): + if self.proposal_base_id is None: + return None + return ProposalBase.objects.get_subclass(pk=self.proposal_base_id) + + def speakers(self): + yield self.speaker + for speaker in self.additional_speakers.all(): + if speaker.user: + yield speaker + + def __str__(self): + return "#%s %s (%s)" % (self.number, self.title, self.speaker) + + class Meta: + ordering = ["slot"] + verbose_name = _("presentation") + verbose_name_plural = _("presentations") + + +class Session(models.Model): + + day = models.ForeignKey(Day, related_name="sessions", verbose_name=_("Day")) + slots = models.ManyToManyField(Slot, related_name="sessions", verbose_name=_("Slots")) + + def sorted_slots(self): + return self.slots.order_by("start") + + def start(self): + slots = self.sorted_slots() + if slots: + return list(slots)[0].start + else: + return None + + def end(self): + slots = self.sorted_slots() + if slots: + return list(slots)[-1].end + else: + return None + + def chair(self): + for role in self.sessionrole_set.all(): + if role.role == SessionRole.SESSION_ROLE_CHAIR: + return role + return None + + def __str__(self): + start = self.start() + end = self.end() + if start and end: + return "%s: %s - %s" % ( + self.day.date.strftime("%a"), + start.strftime("%X"), + end.strftime("%X") + ) + return "" + + class Meta: + verbose_name = _("Session") + verbose_name_plural = _("Sessions") + + +class SessionRole(models.Model): + + SESSION_ROLE_CHAIR = 1 + SESSION_ROLE_RUNNER = 2 + + SESSION_ROLE_TYPES = [ + (SESSION_ROLE_CHAIR, _("Session Chair")), + (SESSION_ROLE_RUNNER, _("Session Runner")), + ] + + session = models.ForeignKey(Session, verbose_name=_("Session")) + user = models.ForeignKey(User, verbose_name=_("User")) + role = models.IntegerField(choices=SESSION_ROLE_TYPES, verbose_name=_("Role")) + status = models.NullBooleanField(verbose_name=_("Status")) + + submitted = models.DateTimeField(default=datetime.datetime.now) + + class Meta: + unique_together = [("session", "user", "role")] + verbose_name = _("Session role") + verbose_name_plural = _("Session roles") + + def __str__(self): + return "%s %s: %s" % (self.user, self.session, + self.SESSION_ROLE_TYPES[self.role - 1][1]) diff --git a/vendor/symposion/schedule/tests/__init__.py b/vendor/symposion/schedule/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/schedule/tests/data/schedule.csv b/vendor/symposion/schedule/tests/data/schedule.csv new file mode 100644 index 00000000..66a87bdc --- /dev/null +++ b/vendor/symposion/schedule/tests/data/schedule.csv @@ -0,0 +1,13 @@ +"date","time_start","time_end","kind"," room " +"12/12/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/12/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room2" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room2" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room2" diff --git a/vendor/symposion/schedule/tests/data/schedule_overlap.csv b/vendor/symposion/schedule/tests/data/schedule_overlap.csv new file mode 100644 index 00000000..3c02ce2b --- /dev/null +++ b/vendor/symposion/schedule/tests/data/schedule_overlap.csv @@ -0,0 +1,14 @@ +"date","time_start","time_end","kind"," room " +"12/12/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/12/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room1" +"12/12/2013","11:00 AM","12:00 PM","talk","Room2" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/12/2013","12:00 PM","12:45 PM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room2" +"12/13/2013","10:00 AM","11:00 AM","plenary","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room1" +"12/13/2013","11:00 AM","12:00 PM","talk","Room2" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room1" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room2" +"12/13/2013","12:00 PM","12:45 PM","plenary","Room2" diff --git a/vendor/symposion/schedule/tests/factories.py b/vendor/symposion/schedule/tests/factories.py new file mode 100644 index 00000000..6e24846b --- /dev/null +++ b/vendor/symposion/schedule/tests/factories.py @@ -0,0 +1,65 @@ +import datetime +import random + +import factory + +from factory import fuzzy + +from symposion.schedule.models import Schedule, Day, Slot, SlotKind +from symposion.conference.models import Section, Conference + + +class ConferenceFactory(factory.DjangoModelFactory): + title = fuzzy.FuzzyText() + start_date = fuzzy.FuzzyDate(datetime.date(2014, 1, 1)) + end_date = fuzzy.FuzzyDate( + datetime.date(2014, 1, 1) + datetime.timedelta(days=random.randint(1, 10)) + ) + # timezone = TimeZoneField("UTC") + + class Meta: + model = Conference + + +class SectionFactory(factory.DjangoModelFactory): + conference = factory.SubFactory(ConferenceFactory) + name = fuzzy.FuzzyText() + slug = fuzzy.FuzzyText() + + class Meta: + model = Section + + +class ScheduleFactory(factory.DjangoModelFactory): + section = factory.SubFactory(SectionFactory) + published = True + hidden = False + + class Meta: + model = Schedule + + +class SlotKindFactory(factory.DjangoModelFactory): + schedule = factory.SubFactory(ScheduleFactory) + label = fuzzy.FuzzyText() + + class Meta: + model = SlotKind + + +class DayFactory(factory.DjangoModelFactory): + schedule = factory.SubFactory(ScheduleFactory) + date = fuzzy.FuzzyDate(datetime.date(2014, 1, 1)) + + class Meta: + model = Day + + +class SlotFactory(factory.DjangoModelFactory): + day = factory.SubFactory(DayFactory) + kind = factory.SubFactory(SlotKindFactory) + start = datetime.time(random.randint(0, 23), random.randint(0, 59)) + end = datetime.time(random.randint(0, 23), random.randint(0, 59)) + + class Meta: + model = Slot diff --git a/vendor/symposion/schedule/tests/runtests.py b/vendor/symposion/schedule/tests/runtests.py new file mode 100755 index 00000000..b7ef4b1d --- /dev/null +++ b/vendor/symposion/schedule/tests/runtests.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# see runtests.py in https://github.com/pydanny/cookiecutter-djangopackage + +import sys + +try: + from django.conf import settings + + settings.configure( + DEBUG=True, + USE_TZ=True, + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + } + }, + ROOT_URLCONF="symposion.schedule.urls", + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sites", + + "reversion", + + "symposion", + "symposion.conference", + "symposion.speakers", + "symposion.schedule", + "symposion.proposals", + + ], + SITE_ID=1, + NOSE_ARGS=['-s'], + ) + + try: + import django + setup = django.setup + except AttributeError: + pass + else: + setup() + + from django_nose import NoseTestSuiteRunner +except ImportError: + raise ImportError("To fix this error, run: pip install -r requirements-test.txt") + + +def run_tests(*test_args): + if not test_args: + test_args = ['tests'] + + # Run tests + test_runner = NoseTestSuiteRunner(verbosity=1) + + failures = test_runner.run_tests(test_args) + + if failures: + sys.exit(failures) + + +if __name__ == '__main__': + run_tests("symposion.schedule.tests.test_views") diff --git a/vendor/symposion/schedule/tests/test_forms.py b/vendor/symposion/schedule/tests/test_forms.py new file mode 100644 index 00000000..883d7ac0 --- /dev/null +++ b/vendor/symposion/schedule/tests/test_forms.py @@ -0,0 +1,156 @@ +import os + +from datetime import datetime, timedelta + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase + +from symposion.conference.models import Conference, Section + +from ..forms import ScheduleSectionForm +from ..models import Day, Room, Schedule, Slot, SlotKind + +DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') + + +class ScheduleSectionFormTests(TestCase): + + def setUp(self): + self.conference = Conference.objects.create(title='test') + self.section = Section.objects.create( + conference=self.conference, + name='test') + self.schedule = Schedule.objects.create(section=self.section) + self.today = datetime.now() + self.tomorrow = self.today + timedelta(days=1) + + def test_clean_filename(self): + """Ensure a file is provided if the submit action was utilized""" + data = {'submit': 'Submit'} + form = ScheduleSectionForm(data=data, schedule=self.schedule) + self.assertIn('filename', form.errors) + + def test_clean_filename_not_required(self): + """Ensure file is not required if the delete action was utilize""" + data = {'delete': 'Delete'} + form = ScheduleSectionForm(data=data, schedule=self.schedule) + self.assertTrue(form.is_valid()) + + def test_delete(self): + """Delete schedule (Days) for supplied section""" + Day.objects.create(schedule=self.schedule, date=self.today) + Day.objects.create(schedule=self.schedule, date=self.tomorrow) + other_section = Section.objects.create(conference=self.conference, name='other') + other_schedule = Schedule.objects.create(section=other_section) + other_day = Day.objects.create(schedule=other_schedule, date=self.tomorrow) + self.assertEqual(3, Day.objects.all().count()) + data = {'delete': 'Delete'} + form = ScheduleSectionForm(data=data, schedule=self.schedule) + form.delete_schedule() + days = Day.objects.all() + self.assertEqual(1, days.count()) + self.assertIn(other_day, days) + + def test_build_days(self): + """Test private method to build days based off ingested CSV""" + form = ScheduleSectionForm(schedule=self.schedule) + data = ( + {'date': datetime.strftime(self.today, "%m/%d/%Y")}, + {'date': datetime.strftime(self.today, "%m/%d/%Y")}, + {'date': datetime.strftime(self.tomorrow, "%m/%d/%Y")}, + ) + self.assertEqual(0, Day.objects.all().count()) + form._build_days(data) + self.assertEqual(2, Day.objects.all().count()) + + def test_build_days_malformed(self): + """Test failure for malformed date in CSV""" + form = ScheduleSectionForm(schedule=self.schedule) + data = ( + {'date': datetime.strftime(self.today, "%m/%d/%Y")}, + {'date': '12-12-12'} + ) + self.assertEqual(0, Day.objects.all().count()) + msg_type, msg = form._build_days(data) + self.assertEqual(0, Day.objects.all().count()) + self.assertEqual(40, msg_type) + self.assertIn('12-12-12', msg) + + def test_build_rooms(self): + """Test private method to build rooms based off ingested CSV""" + form = ScheduleSectionForm(schedule=self.schedule) + data = ( + {'room': 'foo'}, + {'room': 'bar'}, + {'room': 'foo'}, + ) + self.assertEqual(0, Room.objects.all().count()) + form._build_rooms(data) + self.assertEqual(2, Room.objects.all().count()) + + def test_get_start_end_times(self): + """ + Test private method to convert start and end times based off + ingested CSV + """ + form = ScheduleSectionForm(schedule=self.schedule) + start = '12:00 PM' + end = '01:00 PM' + data = {'time_start': start, 'time_end': end} + start_time, end_time = form._get_start_end_times(data) + self.assertEqual(start, start_time.strftime('%I:%M %p')) + self.assertEqual(end, end_time.strftime('%I:%M %p')) + + def test_get_start_end_times_malformed(self): + """ + Test private method for malformed time based off ingested CSV + """ + form = ScheduleSectionForm(schedule=self.schedule) + start = '12:00' + end = '01:00' + data = {'time_start': start, 'time_end': end} + msg_type, msg = form._get_start_end_times(data) + self.assertEqual(40, msg_type) + self.assertIn('Malformed', msg) + + def test_build_schedule(self): + """ + Test successful schedule build based off ingested CSV + """ + self.assertEqual(0, Day.objects.all().count()) + self.assertEqual(0, Room.objects.all().count()) + self.assertEqual(0, Slot.objects.all().count()) + self.assertEqual(0, SlotKind.objects.all().count()) + schedule_csv = open(os.path.join(DATA_DIR, 'schedule.csv'), 'rb') + file_data = {'filename': SimpleUploadedFile(schedule_csv.name, schedule_csv.read())} + data = {'submit': 'Submit'} + form = ScheduleSectionForm(data, file_data, schedule=self.schedule) + form.is_valid() + msg_type, msg = form.build_schedule() + self.assertEqual(25, msg_type) + self.assertIn('imported', msg) + self.assertEqual(2, Day.objects.all().count()) + self.assertEqual(2, Room.objects.all().count()) + self.assertEqual(8, Slot.objects.all().count()) + self.assertEqual(2, SlotKind.objects.all().count()) + + def test_build_schedule_overlap(self): + """ + Test rolledback schedule build based off ingested CSV with Slot overlap + """ + self.assertEqual(0, Day.objects.all().count()) + self.assertEqual(0, Room.objects.all().count()) + self.assertEqual(0, Slot.objects.all().count()) + self.assertEqual(0, SlotKind.objects.all().count()) + schedule_csv = open(os.path.join(DATA_DIR, 'schedule_overlap.csv'), 'rb') + file_data = {'filename': SimpleUploadedFile(schedule_csv.name, schedule_csv.read())} + data = {'submit': 'Submit'} + form = ScheduleSectionForm(data, file_data, schedule=self.schedule) + form.is_valid() + msg_type, msg = form.build_schedule() + self.assertEqual(40, msg_type) + self.assertIn('overlap', msg) + self.assertEqual(0, Day.objects.all().count()) + self.assertEqual(0, Room.objects.all().count()) + self.assertEqual(0, Slot.objects.all().count()) + self.assertEqual(0, SlotKind.objects.all().count()) diff --git a/vendor/symposion/schedule/tests/test_views.py b/vendor/symposion/schedule/tests/test_views.py new file mode 100644 index 00000000..3bc2606b --- /dev/null +++ b/vendor/symposion/schedule/tests/test_views.py @@ -0,0 +1,30 @@ +import json + +from django.test.client import Client +from django.test import TestCase + +from . import factories + + +class ScheduleViewTests(TestCase): + + def test_empty_json(self): + c = Client() + r = c.get('/conference.json') + assert r.status_code == 200 + + conference = json.loads(r.content) + assert 'schedule' in conference + assert len(conference['schedule']) == 0 + + def test_populated_empty_presentations(self): + + factories.SlotFactory.create_batch(size=5) + + c = Client() + r = c.get('/conference.json') + assert r.status_code == 200 + + conference = json.loads(r.content) + assert 'schedule' in conference + assert len(conference['schedule']) == 5 diff --git a/vendor/symposion/schedule/tests/test_views_session.py b/vendor/symposion/schedule/tests/test_views_session.py new file mode 100644 index 00000000..02c2f180 --- /dev/null +++ b/vendor/symposion/schedule/tests/test_views_session.py @@ -0,0 +1,58 @@ +from datetime import date + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.test import TestCase + +from symposion.conference.models import Section, current_conference, Conference +from symposion.schedule.models import Day, Schedule, Session + + +class TestScheduleViews(TestCase): + username = "user@example.com" + first_name = "Sam" + last_name = "McGillicuddy" + + def setUp(self): + self.user = User.objects.create_user(self.username, + password="pass", + email=self.username) + self.user.first_name = self.first_name + self.user.last_name = self.last_name + self.user.save() + + def test_session_list(self): + # Really minimal test for session list + rsp = self.client.get(reverse("schedule_session_list")) + self.assertEqual(200, rsp.status_code) + + def test_session_staff_email(self): + # login and staff required + self.user.is_staff = True + self.user.save() + assert self.client.login(username=self.username, password="pass") + + url = reverse("schedule_session_staff_email") + rsp = self.client.get(url) + self.assertEqual(200, rsp.status_code) + + def test_session_detail(self): + # really minimal test + Conference.objects.get_or_create(id=settings.CONFERENCE_ID) + section = Section.objects.create( + conference=current_conference(), + ) + schedule = Schedule.objects.create( + section=section, + ) + day = Day.objects.create( + schedule=schedule, + date=date.today(), + ) + session = Session.objects.create( + day=day, + ) + url = reverse("schedule_session_detail", args=(session.pk,)) + rsp = self.client.get(url) + self.assertEqual(200, rsp.status_code) diff --git a/vendor/symposion/schedule/timetable.py b/vendor/symposion/schedule/timetable.py new file mode 100644 index 00000000..7ce03460 --- /dev/null +++ b/vendor/symposion/schedule/timetable.py @@ -0,0 +1,51 @@ +import itertools + +from django.db.models import Count, Min + +from symposion.schedule.models import Room, Slot, SlotRoom + + +class TimeTable(object): + + def __init__(self, day): + self.day = day + + def slots_qs(self): + qs = Slot.objects.all() + qs = qs.filter(day=self.day) + return qs + + def rooms(self): + qs = Room.objects.all() + qs = qs.filter(schedule=self.day.schedule) + qs = qs.filter( + pk__in=SlotRoom.objects.filter(slot__in=self.slots_qs().values("pk")).values("room")) + qs = qs.order_by("order") + return qs + + def __iter__(self): + times = sorted(set(itertools.chain(*self.slots_qs().values_list("start", "end")))) + slots = Slot.objects.filter(pk__in=self.slots_qs().values("pk")) + slots = slots.annotate(room_count=Count("slotroom"), order=Min("slotroom__room__order")) + slots = slots.order_by("start", "order") + row = [] + total_room_count = self.rooms().count() + for time, next_time in pairwise(times): + row = {"time": time, "slots": []} + for slot in slots: + if slot.start == time: + slot.rowspan = TimeTable.rowspan(times, slot.start, slot.end) + slot.colspan = slot.room_count if not slot.exclusive else total_room_count + row["slots"].append(slot) + if row["slots"] or next_time is None: + yield row + + @staticmethod + def rowspan(times, start, end): + return times.index(end) - times.index(start) + + +def pairwise(iterable): + a, b = itertools.tee(iterable) + b.next() + return itertools.izip_longest(a, b) diff --git a/vendor/symposion/schedule/urls.py b/vendor/symposion/schedule/urls.py new file mode 100644 index 00000000..1b8b5eee --- /dev/null +++ b/vendor/symposion/schedule/urls.py @@ -0,0 +1,34 @@ +from django.conf.urls import url + +from .views import ( + schedule_conference, + schedule_edit, + schedule_list, + schedule_list_csv, + schedule_presentation_detail, + schedule_detail, + schedule_slot_edit, + schedule_json, + session_staff_email, + session_list, + session_detail, + EventFeed +) + +urlpatterns = [ + url(r"^$", schedule_conference, name="schedule_conference"), + url(r"^edit/$", schedule_edit, name="schedule_edit"), + url(r"^list/$", schedule_list, name="schedule_list"), + url(r"^presentations.csv$", schedule_list_csv, name="schedule_list_csv"), + url(r"^presentation/(\d+)/$", schedule_presentation_detail, name="schedule_presentation_detail"), + url(r"^sessions/staff.txt$", session_staff_email, name="schedule_session_staff_email"), + url(r"^sessions/$", session_list, name="schedule_session_list"), + url(r"^session/(\d+)/$", session_detail, name="schedule_session_detail"), + url(r"^([\w\-]+)/$", schedule_detail, name="schedule_detail"), + url(r"^([\w\-]+)/edit/$", schedule_edit, name="schedule_edit"), + url(r"^([\w\-]+)/list/$", schedule_list, name="schedule_list"), + url(r"^([\w\-]+)/presentations.csv$", schedule_list_csv, name="schedule_list_csv"), + url(r"^([\w\-]+)/edit/slot/(\d+)/", schedule_slot_edit, name="schedule_slot_edit"), + url(r"^conference.json", schedule_json, name="schedule_json"), + url(r"^conference.ics", EventFeed(), name="ical_feed"), +] diff --git a/vendor/symposion/schedule/views.py b/vendor/symposion/schedule/views.py new file mode 100644 index 00000000..6983bdbb --- /dev/null +++ b/vendor/symposion/schedule/views.py @@ -0,0 +1,399 @@ +import json +import pytz + +from django.core.urlresolvers import reverse +from django.http import Http404, HttpResponse +from django.shortcuts import render, get_object_or_404, redirect +from django.template import loader, Context +from django.conf import settings + +from django.contrib.auth.models import User +from django.contrib import messages +from django.contrib.sites.models import Site + +from django_ical.views import ICalFeed + +from django.contrib.auth.decorators import login_required + +from symposion.schedule.forms import SlotEditForm, ScheduleSectionForm +from symposion.schedule.models import Schedule, Day, Slot, Presentation, Session, SessionRole +from symposion.schedule.timetable import TimeTable +from symposion.conference.models import Conference + + +def fetch_schedule(slug): + qs = Schedule.objects.all() + + if slug is None: + if qs.count() > 1: + raise Http404() + schedule = next(iter(qs), None) + if schedule is None: + raise Http404() + else: + schedule = get_object_or_404(qs, section__slug=slug) + + return schedule + + +def schedule_conference(request): + + if request.user.is_staff: + schedules = Schedule.objects.filter(hidden=False) + else: + schedules = Schedule.objects.filter(published=True, hidden=False) + + sections = [] + for schedule in schedules: + days_qs = Day.objects.filter(schedule=schedule) + days = [TimeTable(day) for day in days_qs] + sections.append({ + "schedule": schedule, + "days": days, + }) + + day_switch = request.GET.get('day', None) + ctx = { + "sections": sections, + "day_switch": day_switch + } + return render(request, "symposion/schedule/schedule_conference.html", ctx) + + +def schedule_detail(request, slug=None): + + schedule = fetch_schedule(slug) + if not schedule.published and not request.user.is_staff: + raise Http404() + + days_qs = Day.objects.filter(schedule=schedule) + days = [TimeTable(day) for day in days_qs] + + ctx = { + "schedule": schedule, + "days": days, + } + return render(request, "symposion/schedule/schedule_detail.html", ctx) + + +def schedule_list(request, slug=None): + schedule = fetch_schedule(slug) + if not schedule.published and not request.user.is_staff: + raise Http404() + + presentations = Presentation.objects.filter(section=schedule.section) + presentations = presentations.exclude(cancelled=True) + + if not request.user.is_staff: + presentations = presentations.exclude(unpublish=True) + + ctx = { + "schedule": schedule, + "presentations": presentations, + } + return render(request, "symposion/schedule/schedule_list.html", ctx) + + +def schedule_list_csv(request, slug=None): + schedule = fetch_schedule(slug) + if not schedule.published and not request.user.is_staff: + raise Http404() + + presentations = Presentation.objects.filter(section=schedule.section) + presentations = presentations.exclude(cancelled=True) + if not request.user.is_staff: + presentations = presentations.exclude(unpublish=True) + presentations = presentations.order_by("id") + response = HttpResponse(content_type="text/csv") + + if slug: + file_slug = slug + else: + file_slug = "presentations" + response["Content-Disposition"] = 'attachment; filename="%s.csv"' % file_slug + + response.write(loader.get_template("symposion/schedule/schedule_list.csv").render(Context({ + "presentations": presentations, + + }))) + return response + + +@login_required +def schedule_edit(request, slug=None): + + if not request.user.is_staff: + raise Http404() + + schedule = fetch_schedule(slug) + + if request.method == "POST": + form = ScheduleSectionForm( + request.POST, request.FILES, schedule=schedule + ) + if form.is_valid(): + if 'submit' in form.data: + msg = form.build_schedule() + elif 'delete' in form.data: + msg = form.delete_schedule() + messages.add_message(request, msg[0], msg[1]) + else: + form = ScheduleSectionForm(schedule=schedule) + days_qs = Day.objects.filter(schedule=schedule) + days = [TimeTable(day) for day in days_qs] + ctx = { + "schedule": schedule, + "days": days, + "form": form + } + return render(request, "symposion/schedule/schedule_edit.html", ctx) + + +@login_required +def schedule_slot_edit(request, slug, slot_pk): + + if not request.user.is_staff: + raise Http404() + + slot = get_object_or_404(Slot, day__schedule__section__slug=slug, pk=slot_pk) + + if request.method == "POST": + form = SlotEditForm(request.POST, slot=slot) + if form.is_valid(): + save = False + if "content_override" in form.cleaned_data: + slot.content_override = form.cleaned_data["content_override"] + save = True + if "presentation" in form.cleaned_data: + presentation = form.cleaned_data["presentation"] + if presentation is None: + slot.unassign() + else: + slot.assign(presentation) + if save: + slot.save() + return redirect("schedule_edit", slug) + else: + form = SlotEditForm(slot=slot) + ctx = { + "slug": slug, + "form": form, + "slot": slot, + } + return render(request, "symposion/schedule/_slot_edit.html", ctx) + + +def schedule_presentation_detail(request, pk): + + presentation = get_object_or_404(Presentation, pk=pk) + + if presentation.slot: + # 1) Schedule from presentation's slot + schedule = presentation.slot.day.schedule + else: + # 2) Fall back to the schedule for this proposal + schedule = presentation.proposal.kind.section.schedule + + if not request.user.is_staff: + # 3) Is proposal unpublished? + if presentation.unpublish or not (schedule and schedule.published): + raise Http404() + + ctx = { + "presentation": presentation, + "schedule": schedule, + } + return render(request, "symposion/schedule/presentation_detail.html", ctx) + + +def schedule_json(request): + slots = Slot.objects.filter( + day__schedule__published=True, + day__schedule__hidden=False + ).order_by("start") + + protocol = request.META.get('HTTP_X_FORWARDED_PROTO', 'http') + data = [] + for slot in slots: + slot_data = { + "room": ", ".join(room["name"] for room in slot.rooms.values()), + "rooms": [room["name"] for room in slot.rooms.values()], + "start": slot.start_datetime.isoformat(), + "end": slot.end_datetime.isoformat(), + "duration": slot.length_in_minutes, + "kind": slot.kind.label, + "section": slot.day.schedule.section.slug, + "conf_key": slot.pk, + # TODO: models should be changed. + # these are model features from other conferences that have forked symposion + # these have been used almost everywhere and are good candidates for + # base proposals + "license": "CC BY", + "tags": "", + "released": False, + "contact": [], + } + if hasattr(slot.content, "proposal"): + if slot.content.unpublish and not request.user.is_staff: + continue + + slot_data.update({ + "name": slot.content.title, + "authors": [s.name for s in slot.content.speakers()], + "contact": [ + s.email for s in slot.content.speakers() + ] if request.user.has_perm('symposion_speakers.can_view_contact_details') or request.user.is_staff else ["redacted"], + "abstract": slot.content.abstract, + "conf_url": "%s://%s%s" % ( + protocol, + Site.objects.get_current().domain, + reverse("schedule_presentation_detail", args=[slot.content.pk]) + ), + "cancelled": slot.content.cancelled, + "released": slot.content.proposal.recording_release + }) + if not slot.content.speaker.twitter_username == '': + slot_data["twitter_id"] = slot.content.speaker.twitter_username + else: + slot_data.update({ + "name": slot.content_override if slot.content_override else "Slot", + }) + data.append(slot_data) + + return HttpResponse( + json.dumps({"schedule": data}, indent=2), + content_type="application/json" + ) + + +class EventFeed(ICalFeed): + + product_id = '-//linux.conf.au/schedule//EN' + timezone = settings.TIME_ZONE + filename = 'conference.ics' + + def description(self): + return Conference.objects.all().first().title + + def items(self): + return Slot.objects.filter( + day__schedule__published=True, + day__schedule__hidden=False + ).exclude( + kind__label='shortbreak' + ).order_by("start") + + def item_title(self, item): + if hasattr(item.content, 'proposal'): + title = item.content.title + else: + title = item.kind if item.kind else "Slot" + return title + + def item_description(self, item): + if hasattr(item.content, 'proposal'): + description = "Speaker: %s\n%s" % ( + item.content.speaker, item.content.abstract) + else: + description = item.content_override if item.content_override else "No description" + return description + + def item_start_datetime(self, item): + return pytz.timezone(settings.TIME_ZONE).localize(item.start_datetime) + + def item_end_datetime(self, item): + return pytz.timezone(settings.TIME_ZONE).localize(item.end_datetime) + + def item_location(self, item): + return ", ".join(room["name"] for room in item.rooms.values()) + + def item_link(self, item) -> str: + if hasattr(item.content, 'proposal'): + return ( + 'http://%s%s' % (Site.objects.get_current().domain, + reverse('schedule_presentation_detail', args=[item.content.pk]))) + else: + return 'http://%s' % Site.objects.get_current().domain + + def item_guid(self, item): + return '%d@%s' % (item.pk, Site.objects.get_current().domain) + + +def session_list(request): + sessions = Session.objects.all().order_by('pk') + + return render(request, "symposion/schedule/session_list.html", { + "sessions": sessions, + }) + + +@login_required +def session_staff_email(request): + + if not request.user.is_staff: + return redirect("schedule_session_list") + + data = "\n".join(user.email for user in User.objects.filter(sessionrole__isnull=False).distinct()) + + return HttpResponse(data, content_type="text/plain;charset=UTF-8") + + +def session_detail(request, session_id): + + session = get_object_or_404(Session, id=session_id) + + chair = None + chair_denied = False + chairs = SessionRole.objects.filter(session=session, role=SessionRole.SESSION_ROLE_CHAIR).exclude(status=False) + if chairs: + chair = chairs[0].user + else: + if request.user.is_authenticated(): + # did the current user previously try to apply and got rejected? + if SessionRole.objects.filter(session=session, user=request.user, role=SessionRole.SESSION_ROLE_CHAIR, status=False): + chair_denied = True + + runner = None + runner_denied = False + runners = SessionRole.objects.filter(session=session, role=SessionRole.SESSION_ROLE_RUNNER).exclude(status=False) + if runners: + runner = runners[0].user + else: + if request.user.is_authenticated(): + # did the current user previously try to apply and got rejected? + if SessionRole.objects.filter(session=session, user=request.user, role=SessionRole.SESSION_ROLE_RUNNER, status=False): + runner_denied = True + + if request.method == "POST" and request.user.is_authenticated(): + if not hasattr(request.user, "attendee") or not request.user.attendee.completed_registration: + response = redirect("guided_registration") + response["Location"] += "?next=%s" % request.path + return response + + role = request.POST.get("role") + if role == "chair": + if chair is None and not chair_denied: + SessionRole(session=session, role=SessionRole.SESSION_ROLE_CHAIR, user=request.user).save() + elif role == "runner": + if runner is None and not runner_denied: + SessionRole(session=session, role=SessionRole.SESSION_ROLE_RUNNER, user=request.user).save() + elif role == "un-chair": + if chair == request.user: + session_role = SessionRole.objects.filter(session=session, role=SessionRole.SESSION_ROLE_CHAIR, user=request.user) + if session_role: + session_role[0].delete() + elif role == "un-runner": + if runner == request.user: + session_role = SessionRole.objects.filter(session=session, role=SessionRole.SESSION_ROLE_RUNNER, user=request.user) + if session_role: + session_role[0].delete() + + return redirect("schedule_session_detail", session_id) + + return render(request, "symposion/schedule/session_detail.html", { + "session": session, + "chair": chair, + "chair_denied": chair_denied, + "runner": runner, + "runner_denied": runner_denied, + }) diff --git a/vendor/symposion/speakers/__init__.py b/vendor/symposion/speakers/__init__.py new file mode 100644 index 00000000..a47c10fc --- /dev/null +++ b/vendor/symposion/speakers/__init__.py @@ -0,0 +1 @@ +default_app_config = "symposion.speakers.apps.SpeakersConfig" diff --git a/vendor/symposion/speakers/admin.py b/vendor/symposion/speakers/admin.py new file mode 100644 index 00000000..6331ab7b --- /dev/null +++ b/vendor/symposion/speakers/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from symposion.speakers.models import Speaker + + +admin.site.register(Speaker, + list_display=["name", "email", "created", "twitter_username"], + search_fields=["name"]) diff --git a/vendor/symposion/speakers/apps.py b/vendor/symposion/speakers/apps.py new file mode 100644 index 00000000..c772a12a --- /dev/null +++ b/vendor/symposion/speakers/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class SpeakersConfig(AppConfig): + name = "symposion.speakers" + label = "symposion_speakers" + verbose_name = _("Symposion Speakers") diff --git a/vendor/symposion/speakers/forms.py b/vendor/symposion/speakers/forms.py new file mode 100644 index 00000000..e4fddbc8 --- /dev/null +++ b/vendor/symposion/speakers/forms.py @@ -0,0 +1,32 @@ +from django import forms + +from symposion.speakers.models import Speaker + + +class SpeakerForm(forms.ModelForm): + + required_css_class = 'label-required' + + class Meta: + model = Speaker + fields = [ + "name", + "biography", + "experience", + "photo", + "telephone", + "homepage", + "twitter_username", + "accessibility", + "agreement", + ] + + def __init__(self, *a, **k): + super(SpeakerForm, self).__init__(*a, **k) + self.fields['agreement'].required = True + + def clean_twitter_username(self): + value = self.cleaned_data["twitter_username"] + if value.startswith("@"): + value = value[1:] + return value diff --git a/vendor/symposion/speakers/management/__init__.py b/vendor/symposion/speakers/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/speakers/management/commands/__init__.py b/vendor/symposion/speakers/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/speakers/management/commands/export_speaker_data.py b/vendor/symposion/speakers/management/commands/export_speaker_data.py new file mode 100644 index 00000000..08da5891 --- /dev/null +++ b/vendor/symposion/speakers/management/commands/export_speaker_data.py @@ -0,0 +1,20 @@ +import csv +import os + +from django.core.management.base import BaseCommand + +from symposion.speakers.models import Speaker + + +class Command(BaseCommand): + + def handle(self, *args, **options): + with open(os.path.join(os.getcwd(), "speakers.csv"), "w") as csv_file: + csv_writer = csv.writer(csv_file) + csv_writer.writerow(["Name", "Bio"]) + + for speaker in Speaker.objects.all(): + csv_writer.writerow([ + speaker.name, + speaker.biography, + ]) diff --git a/vendor/symposion/speakers/migrations/0001_initial.py b/vendor/symposion/speakers/migrations/0001_initial.py new file mode 100644 index 00000000..e6dc8deb --- /dev/null +++ b/vendor/symposion/speakers/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-09-17 03:35 +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Speaker', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='As you would like it to appear in the conference programme.', max_length=100, verbose_name='Name')), + ('biography', models.TextField(blank=True, help_text="This will appear on the conference website and in the programme. Please write in the third person, eg 'Alice is a Moblin hacker...', 150-200 words. Edit using Markdown.", verbose_name='Biography')), + ('biography_html', models.TextField(blank=True)), + ('experience', models.TextField(blank=True, help_text="Have you had any experience presenting elsewhere? If so, we'd like to know. Anything you put here will only be seen by the organisers and reviewers; use it to convince them why they should accept your proposal. Edit using Markdown.", verbose_name='Speaking experience')), + ('experience_html', models.TextField(blank=True)), + ('photo', models.ImageField(blank=True, upload_to='speaker_photos', verbose_name='Photo')), + ('telephone', models.CharField(help_text="The conference team will need this to contact you during the conference week. If you don't have one, or do not wish to provide it, then enter NONE in this field.", max_length=15)), + ('homepage', models.URLField(blank=True, help_text='Your home page, if you have one')), + ('twitter_username', models.CharField(blank=True, help_text='Your Twitter account', max_length=15)), + ('accessibility', models.TextField(blank=True, help_text="Please describe any special accessibility requirements that you may have. Edit using Markdown.", verbose_name='Accessibility requirements')), + ('accessibility_html', models.TextField(blank=True)), + ('travel_assistance', models.BooleanField(default=False, help_text='Check this box if you require assistance to travel to Hobart to present your proposed sessions.', verbose_name='Travel assistance required')), + ('accommodation_assistance', models.BooleanField(default=False, help_text='Check this box if you require us to provide you with student-style accommodation in order to present your proposed sessions.', verbose_name='Accommodation assistance required')), + ('agreement', models.BooleanField(default=False, help_text='I agree to the terms and conditions of attendance, and I have read, understood, and agree to act according to the standards set forth in our Code of Conduct ')), + ('annotation', models.TextField(verbose_name='Annotation')), + ('invite_email', models.CharField(db_index=True, max_length=200, null=True, unique=True, verbose_name='Invite_email')), + ('invite_token', models.CharField(db_index=True, max_length=40, verbose_name='Invite token')), + ('created', models.DateTimeField(default=datetime.datetime.now, editable=False, verbose_name='Created')), + ('user', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='speaker_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'ordering': ['name'], + 'verbose_name': 'Speaker', + 'verbose_name_plural': 'Speakers', + }, + ), + ] diff --git a/vendor/symposion/speakers/migrations/0002_auto_20161230_1900.py b/vendor/symposion/speakers/migrations/0002_auto_20161230_1900.py new file mode 100644 index 00000000..47eb7a77 --- /dev/null +++ b/vendor/symposion/speakers/migrations/0002_auto_20161230_1900.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-12-30 08:09 +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('symposion_speakers', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='speaker', + options={'ordering': ['name'], 'permissions': (('can_view_contact_details', 'Can View Contact Details'),), 'verbose_name': 'Speaker', 'verbose_name_plural': 'Speakers'}, + ), + ] diff --git a/vendor/symposion/speakers/migrations/__init__.py b/vendor/symposion/speakers/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/speakers/models.py b/vendor/symposion/speakers/models.py new file mode 100644 index 00000000..55397506 --- /dev/null +++ b/vendor/symposion/speakers/models.py @@ -0,0 +1,132 @@ +import datetime + +from django.core.urlresolvers import reverse +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from django.contrib.auth.models import User + +from symposion import constants +from symposion.text_parser import parse + + +class Speaker(models.Model): + + SESSION_COUNT_CHOICES = [ + (1, "One"), + (2, "Two") + ] + + user = models.OneToOneField(User, null=True, related_name="speaker_profile", verbose_name=_("User")) + name = models.CharField(verbose_name=_("Name"), max_length=100, + help_text=_("As you would like it to appear in the" + " conference programme.")) + biography = models.TextField( + blank=True, + help_text=_("This will appear on the conference website and in the " + "programme. Please write in the third person, eg 'Alice " + "is a Moblin hacker...', 150-200 words. " + + constants.TEXT_FIELD_MONOSPACE_NOTE), + verbose_name=_("Biography"), + ) + biography_html = models.TextField(blank=True) + experience = models.TextField( + blank=True, + help_text=_("Have you had any experience presenting elsewhere? If so, " + "we'd like to know. Anything you put here will only be " + "seen by the organisers and reviewers; use it to convince " + "them why they should accept your proposal. " + + constants.TEXT_FIELD_MONOSPACE_NOTE), + verbose_name=_("Speaking experience"), + ) + experience_html = models.TextField(blank=True) + photo = models.ImageField(upload_to="speaker_photos", blank=True, verbose_name=_("Photo")) + telephone = models.CharField( + max_length=15, + help_text=_(u"The conference team will need this to contact you " + "during the conference week. If you don't have one, or do " + "not wish to provide it, then enter NONE in this field.") + ) + homepage = models.URLField( + blank=True, + help_text=_(u"Your home page, if you have one") + ) + twitter_username = models.CharField( + max_length=15, + blank=True, + help_text=_(u"Your Twitter account") + ) + accessibility = models.TextField( + blank=True, + help_text=_("Please describe any special accessibility requirements " + "that you may have. " + + constants.TEXT_FIELD_MONOSPACE_NOTE), + verbose_name=_("Accessibility requirements")) + accessibility_html = models.TextField(blank=True) + travel_assistance = models.BooleanField( + blank=True, + default=False, + help_text=_("Check this box if you require assistance to travel to Hobart to " + "present your proposed sessions."), + verbose_name=_("Travel assistance required"), + ) + accommodation_assistance = models.BooleanField( + blank=True, + default=False, + help_text=_("Check this box if you require us to provide you with student-style " + "accommodation in order to present your proposed sessions."), + verbose_name=_("Accommodation assistance required"), + ) + agreement = models.BooleanField( + default=False, + help_text=_("I agree to the terms and conditions of attendance, and " + "I have read, understood, and agree to act according to " + "the standards set forth in our Code of Conduct ") + ) + + annotation = models.TextField(verbose_name=_("Annotation")) # staff only + invite_email = models.CharField(max_length=200, unique=True, null=True, db_index=True, verbose_name=_("Invite_email")) + invite_token = models.CharField(max_length=40, db_index=True, verbose_name=_("Invite token")) + created = models.DateTimeField( + default=datetime.datetime.now, + editable=False, + verbose_name=_("Created") + ) + + class Meta: + ordering = ['name'] + verbose_name = _("Speaker") + verbose_name_plural = _("Speakers") + permissions = (('can_view_contact_details', 'Can View Contact Details'),) + + def save(self, *args, **kwargs): + self.biography_html = parse(self.biography) + self.experience_html = parse(self.experience) + self.accessibility_html = parse(self.accessibility) + return super(Speaker, self).save(*args, **kwargs) + + def __str__(self): + if self.user: + return self.name + else: + return "?" + + def get_absolute_url(self): + return reverse("speaker_edit") + + @property + def email(self): + if self.user is not None: + return self.user.email + else: + return self.invite_email + + @property + def all_presentations(self): + presentations = [] + if self.presentations: + for p in self.presentations.all(): + presentations.append(p) + for p in self.copresentations.all(): + presentations.append(p) + return presentations diff --git a/vendor/symposion/speakers/urls.py b/vendor/symposion/speakers/urls.py new file mode 100644 index 00000000..a3cf7e2c --- /dev/null +++ b/vendor/symposion/speakers/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls import url + +from .views import ( + speaker_create, + speaker_create_token, + speaker_edit, + speaker_profile, + speaker_create_staff +) + +urlpatterns = [ + url(r"^create/$", speaker_create, name="speaker_create"), + url(r"^create/(\w+)/$", speaker_create_token, name="speaker_create_token"), + url(r"^edit/(?:(?P\d+)/)?$", speaker_edit, name="speaker_edit"), + url(r"^profile/(?P\d+)/$", speaker_profile, name="speaker_profile"), + url(r"^staff/create/(\d+)/$", speaker_create_staff, name="speaker_create_staff"), +] diff --git a/vendor/symposion/speakers/views.py b/vendor/symposion/speakers/views.py new file mode 100644 index 00000000..b087aa5a --- /dev/null +++ b/vendor/symposion/speakers/views.py @@ -0,0 +1,133 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import Http404 +from django.shortcuts import render, redirect, get_object_or_404 + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ + +from symposion.proposals.models import ProposalBase +from symposion.speakers.forms import SpeakerForm +from symposion.speakers.models import Speaker + + +@login_required +def speaker_create(request): + try: + return redirect(request.user.speaker_profile) + except ObjectDoesNotExist: + pass + + if request.method == "POST": + try: + speaker = Speaker.objects.get(invite_email=request.user.email) + found = True + except Speaker.DoesNotExist: + speaker = None + found = False + form = SpeakerForm(request.POST, request.FILES, instance=speaker) + + if form.is_valid(): + speaker = form.save(commit=False) + speaker.user = request.user + if not found: + speaker.invite_email = None + speaker.save() + messages.success(request, _("Speaker profile created.")) + return redirect("dashboard") + else: + form = SpeakerForm(initial={"name": request.user.get_full_name()}) + return render(request, "symposion/speakers/speaker_create.html", { + "speaker_form": form, + }) + + +@login_required +def speaker_create_staff(request, pk): + user = get_object_or_404(User, pk=pk) + if not request.user.is_staff: + raise Http404 + + try: + return redirect(user.speaker_profile) + except ObjectDoesNotExist: + pass + + if request.method == "POST": + form = SpeakerForm(request.POST, request.FILES) + + if form.is_valid(): + speaker = form.save(commit=False) + speaker.user = user + speaker.save() + messages.success(request, _("Speaker profile created.")) + return redirect("user_list") + else: + form = SpeakerForm(initial={"name": user.get_full_name()}) + + return render(request, "symposion/speakers/speaker_create.html", { + "speaker_form": form, + }) + + +@login_required +def speaker_create_token(request, token): + speaker = get_object_or_404(Speaker, invite_token=token) + request.session["pending-token"] = token + # check for speaker profile + try: + existing_speaker = request.user.speaker_profile + except ObjectDoesNotExist: + pass + else: + del request.session["pending-token"] + additional_speakers = ProposalBase.additional_speakers.through + additional_speakers._default_manager.filter( + speaker=speaker + ).update( + speaker=existing_speaker + ) + messages.info(request, _("You have been associated with all pending " + "talk proposals")) + return redirect("dashboard") + return redirect("speaker_create") + + +@login_required +def speaker_edit(request, pk=None): + if pk is None: + try: + speaker = request.user.speaker_profile + except Speaker.DoesNotExist: + return redirect("speaker_create") + else: + if request.user.is_staff: + speaker = get_object_or_404(Speaker, pk=pk) + else: + raise Http404() + + if request.method == "POST": + form = SpeakerForm(request.POST, request.FILES, instance=speaker) + if form.is_valid(): + form.save() + messages.success(request, "Speaker profile updated.") + return redirect("dashboard") + else: + form = SpeakerForm(instance=speaker) + + return render(request, "symposion/speakers/speaker_edit.html", { + "speaker_form": form, + }) + + +def speaker_profile(request, pk): + speaker = get_object_or_404(Speaker, pk=pk) + presentations = speaker.all_presentations + if not presentations and not request.user.is_staff: + raise Http404() + + return render(request, "symposion/speakers/speaker_profile.html", { + "speaker": speaker, + "presentations": presentations, + }) diff --git a/vendor/symposion/sponsorship/__init__.py b/vendor/symposion/sponsorship/__init__.py new file mode 100644 index 00000000..30b11cfe --- /dev/null +++ b/vendor/symposion/sponsorship/__init__.py @@ -0,0 +1 @@ +default_app_config = "symposion.sponsorship.apps.SponsorshipConfig" diff --git a/vendor/symposion/sponsorship/admin.py b/vendor/symposion/sponsorship/admin.py new file mode 100644 index 00000000..0a9272ac --- /dev/null +++ b/vendor/symposion/sponsorship/admin.py @@ -0,0 +1,128 @@ +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext_lazy as _ + +from django.contrib import admin + +from symposion.sponsorship.models import ( + Benefit, + BENEFITS, + BenefitLevel, + Sponsor, + SponsorBenefit, + SponsorLevel, +) + + +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] + list_filter = ["level", "active"] + list_display = ["name", "external_url", "level", "active", "contact", "applicant_field"] + + def contact(self, sponsor): + return mark_safe('%s' % (escape(sponsor.contact_email), escape(sponsor.contact_name))) + + def applicant_field(self, sponsor): + name = sponsor.applicant.get_full_name() + email = sponsor.applicant.email + return mark_safe('%s' % (escape(email), escape(name))) + applicant_field.short_description = _(u"Applicant") + + 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 = [ + ("1", _("unreviewed")), + ("2", _("approved")), + ("3", _("rejected")) + ] + return form + + # Define accessor functions for our benefit fields and add them to + # list_display, so we can sort on them and give them sensible names. + # Add the fields to list_filters while we're at it. + for benefit in BENEFITS: + benefit_name = benefit['name'] + field_name = benefit['field_name'] + + def func_generator(ben): + def column_func(obj): + return getattr(obj, ben['field_name']) + column_func.short_description = ben['column_title'] + column_func.boolean = True + column_func.admin_order_field = ben['field_name'] + return column_func + list_display.append(func_generator(benefit)) + list_filter.append(field_name) + + def save_related(self, request, form, formsets, change): + super(SponsorAdmin, self).save_related(request, form, formsets, change) + obj = form.instance + obj.save() + + +class BenefitAdmin(admin.ModelAdmin): + + list_display = ["name", "type", "description", "levels"] + inlines = [BenefitLevelInline] + + def levels(self, benefit): + return u", ".join(l.level.name for l in benefit.benefit_levels.all()) + + +class SponsorLevelAdmin(admin.ModelAdmin): + + inlines = [BenefitLevelInline] + + +class SponsorBenefitAdmin(admin.ModelAdmin): + list_display = ('benefit', 'sponsor', 'active', '_is_complete', 'show_text') + + def show_text(self, sponsor_benefit): + if sponsor_benefit.text: + return sponsor_benefit.text[:100] + else: + return _("None") + + +admin.site.register(SponsorLevel, SponsorLevelAdmin) +admin.site.register(Sponsor, SponsorAdmin) +admin.site.register(Benefit, BenefitAdmin) +admin.site.register(SponsorBenefit, SponsorBenefitAdmin) diff --git a/vendor/symposion/sponsorship/apps.py b/vendor/symposion/sponsorship/apps.py new file mode 100644 index 00000000..3d2d5d62 --- /dev/null +++ b/vendor/symposion/sponsorship/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class SponsorshipConfig(AppConfig): + name = "symposion.sponsorship" + label = "symposion_sponsorship" + verbose_name = _("Symposion Sponsorship") diff --git a/vendor/symposion/sponsorship/forms.py b/vendor/symposion/sponsorship/forms.py new file mode 100644 index 00000000..418eb7c5 --- /dev/null +++ b/vendor/symposion/sponsorship/forms.py @@ -0,0 +1,91 @@ +from django import forms +from django.forms.models import inlineformset_factory, BaseInlineFormSet + +from django.contrib.admin.widgets import AdminFileWidget +from django.utils.translation import ugettext_lazy as _ + +from symposion.sponsorship.models import Sponsor, SponsorBenefit + + +class SponsorApplicationForm(forms.ModelForm): + + required_css_class = 'label-required' + + 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", + "external_url", + "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): + + required_css_class = 'label-required' + + class Meta: + model = Sponsor + fields = [ + "name", + "external_url", + "contact_name", + "contact_email" + ] + + +class SponsorBenefitsInlineFormSet(BaseInlineFormSet): + + required_css_class = 'label-required' + + def __init__(self, *args, **kwargs): + kwargs['queryset'] = kwargs.get('queryset', self.model._default_manager).exclude(benefit__type="option") + super(SponsorBenefitsInlineFormSet, self).__init__(*args, **kwargs) + + 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 = _("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/vendor/symposion/sponsorship/management/__init__.py b/vendor/symposion/sponsorship/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/sponsorship/management/commands/__init__.py b/vendor/symposion/sponsorship/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/sponsorship/management/commands/export_sponsors_data.py b/vendor/symposion/sponsorship/management/commands/export_sponsors_data.py new file mode 100644 index 00000000..a20e3f3b --- /dev/null +++ b/vendor/symposion/sponsorship/management/commands/export_sponsors_data.py @@ -0,0 +1,77 @@ +import csv +import os +import shutil +import zipfile + +from contextlib import closing + +from django.core.management.base import BaseCommand +from django.template.defaultfilters import slugify + +from sotmjp.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 + + with open(os.path.join(os.getcwd(), "build", "sponsors.csv"), "w") as csv_file: + csv_writer = csv.writer(csv_file) + csv_writer.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_writer.writerow([ + data["name"], + data["url"], + data["level"], + data["description"] + ]) + + zipdir( + os.path.join(os.getcwd(), "build"), + os.path.join(os.getcwd(), "sponsors.zip")) diff --git a/vendor/symposion/sponsorship/management/commands/reset_sponsor_benefits.py b/vendor/symposion/sponsorship/management/commands/reset_sponsor_benefits.py new file mode 100644 index 00000000..efc7edba --- /dev/null +++ b/vendor/symposion/sponsorship/management/commands/reset_sponsor_benefits.py @@ -0,0 +1,36 @@ +from django.core.management.base import BaseCommand + +from symposion.sponsorship.models import Sponsor, SponsorBenefit, SponsorLevel + + +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 %s for %s" % (sponsor_benefit, 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/vendor/symposion/sponsorship/managers.py b/vendor/symposion/sponsorship/managers.py new file mode 100644 index 00000000..490b328c --- /dev/null +++ b/vendor/symposion/sponsorship/managers.py @@ -0,0 +1,7 @@ +from django.db import models + + +class SponsorManager(models.Manager): + + def active(self): + return self.get_query_set().filter(active=True).order_by("level") diff --git a/vendor/symposion/sponsorship/migrations/0001_initial.py b/vendor/symposion/sponsorship/migrations/0001_initial.py new file mode 100644 index 00000000..1cbbc389 --- /dev/null +++ b/vendor/symposion/sponsorship/migrations/0001_initial.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-09-17 03:35 +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('symposion_conference', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Benefit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('type', models.CharField(choices=[('text', 'Text'), ('file', 'File'), ('richtext', 'Rich Text'), ('weblogo', 'Web Logo'), ('simple', 'Simple'), ('option', 'Option')], default='simple', max_length=10, verbose_name='Type')), + ('content_type', models.CharField(choices=[('simple', 'Simple'), ('listing_text_af', 'Listing Text (Afrikaans)'), ('listing_text_ar', 'Listing Text (Arabic)'), ('listing_text_ast', 'Listing Text (Asturian)'), ('listing_text_az', 'Listing Text (Azerbaijani)'), ('listing_text_bg', 'Listing Text (Bulgarian)'), ('listing_text_be', 'Listing Text (Belarusian)'), ('listing_text_bn', 'Listing Text (Bengali)'), ('listing_text_br', 'Listing Text (Breton)'), ('listing_text_bs', 'Listing Text (Bosnian)'), ('listing_text_ca', 'Listing Text (Catalan)'), ('listing_text_cs', 'Listing Text (Czech)'), ('listing_text_cy', 'Listing Text (Welsh)'), ('listing_text_da', 'Listing Text (Danish)'), ('listing_text_de', 'Listing Text (German)'), ('listing_text_el', 'Listing Text (Greek)'), ('listing_text_en', 'Listing Text (English)'), ('listing_text_en-au', 'Listing Text (Australian English)'), ('listing_text_en-gb', 'Listing Text (British English)'), ('listing_text_eo', 'Listing Text (Esperanto)'), ('listing_text_es', 'Listing Text (Spanish)'), ('listing_text_es-ar', 'Listing Text (Argentinian Spanish)'), ('listing_text_es-co', 'Listing Text (Colombian Spanish)'), ('listing_text_es-mx', 'Listing Text (Mexican Spanish)'), ('listing_text_es-ni', 'Listing Text (Nicaraguan Spanish)'), ('listing_text_es-ve', 'Listing Text (Venezuelan Spanish)'), ('listing_text_et', 'Listing Text (Estonian)'), ('listing_text_eu', 'Listing Text (Basque)'), ('listing_text_fa', 'Listing Text (Persian)'), ('listing_text_fi', 'Listing Text (Finnish)'), ('listing_text_fr', 'Listing Text (French)'), ('listing_text_fy', 'Listing Text (Frisian)'), ('listing_text_ga', 'Listing Text (Irish)'), ('listing_text_gd', 'Listing Text (Scottish Gaelic)'), ('listing_text_gl', 'Listing Text (Galician)'), ('listing_text_he', 'Listing Text (Hebrew)'), ('listing_text_hi', 'Listing Text (Hindi)'), ('listing_text_hr', 'Listing Text (Croatian)'), ('listing_text_hu', 'Listing Text (Hungarian)'), ('listing_text_ia', 'Listing Text (Interlingua)'), ('listing_text_id', 'Listing Text (Indonesian)'), ('listing_text_io', 'Listing Text (Ido)'), ('listing_text_is', 'Listing Text (Icelandic)'), ('listing_text_it', 'Listing Text (Italian)'), ('listing_text_ja', 'Listing Text (Japanese)'), ('listing_text_ka', 'Listing Text (Georgian)'), ('listing_text_kk', 'Listing Text (Kazakh)'), ('listing_text_km', 'Listing Text (Khmer)'), ('listing_text_kn', 'Listing Text (Kannada)'), ('listing_text_ko', 'Listing Text (Korean)'), ('listing_text_lb', 'Listing Text (Luxembourgish)'), ('listing_text_lt', 'Listing Text (Lithuanian)'), ('listing_text_lv', 'Listing Text (Latvian)'), ('listing_text_mk', 'Listing Text (Macedonian)'), ('listing_text_ml', 'Listing Text (Malayalam)'), ('listing_text_mn', 'Listing Text (Mongolian)'), ('listing_text_mr', 'Listing Text (Marathi)'), ('listing_text_my', 'Listing Text (Burmese)'), ('listing_text_nb', 'Listing Text (Norwegian Bokmal)'), ('listing_text_ne', 'Listing Text (Nepali)'), ('listing_text_nl', 'Listing Text (Dutch)'), ('listing_text_nn', 'Listing Text (Norwegian Nynorsk)'), ('listing_text_os', 'Listing Text (Ossetic)'), ('listing_text_pa', 'Listing Text (Punjabi)'), ('listing_text_pl', 'Listing Text (Polish)'), ('listing_text_pt', 'Listing Text (Portuguese)'), ('listing_text_pt-br', 'Listing Text (Brazilian Portuguese)'), ('listing_text_ro', 'Listing Text (Romanian)'), ('listing_text_ru', 'Listing Text (Russian)'), ('listing_text_sk', 'Listing Text (Slovak)'), ('listing_text_sl', 'Listing Text (Slovenian)'), ('listing_text_sq', 'Listing Text (Albanian)'), ('listing_text_sr', 'Listing Text (Serbian)'), ('listing_text_sr-latn', 'Listing Text (Serbian Latin)'), ('listing_text_sv', 'Listing Text (Swedish)'), ('listing_text_sw', 'Listing Text (Swahili)'), ('listing_text_ta', 'Listing Text (Tamil)'), ('listing_text_te', 'Listing Text (Telugu)'), ('listing_text_th', 'Listing Text (Thai)'), ('listing_text_tr', 'Listing Text (Turkish)'), ('listing_text_tt', 'Listing Text (Tatar)'), ('listing_text_udm', 'Listing Text (Udmurt)'), ('listing_text_uk', 'Listing Text (Ukrainian)'), ('listing_text_ur', 'Listing Text (Urdu)'), ('listing_text_vi', 'Listing Text (Vietnamese)'), ('listing_text_zh-hans', 'Listing Text (Simplified Chinese)'), ('listing_text_zh-hant', 'Listing Text (Traditional Chinese)')], default='simple', max_length=20, verbose_name='content type')), + ], + ), + migrations.CreateModel( + name='BenefitLevel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('max_words', models.PositiveIntegerField(blank=True, null=True, verbose_name='Max words')), + ('other_limits', models.CharField(blank=True, max_length=200, verbose_name='Other limits')), + ('benefit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='benefit_levels', to='symposion_sponsorship.Benefit', verbose_name='Benefit')), + ], + options={ + 'ordering': ['level'], + 'verbose_name': 'Benefit level', + 'verbose_name_plural': 'Benefit levels', + }, + ), + migrations.CreateModel( + name='Sponsor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Sponsor Name')), + ('display_url', models.URLField(blank=True, verbose_name='display URL')), + ('external_url', models.URLField(verbose_name='External URL')), + ('annotation', models.TextField(blank=True, verbose_name='Annotation')), + ('contact_name', models.CharField(max_length=100, verbose_name='Contact Name')), + ('contact_email', models.EmailField(max_length=254, verbose_name='Contact Email')), + ('added', models.DateTimeField(default=datetime.datetime.now, verbose_name='added')), + ('active', models.BooleanField(default=False, verbose_name='active')), + ('web_logo_benefit', models.NullBooleanField(help_text='Web logo benefit is complete', verbose_name='Web logo benefit')), + ('print_logo_benefit', models.NullBooleanField(help_text='Print logo benefit is complete', verbose_name='Print logo benefit')), + ('print_description_benefit', models.NullBooleanField(help_text='Print description benefit is complete', verbose_name='Print description benefit')), + ('company_description_benefit', models.NullBooleanField(help_text='Company description benefit is complete', verbose_name='Company description benefit')), + ('applicant', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sponsorships', to=settings.AUTH_USER_MODEL, verbose_name='Applicant')), + ], + options={ + 'ordering': ['name'], + 'verbose_name': 'Sponsor', + 'verbose_name_plural': 'Sponsors', + }, + ), + migrations.CreateModel( + name='SponsorBenefit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('max_words', models.PositiveIntegerField(blank=True, null=True, verbose_name='Max words')), + ('other_limits', models.CharField(blank=True, max_length=200, verbose_name='Other limits')), + ('text', models.TextField(blank=True, verbose_name='Text')), + ('upload', models.FileField(blank=True, upload_to='sponsor_files', verbose_name='File')), + ('is_complete', models.NullBooleanField(help_text='True - benefit complete; False - benefit incomplete; Null - n/a', verbose_name='Complete?')), + ('benefit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sponsor_benefits', to='symposion_sponsorship.Benefit', verbose_name='Benefit')), + ('sponsor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sponsor_benefits', to='symposion_sponsorship.Sponsor', verbose_name='Sponsor')), + ], + options={ + 'ordering': ['-active'], + 'verbose_name': 'Sponsor benefit', + 'verbose_name_plural': 'Sponsor benefits', + }, + ), + migrations.CreateModel( + name='SponsorLevel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ('order', models.IntegerField(default=0, verbose_name='Order')), + ('cost', models.PositiveIntegerField(verbose_name='Cost')), + ('description', models.TextField(blank=True, help_text='This is private.', verbose_name='Description')), + ('conference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_conference.Conference', verbose_name='Conference')), + ], + options={ + 'ordering': ['conference', 'order'], + 'verbose_name': 'Sponsor level', + 'verbose_name_plural': 'Sponsor levels', + }, + ), + migrations.AddField( + model_name='sponsor', + name='level', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='symposion_sponsorship.SponsorLevel', verbose_name='level'), + ), + migrations.AddField( + model_name='sponsor', + name='sponsor_logo', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='symposion_sponsorship.SponsorBenefit', verbose_name='Sponsor logo'), + ), + migrations.AddField( + model_name='benefitlevel', + name='level', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='benefit_levels', to='symposion_sponsorship.SponsorLevel', verbose_name='Level'), + ), + ] diff --git a/vendor/symposion/sponsorship/migrations/__init__.py b/vendor/symposion/sponsorship/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/sponsorship/models.py b/vendor/symposion/sponsorship/models.py new file mode 100644 index 00000000..cd256d11 --- /dev/null +++ b/vendor/symposion/sponsorship/models.py @@ -0,0 +1,331 @@ +import datetime + +from django.conf import settings +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 symposion.sponsorship.managers import SponsorManager + + +# The benefits we track as individual fields on sponsors +# Names are the names in the database as defined by organizers. +# Field names are the benefit names, lowercased, with +# spaces changed to _, and with "_benefit" appended. +# Column titles are arbitrary. + +# "really just care about the ones we have today: print logo, web logo, print description, web description and the ad." + +BENEFITS = [ + { + 'name': 'Web logo', + 'field_name': 'web_logo_benefit', + 'column_title': _(u"Web Logo"), + }, { + 'name': 'Print logo', + 'field_name': 'print_logo_benefit', + 'column_title': _(u"Print Logo"), + }, { + 'name': 'Company Description', + 'field_name': 'company_description_benefit', + 'column_title': _(u"Web Desc"), + }, { + 'name': 'Print Description', + 'field_name': 'print_description_benefit', + 'column_title': _(u"Print Desc"), + } +] + + +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 __str__(self): + return "%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) + display_url = models.URLField(_("display URL"), blank=True) + 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(_("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, verbose_name=_("Sponsor logo")) + + # Whether things are complete + # True = complete, False = incomplate, Null = n/a for this sponsor level + web_logo_benefit = models.NullBooleanField(_("Web logo benefit"), help_text=_(u"Web logo benefit is complete")) + print_logo_benefit = models.NullBooleanField(_("Print logo benefit"), help_text=_(u"Print logo benefit is complete")) + print_description_benefit = models.NullBooleanField(_("Print description benefit"), help_text=_(u"Print description benefit is complete")) + company_description_benefit = models.NullBooleanField(_("Company description benefit"), help_text=_(u"Company description benefit is complete")) + + objects = SponsorManager() + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("Sponsor") + verbose_name_plural = _("Sponsors") + ordering = ['name'] + + def save(self, *args, **kwargs): + # Set fields related to benefits being complete + for benefit in BENEFITS: + field_name = benefit['field_name'] + benefit_name = benefit['name'] + setattr(self, field_name, self.benefit_is_complete(benefit_name)) + super(Sponsor, self).save(*args, **kwargs) + + def get_absolute_url(self): + if self.active: + return reverse("sponsor_detail", kwargs={"pk": self.pk}) + return reverse("sponsor_list") + + def get_display_url(self): + if self.display_url: + return self.display_url + else: + return self.external_url + + @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 + + @property + def listing_text(self): + if not hasattr(self, "_listing_text"): + self._listing_text = "" + # @@@ better than hard-coding a pk but still not good + benefits = self.sponsor_benefits.filter(benefit__name="Sponsor Description") + if benefits.count(): + self._listing_text = benefits[0].text + return self._listing_text + + 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="") + + def send_coordinator_emails(self): + pass # @@@ should this just be done centrally? + + def benefit_is_complete(self, name): + """Return True - benefit is complete, False - benefit is not complete, + or None - benefit not applicable for this sponsor's level """ + if BenefitLevel.objects.filter(level=self.level, benefit__name=name).exists(): + try: + benefit = self.sponsor_benefits.get(benefit__name=name) + except SponsorBenefit.DoesNotExist: + return False + else: + return benefit.is_complete + else: + return None # Not an applicable benefit for this sponsor's level + + +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) + + +BENEFIT_TYPE_CHOICES = [ + ("text", _("Text")), + ("file", _("File")), + ("richtext", _("Rich Text")), + ("weblogo", _("Web Logo")), + ("simple", _("Simple")), + ("option", _("Option")) +] + +CONTENT_TYPE_CHOICES = [ + ("simple", "Simple"), +] + [ + ("listing_text_%s" % lang, "Listing Text (%s)" % label) for lang, label in settings.LANGUAGES +] + + +class Benefit(models.Model): + + name = models.CharField(_("Name"), max_length=100) + description = models.TextField(_("Description"), blank=True) + type = models.CharField(_("Type"), choices=BENEFIT_TYPE_CHOICES, max_length=10, + default="simple") + content_type = models.CharField(_("content type"), choices=CONTENT_TYPE_CHOICES, + max_length=20, default="simple") + + def __str__(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")) + + # default limits for this benefit at given 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"] + verbose_name = _("Benefit level") + verbose_name_plural = _("Benefit levels") + + def __str__(self): + return "%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, verbose_name=_("Active")) + + # 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") + + # Whether any assets required from the sponsor have been provided + # (e.g. a logo file for a Web logo benefit). + is_complete = models.NullBooleanField(_("Complete?"), help_text=_(u"True - benefit complete; False - benefit incomplete; Null - n/a")) + + class Meta: + ordering = ["-active"] + verbose_name = _("Sponsor benefit") + verbose_name_plural = _("Sponsor benefits") + + def __str__(self): + return "%s - %s (%s)" % (self.sponsor, self.benefit, self.benefit.type) + + def save(self, *args, **kwargs): + # Validate - save() doesn't clean your model by default, so call + # it explicitly before saving + self.full_clean() + self.is_complete = self._is_complete() + super(SponsorBenefit, self).save(*args, **kwargs) + + def clean(self): + num_words = len(self.text.split()) + if self.max_words and num_words > self.max_words: + raise ValidationError( + _("Sponsorship level only allows for %(word)s words, you provided %(num)d.") % { + "word": self.max_words, "num": num_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 in ("text", "richtext", "simple", "option"): + return ["text"] + return [] + + def _is_text_benefit(self): + return self.benefit.type in ["text", "richtext", "simple"] and bool(self.text) + + def _is_upload_benefit(self): + return self.benefit.type in ["file", "weblogo"] and bool(self.upload) + + def _is_complete(self): + return self.active and (self._is_text_benefit() or self._is_upload_benefit()) + + +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) diff --git a/vendor/symposion/sponsorship/templatetags/__init__.py b/vendor/symposion/sponsorship/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vendor/symposion/sponsorship/templatetags/sponsorship_tags.py b/vendor/symposion/sponsorship/templatetags/sponsorship_tags.py new file mode 100644 index 00000000..efbdda09 --- /dev/null +++ b/vendor/symposion/sponsorship/templatetags/sponsorship_tags.py @@ -0,0 +1,120 @@ +from django import template +from django.template.defaultfilters import linebreaks, urlize + +from symposion.conference.models import current_conference +from symposion.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) + + +class LocalizedTextNode(template.Node): + + @classmethod + def handle_token(cls, parser, token): + bits = token.split_contents() + if len(bits) == 3: + return cls(bits[2], bits[1][1:-1]) + elif len(bits) == 5 and bits[-2] == "as": + return cls(bits[2], bits[1][1:-1], bits[4]) + else: + raise template.TemplateSyntaxError("%r takes 'as var'" % bits[0]) + + def __init__(self, sponsor, content_type, context_var=None): + self.sponsor_var = template.Variable(sponsor) + self.content_type = content_type + self.content_var = context_var + + def render(self, context): + s = '' + try: + sponsor = self.sponsor_var.resolve(context) + content_type = '%s_%s' % (self.content_type, context['request'].LANGUAGE_CODE) + texts = sponsor.sponsor_benefits.filter(benefit__content_type=content_type) + if texts.count() > 0: + s = linebreaks(urlize(texts[0].text, autoescape=True)) + if self.content_var: + context[self.content_var] = s + s = '' + except: + pass + return s + + +@register.tag +def localized_text(parser, token): + """ + {% localized_text "content_type" sponsor %} + {% localized_text "content_type" sponsor as localized_text %} + """ + return LocalizedTextNode.handle_token(parser, token) diff --git a/vendor/symposion/sponsorship/tests.py b/vendor/symposion/sponsorship/tests.py new file mode 100644 index 00000000..a6eae66b --- /dev/null +++ b/vendor/symposion/sponsorship/tests.py @@ -0,0 +1,307 @@ +from cStringIO import StringIO +import os +import shutil +import tempfile +from zipfile import ZipFile + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.core.urlresolvers import reverse +from django.test import TestCase +from django.test.utils import override_settings + +from pycon.sponsorship.models import Benefit, Sponsor, SponsorBenefit,\ + SponsorLevel +from symposion.conference.models import current_conference + + +class TestSponsorZipDownload(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='joe', + email='joe@example.com', + password='joe') + self.user.is_staff = True + self.user.save() + self.url = reverse("sponsor_zip_logos") + self.assertTrue(self.client.login(username='joe@example.com', + password='joe')) + + # we need a sponsor + conference = current_conference() + self.sponsor_level = SponsorLevel.objects.create( + conference=conference, name="Lead", cost=1) + self.sponsor = Sponsor.objects.create( + name="Big Daddy", + level=self.sponsor_level, + active=True, + ) + + # Create our benefits, of various types + self.text_benefit = Benefit.objects.create(name="text", type="text") + self.file_benefit = Benefit.objects.create(name="file", type="file") + # These names must be spelled exactly this way: + self.weblogo_benefit = Benefit.objects.create(name="Web logo", type="weblogo") + self.printlogo_benefit = Benefit.objects.create(name="Print logo", type="file") + self.advertisement_benefit = Benefit.objects.create(name="Advertisement", type="file") + + def validate_response(self, rsp, names_and_sizes): + # Ensure a response from the view looks right, contains a valid + # zip archive, has files with the right names and sizes. + self.assertEqual("application/zip", rsp['Content-type']) + prefix = settings.CONFERENCE_URL_PREFIXES[settings.CONFERENCE_ID] + + self.assertEqual( + 'attachment; filename="pycon_%s_sponsorlogos.zip"' % prefix, + rsp['Content-Disposition']) + zipfile = ZipFile(StringIO(rsp.content), "r") + # Check out the zip - testzip() returns None if no errors found + self.assertIsNone(zipfile.testzip()) + # Compare contents to what is expected + infolist = zipfile.infolist() + self.assertEqual(len(names_and_sizes), len(infolist)) + for info, name_and_size in zip(infolist, names_and_sizes): + name, size = name_and_size + self.assertEqual(name, info.filename) + self.assertEqual(size, info.file_size) + + def make_temp_file(self, name, size=0): + # Create a temp file with the given name and size under self.temp_dir + path = os.path.join(self.temp_dir, name) + with open(path, "wb") as f: + f.write(size * "x") + + def test_must_be_logged_in(self): + # Must be logged in to use the view + # If not logged in, doesn't redirect, just serves up a login view + self.client.logout() + rsp = self.client.get(self.url) + self.assertEqual(200, rsp.status_code) + self.assertIn("""""", rsp.content) + + def test_must_be_staff(self): + # Only staff can use the view + # If not staff, doesn't show error, just serves up a login view + # Also, the dashboard doesn't show the download button + self.user.is_staff = False + self.user.save() + rsp = self.client.get(self.url) + self.assertEqual(200, rsp.status_code) + self.assertIn("""""", rsp.content) + rsp = self.client.get(reverse('dashboard')) + self.assertNotIn(self.url, rsp.content) + + def test_no_files(self): + # If there are no sponsor files, we still work + # And the dashboard shows our download button + rsp = self.client.get(self.url) + self.validate_response(rsp, []) + rsp = self.client.get(reverse('dashboard')) + self.assertIn(self.url, rsp.content) + + def test_different_benefit_types(self): + # We only get files from the benefits named "Print logo" and "Web logo" + # And we ignore any non-existent files + try: + # Create a temp dir for media files + self.temp_dir = tempfile.mkdtemp() + with override_settings(MEDIA_ROOT=self.temp_dir): + + # Give our sponsor some benefits + SponsorBenefit.objects.create( + sponsor=self.sponsor, + benefit=self.text_benefit, + text="Foo!" + ) + + self.make_temp_file("file1", 10) + SponsorBenefit.objects.create( + sponsor=self.sponsor, + benefit=self.file_benefit, + upload="file1" + ) + + self.make_temp_file("file2", 20) + SponsorBenefit.objects.create( + sponsor=self.sponsor, + benefit=self.weblogo_benefit, + upload="file2" + ) + + # Benefit whose file is missing from the disk + SponsorBenefit.objects.create( + sponsor=self.sponsor, + benefit=self.weblogo_benefit, + upload="file3" + ) + + # print logo benefit + self.make_temp_file("file4", 40) + SponsorBenefit.objects.create( + sponsor=self.sponsor, + benefit=self.printlogo_benefit, + upload="file4" + ) + + self.make_temp_file("file5", 50) + SponsorBenefit.objects.create( + sponsor=self.sponsor, + benefit=self.advertisement_benefit, + upload="file5" + ) + + rsp = self.client.get(self.url) + expected = [ + ('web_logos/lead/big_daddy/file2', 20), + ('print_logos/lead/big_daddy/file4', 40), + ('advertisement/lead/big_daddy/file5', 50) + ] + self.validate_response(rsp, expected) + finally: + if hasattr(self, 'temp_dir'): + # Clean up any temp media files + shutil.rmtree(self.temp_dir) + + def test_file_org(self): + # The zip file is organized into directories: + # {print_logos,web_logos,advertisement}/// + + # Add another sponsor at a different sponsor level + conference = current_conference() + self.sponsor_level2 = SponsorLevel.objects.create( + conference=conference, name="Silly putty", cost=1) + self.sponsor2 = Sponsor.objects.create( + name="Big Mama", + level=self.sponsor_level2, + active=True, + ) + # + try: + # Create a temp dir for media files + self.temp_dir = tempfile.mkdtemp() + with override_settings(MEDIA_ROOT=self.temp_dir): + + # Give our sponsors some benefits + self.make_temp_file("file1", 10) + SponsorBenefit.objects.create( + sponsor=self.sponsor, + benefit=self.weblogo_benefit, + upload="file1" + ) + # print logo benefit + self.make_temp_file("file2", 20) + SponsorBenefit.objects.create( + sponsor=self.sponsor, + benefit=self.printlogo_benefit, + upload="file2" + ) + # Sponsor 2 + self.make_temp_file("file3", 30) + SponsorBenefit.objects.create( + sponsor=self.sponsor2, + benefit=self.weblogo_benefit, + upload="file3" + ) + # print logo benefit + self.make_temp_file("file4", 42) + SponsorBenefit.objects.create( + sponsor=self.sponsor2, + benefit=self.printlogo_benefit, + upload="file4" + ) + # ad benefit + self.make_temp_file("file5", 55) + SponsorBenefit.objects.create( + sponsor=self.sponsor2, + benefit=self.advertisement_benefit, + upload="file5" + ) + + rsp = self.client.get(self.url) + expected = [ + ('web_logos/lead/big_daddy/file1', 10), + ('web_logos/silly_putty/big_mama/file3', 30), + ('print_logos/lead/big_daddy/file2', 20), + ('print_logos/silly_putty/big_mama/file4', 42), + ('advertisement/silly_putty/big_mama/file5', 55), + ] + self.validate_response(rsp, expected) + finally: + if hasattr(self, 'temp_dir'): + # Clean up any temp media files + shutil.rmtree(self.temp_dir) + + +class TestBenefitValidation(TestCase): + """ + It should not be possible to save a SponsorBenefit if it has the + wrong kind of data in it - e.g. a text-type benefit cannot have + an uploaded file, and vice-versa. + """ + def setUp(self): + # we need a sponsor + conference = current_conference() + self.sponsor_level = SponsorLevel.objects.create( + conference=conference, name="Lead", cost=1) + self.sponsor = Sponsor.objects.create( + name="Big Daddy", + level=self.sponsor_level, + ) + + # Create our benefit types + self.text_type = Benefit.objects.create(name="text", type="text") + self.file_type = Benefit.objects.create(name="file", type="file") + self.weblogo_type = Benefit.objects.create(name="log", type="weblogo") + self.simple_type = Benefit.objects.create(name="simple", type="simple") + + def validate(self, should_work, benefit_type, upload, text): + obj = SponsorBenefit( + benefit=benefit_type, + sponsor=self.sponsor, + upload=upload, + text=text + ) + if should_work: + obj.save() + else: + with self.assertRaises(ValidationError): + obj.save() + + def test_text_has_text(self): + self.validate(True, self.text_type, upload=None, text="Some text") + + def test_text_has_upload(self): + self.validate(False, self.text_type, upload="filename", text='') + + def test_text_has_both(self): + self.validate(False, self.text_type, upload="filename", text="Text") + + def test_file_has_text(self): + self.validate(False, self.file_type, upload=None, text="Some text") + + def test_file_has_upload(self): + self.validate(True, self.file_type, upload="filename", text='') + + def test_file_has_both(self): + self.validate(False, self.file_type, upload="filename", text="Text") + + def test_weblogo_has_text(self): + self.validate(False, self.weblogo_type, upload=None, text="Some text") + + def test_weblogo_has_upload(self): + self.validate(True, self.weblogo_type, upload="filename", text='') + + def test_weblogo_has_both(self): + self.validate(False, self.weblogo_type, upload="filename", text="Text") + + def test_simple_has_neither(self): + self.validate(True, self.simple_type, upload=None, text='') + + def test_simple_has_text(self): + self.validate(True, self.simple_type, upload=None, text="Some text") + + def test_simple_has_upload(self): + self.validate(False, self.simple_type, upload="filename", text='') + + def test_simple_has_both(self): + self.validate(False, self.simple_type, upload="filename", text="Text") diff --git a/vendor/symposion/sponsorship/urls.py b/vendor/symposion/sponsorship/urls.py new file mode 100644 index 00000000..9fdc1927 --- /dev/null +++ b/vendor/symposion/sponsorship/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls import url +from django.views.generic import TemplateView + +from .views import ( + sponsor_apply, + sponsor_add, + sponsor_zip_logo_files, + sponsor_detail +) + +urlpatterns = [ + url(r"^$", TemplateView.as_view(template_name="symposion/sponsorship/list.html"), name="sponsor_list"), + url(r"^apply/$", sponsor_apply, name="sponsor_apply"), + url(r"^add/$", sponsor_add, name="sponsor_add"), + url(r"^ziplogos/$", sponsor_zip_logo_files, name="sponsor_zip_logos"), + url(r"^(?P\d+)/$", sponsor_detail, name="sponsor_detail"), +] diff --git a/vendor/symposion/sponsorship/views.py b/vendor/symposion/sponsorship/views.py new file mode 100644 index 00000000..bc5acf8f --- /dev/null +++ b/vendor/symposion/sponsorship/views.py @@ -0,0 +1,195 @@ +from io import StringIO +import itertools +import logging +import os +import time +from zipfile import ZipFile, ZipInfo + +from django.conf import settings +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth.decorators import login_required +from django.http import Http404, HttpResponse +from django.shortcuts import render_to_response, redirect, get_object_or_404 +from django.template import RequestContext +from django.utils.translation import ugettext_lazy as _ + +from symposion.sponsorship.forms import SponsorApplicationForm, \ + SponsorDetailsForm, SponsorBenefitsFormSet +from symposion.sponsorship.models import Benefit, Sponsor, SponsorBenefit, \ + SponsorLevel + + +log = logging.getLogger(__name__) + + +@login_required +def sponsor_apply(request): + if request.method == "POST": + form = SponsorApplicationForm(request.POST, user=request.user) + if form.is_valid(): + sponsor = form.save() + if sponsor.sponsor_benefits.all(): + # Redirect user to sponsor_detail to give extra information. + messages.success(request, _("Thank you for your sponsorship " + "application. Please update your " + "benefit details below.")) + return redirect("sponsor_detail", pk=sponsor.pk) + else: + messages.success(request, _("Thank you for your sponsorship " + "application.")) + return redirect("dashboard") + else: + form = SponsorApplicationForm(user=request.user) + + return render_to_response("symposion/sponsorship/apply.html", { + "form": form, + }, context_instance=RequestContext(request)) + + +@login_required +def sponsor_add(request): + if not request.user.is_staff: + raise Http404() + + if request.method == "POST": + form = SponsorApplicationForm(request.POST, user=request.user) + if form.is_valid(): + sponsor = form.save(commit=False) + sponsor.active = True + sponsor.save() + return redirect("sponsor_detail", pk=sponsor.pk) + else: + form = SponsorApplicationForm(user=request.user) + + return render_to_response("symposion/sponsorship/add.html", { + "form": form, + }, context_instance=RequestContext(request)) + + +@login_required +def sponsor_detail(request, pk): + sponsor = get_object_or_404(Sponsor, pk=pk) + + if 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, _("Sponsorship details have been updated")) + + return redirect("dashboard") + else: + form = SponsorDetailsForm(instance=sponsor) + formset = SponsorBenefitsFormSet(**formset_kwargs) + + return render_to_response("symposion/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") + + +@staff_member_required +def sponsor_zip_logo_files(request): + """Return a zip file of sponsor web and print logos""" + + zip_stringio = StringIO() + zipfile = ZipFile(zip_stringio, "w") + try: + benefits = Benefit.objects.all() + for benefit in benefits: + dir_name = benefit.name.lower().replace(" ", "_").replace('/', '_') + for level in SponsorLevel.objects.all(): + level_name = level.name.lower().replace(" ", "_").replace('/', '_') + for sponsor in Sponsor.objects.filter(level=level, active=True): + sponsor_name = sponsor.name.lower().replace(" ", "_").replace('/', '_') + full_dir = "/".join([dir_name, level_name, sponsor_name]) + for sponsor_benefit in SponsorBenefit.objects.filter( + benefit=benefit, + sponsor=sponsor, + active=True, + ).exclude(upload=''): + if os.path.exists(sponsor_benefit.upload.path): + modtime = time.gmtime(os.stat(sponsor_benefit.upload.path).st_mtime) + with open(sponsor_benefit.upload.path, "rb") as f: + fname = os.path.split(sponsor_benefit.upload.name)[-1] + zipinfo = ZipInfo(filename=full_dir + "/" + fname, + date_time=modtime) + zipfile.writestr(zipinfo, f.read()) + else: + log.debug("No such sponsor file: %s" % sponsor_benefit.upload.path) + finally: + zipfile.close() + + response = HttpResponse(zip_stringio.getvalue(), + content_type="application/zip") + prefix = settings.CONFERENCE_URL_PREFIXES[settings.CONFERENCE_ID] + response['Content-Disposition'] = \ + 'attachment; filename="%s_sponsorlogos.zip"' % prefix + return response diff --git a/vendor/symposion/static/datatables/js/dataTables.bootstrap.js b/vendor/symposion/static/datatables/js/dataTables.bootstrap.js new file mode 100644 index 00000000..b58e5f77 --- /dev/null +++ b/vendor/symposion/static/datatables/js/dataTables.bootstrap.js @@ -0,0 +1,138 @@ +$(function() { + /* Default class modification */ + $.extend( $.fn.dataTableExt.oStdClasses, { + "sWrapper": "dataTables_wrapper form-inline" + } ); + + /* API method to get paging information */ + $.fn.dataTableExt.oApi.fnPagingInfo = function ( oSettings ) + { + return { + "iStart": oSettings._iDisplayStart, + "iEnd": oSettings.fnDisplayEnd(), + "iLength": oSettings._iDisplayLength, + "iTotal": oSettings.fnRecordsTotal(), + "iFilteredTotal": oSettings.fnRecordsDisplay(), + "iPage": Math.ceil( oSettings._iDisplayStart / oSettings._iDisplayLength ), + "iTotalPages": Math.ceil( oSettings.fnRecordsDisplay() / oSettings._iDisplayLength ) + }; + } + + /* Bootstrap style pagination control */ + $.extend( $.fn.dataTableExt.oPagination, { + "bootstrap": { + "fnInit": function( oSettings, nPaging, fnDraw ) { + var oLang = oSettings.oLanguage.oPaginate; + var fnClickHandler = function ( e ) { + e.preventDefault(); + if ( oSettings.oApi._fnPageChange(oSettings, e.data.action) ) { + fnDraw( oSettings ); + } + }; + + $(nPaging).addClass('pagination').append( + '' + ); + var els = $('a', nPaging); + $(els[0]).bind( 'click.DT', { action: "previous" }, fnClickHandler ); + $(els[1]).bind( 'click.DT', { action: "next" }, fnClickHandler ); + }, + + "fnUpdate": function ( oSettings, fnDraw ) { + var iListLength = 5; + var oPaging = oSettings.oInstance.fnPagingInfo(); + var an = oSettings.aanFeatures.p; + var i, j, sClass, iStart, iEnd, iHalf=Math.floor(iListLength/2); + + if ( oPaging.iTotalPages < iListLength) { + iStart = 1; + iEnd = oPaging.iTotalPages; + } + else if ( oPaging.iPage <= iHalf ) { + iStart = 1; + iEnd = iListLength; + } else if ( oPaging.iPage >= (oPaging.iTotalPages-iHalf) ) { + iStart = oPaging.iTotalPages - iListLength + 1; + iEnd = oPaging.iTotalPages; + } else { + iStart = oPaging.iPage - iHalf + 1; + iEnd = iStart + iListLength - 1; + } + + for ( i=0, iLen=an.length ; i'+j+'') + .insertBefore( $('li:last', an[i])[0] ) + .bind('click', function (e) { + e.preventDefault(); + oSettings._iDisplayStart = (parseInt($('a', this).text(),10)-1) * oPaging.iLength; + fnDraw( oSettings ); + } ); + } + + // Add / remove disabled classes from the static elements + if ( oPaging.iPage === 0 ) { + $('li:first', an[i]).addClass('disabled'); + } else { + $('li:first', an[i]).removeClass('disabled'); + } + + if ( oPaging.iPage === oPaging.iTotalPages-1 || oPaging.iTotalPages === 0 ) { + $('li:last', an[i]).addClass('disabled'); + } else { + $('li:last', an[i]).removeClass('disabled'); + } + } + } + } + } ); + + $.extend($.fn.dataTableExt.oStdClasses, { + "sWrapper": "dataTables_wrapper form-inline" + }); + + /* + * TableTools Bootstrap compatibility + * Required TableTools 2.1+ + */ + if ( $.fn.DataTable.TableTools ) { + // Set the classes that TableTools uses to something suitable for Bootstrap + $.extend( true, $.fn.DataTable.TableTools.classes, { + "container": "DTTT btn-group", + "buttons": { + "normal": "btn", + "disabled": "disabled" + }, + "collection": { + "container": "DTTT_dropdown dropdown-menu", + "buttons": { + "normal": "", + "disabled": "disabled" + } + }, + "print": { + "info": "DTTT_print_info modal" + }, + "select": { + "row": "active" + } + } ); + + // Have the collection use a bootstrap compatible dropdown + $.extend( true, $.fn.DataTable.TableTools.DEFAULTS.oTags, { + "collection": { + "container": "ul", + "button": "li", + "liner": "a" + } + } ); + } +}); diff --git a/vendor/symposion/static/datatables/js/jquery.dataTables.min.js b/vendor/symposion/static/datatables/js/jquery.dataTables.min.js new file mode 100644 index 00000000..ce18c935 --- /dev/null +++ b/vendor/symposion/static/datatables/js/jquery.dataTables.min.js @@ -0,0 +1,154 @@ +/* + * File: jquery.dataTables.min.js + * Version: 1.9.2 + * Author: Allan Jardine (www.sprymedia.co.uk) + * Info: www.datatables.net + * + * Copyright 2008-2012 Allan Jardine, all rights reserved. + * + * This source file is free software, under either the GPL v2 license or a + * BSD style license, available at: + * http://datatables.net/license_gpl2 + * http://datatables.net/license_bsd + * + * This source file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the license files for details. + */ +(function(i,V,l,n){var j=function(e){function o(a,b){var c=j.defaults.columns,d=a.aoColumns.length,c=i.extend({},j.models.oColumn,c,{sSortingClass:a.oClasses.sSortable,sSortingClassJUI:a.oClasses.sSortJUI,nTh:b?b:l.createElement("th"),sTitle:c.sTitle?c.sTitle:b?b.innerHTML:"",aDataSort:c.aDataSort?c.aDataSort:[d],mDataProp:c.mDataProp?c.oDefaults:d});a.aoColumns.push(c);if(a.aoPreSearchCols[d]===n||null===a.aoPreSearchCols[d])a.aoPreSearchCols[d]=i.extend({},j.models.oSearch);else if(c=a.aoPreSearchCols[d], +c.bRegex===n&&(c.bRegex=!0),c.bSmart===n&&(c.bSmart=!0),c.bCaseInsensitive===n)c.bCaseInsensitive=!0;r(a,d,null)}function r(a,b,c){b=a.aoColumns[b];c!==n&&null!==c&&(c.sType!==n&&(b.sType=c.sType,b._bAutoType=!1),i.extend(b,c),p(b,c,"sWidth","sWidthOrig"),c.iDataSort!==n&&(b.aDataSort=[c.iDataSort]),p(b,c,"aDataSort"));b.fnGetData=W(b.mDataProp);b.fnSetData=ta(b.mDataProp);a.oFeatures.bSort||(b.bSortable=!1);!b.bSortable||-1==i.inArray("asc",b.asSorting)&&-1==i.inArray("desc",b.asSorting)?(b.sSortingClass= +a.oClasses.sSortableNone,b.sSortingClassJUI=""):b.bSortable||-1==i.inArray("asc",b.asSorting)&&-1==i.inArray("desc",b.asSorting)?(b.sSortingClass=a.oClasses.sSortable,b.sSortingClassJUI=a.oClasses.sSortJUI):-1!=i.inArray("asc",b.asSorting)&&-1==i.inArray("desc",b.asSorting)?(b.sSortingClass=a.oClasses.sSortableAsc,b.sSortingClassJUI=a.oClasses.sSortJUIAscAllowed):-1==i.inArray("asc",b.asSorting)&&-1!=i.inArray("desc",b.asSorting)&&(b.sSortingClass=a.oClasses.sSortableDesc,b.sSortingClassJUI=a.oClasses.sSortJUIDescAllowed)} +function k(a){if(!1===a.oFeatures.bAutoWidth)return!1;ba(a);for(var b=0,c=a.aoColumns.length;bm[f])d(a.aoColumns.length+m[f],b[g]);else if("string"===typeof m[f]){e=0;for(s=a.aoColumns.length;eb&&a[d]--; -1!=c&&a.splice(c,1)}function R(a,b,c){var d=a.aoColumns[c];return d.fnRender({iDataRow:b,iDataColumn:c,oSettings:a,aData:a.aoData[b]._aData,mDataProp:d.mDataProp},w(a,b,c,"display"))}function ca(a,b){var c=a.aoData[b],d;if(null===c.nTr){c.nTr=l.createElement("tr");c.nTr._DT_RowIndex=b;c._aData.DT_RowId&&(c.nTr.id=c._aData.DT_RowId);c._aData.DT_RowClass&&i(c.nTr).addClass(c._aData.DT_RowClass);for(var g=0,f=a.aoColumns.length;g=a.fnRecordsDisplay()?0:a.iInitDisplayStart,a.iInitDisplayStart=-1,A(a));if(a.bDeferLoading)a.bDeferLoading=!1,a.iDraw++;else if(a.oFeatures.bServerSide){if(!a.bDestroying&&!wa(a))return}else a.iDraw++;if(0!==a.aiDisplay.length){var h=a._iDisplayStart;d=a._iDisplayEnd;a.oFeatures.bServerSide&&(h=0,d=a.aoData.length);for(;h")[0];a.nTable.parentNode.insertBefore(b,a.nTable);a.nTableWrapper=i('
')[0];a.nTableReinsertBefore=a.nTable.nextSibling;for(var c=a.nTableWrapper,d=a.sDom.split(""),g,f,h,e,s,m,o,k=0;k")[0];s=d[k+1];if("'"==s||'"'==s){m="";for(o=2;d[k+o]!=s;)m+=d[k+o],o++;"H"==m?m=a.oClasses.sJUIHeader:"F"==m&&(m=a.oClasses.sJUIFooter); +-1!=m.indexOf(".")?(s=m.split("."),e.id=s[0].substr(1,s[0].length-1),e.className=s[1]):"#"==m.charAt(0)?e.id=m.substr(1,m.length-1):e.className=m;k+=o}c.appendChild(e);c=e}else if(">"==h)c=c.parentNode;else if("l"==h&&a.oFeatures.bPaginate&&a.oFeatures.bLengthChange)g=ya(a),f=1;else if("f"==h&&a.oFeatures.bFilter)g=za(a),f=1;else if("r"==h&&a.oFeatures.bProcessing)g=Aa(a),f=1;else if("t"==h)g=Ba(a),f=1;else if("i"==h&&a.oFeatures.bInfo)g=Ca(a),f=1;else if("p"==h&&a.oFeatures.bPaginate)g=Da(a),f=1; +else if(0!==j.ext.aoFeatures.length){e=j.ext.aoFeatures;o=0;for(s=e.length;o'): +""===c?'':c+' ',d=l.createElement("div");d.className=a.oClasses.sFilter;d.innerHTML="";a.aanFeatures.f||(d.id=a.sTableId+"_filter");c=i('input[type="text"]',d);d._DT_Input=c[0];c.val(b.sSearch.replace('"',"""));c.bind("keyup.DT",function(){for(var c=a.aanFeatures.f,d=this.value===""?"":this.value,h=0,e=c.length;h=b.length)a.aiDisplay.splice(0,a.aiDisplay.length),a.aiDisplay=a.aiDisplayMaster.slice();else if(a.aiDisplay.length==a.aiDisplayMaster.length||g.sSearch.length>b.length||1==c||0!==b.indexOf(g.sSearch)){a.aiDisplay.splice(0,a.aiDisplay.length);ja(a,1);for(b=0;b/g,""):"string"===typeof a?a.replace(/[\r\n]/g," "):a}function na(a){return a.replace(RegExp("(\\/|\\.|\\*|\\+|\\?|\\||\\(|\\)|\\[|\\]|\\{|\\}|\\\\|\\$|\\^|\\-)", +"g"),"\\$1")}function Ca(a){var b=l.createElement("div");b.className=a.oClasses.sInfo;a.aanFeatures.i||(a.aoDrawCallback.push({fn:Ja,sName:"information"}),b.id=a.sTableId+"_info");a.nTable.setAttribute("aria-describedby",a.sTableId+"_info");return b}function Ja(a){if(a.oFeatures.bInfo&&0!==a.aanFeatures.i.length){var b=a.oLanguage,c=a._iDisplayStart+1,d=a.fnDisplayEnd(),g=a.fnRecordsTotal(),f=a.fnRecordsDisplay(),h;h=0===f&&f==g?b.sInfoEmpty:0===f?b.sInfoEmpty+" "+b.sInfoFiltered:f==g?b.sInfo:b.sInfo+ +" "+b.sInfoFiltered;h+=b.sInfoPostFix;h=ha(a,h);null!==b.fnInfoCallback&&(h=b.fnInfoCallback.call(a.oInstance,a,c,d,g,f,h));a=a.aanFeatures.i;b=0;for(c=a.length;b",c,d,g=a.aLengthMenu;if(2==g.length&&"object"===typeof g[0]&&"object"===typeof g[1]){c=0;for(d=g[0].length;c'+g[1][c]+""}else{c=0;for(d=g.length;c'+g[c]+""}b+= +"";g=l.createElement("div");a.aanFeatures.l||(g.id=a.sTableId+"_length");g.className=a.oClasses.sLength;g.innerHTML="";i('select option[value="'+a._iDisplayLength+'"]',g).attr("selected",!0);i("select",g).bind("change.DT",function(){var b=i(this).val(),g=a.aanFeatures.l;c=0;for(d=g.length;ca.aiDisplay.length||-1==a._iDisplayLength?a.aiDisplay.length:a._iDisplayStart+a._iDisplayLength}function Da(a){if(a.oScroll.bInfinite)return null;var b=l.createElement("div");b.className=a.oClasses.sPaging+a.sPaginationType; +j.ext.oPagination[a.sPaginationType].fnInit(a,b,function(a){A(a);y(a)});a.aanFeatures.p||a.aoDrawCallback.push({fn:function(a){j.ext.oPagination[a.sPaginationType].fnUpdate(a,function(a){A(a);y(a)})},sName:"pagination"});return b}function pa(a,b){var c=a._iDisplayStart;if("number"===typeof b)a._iDisplayStart=b*a._iDisplayLength,a._iDisplayStart>a.fnRecordsDisplay()&&(a._iDisplayStart=0);else if("first"==b)a._iDisplayStart=0;else if("previous"==b)a._iDisplayStart=0<=a._iDisplayLength?a._iDisplayStart- +a._iDisplayLength:0,0>a._iDisplayStart&&(a._iDisplayStart=0);else if("next"==b)0<=a._iDisplayLength?a._iDisplayStart+a._iDisplayLengthi(a.nTable).height()-a.oScroll.iLoadGap&&a.fnDisplayEnd()=i.browser.version;i(a.nTable).children("thead, tfoot").remove();h=i(a.nTHead).clone()[0];a.nTable.insertBefore(h,a.nTable.childNodes[0]);null!==a.nTFoot&&(j=i(a.nTFoot).clone()[0],a.nTable.insertBefore(j,a.nTable.childNodes[1]));""===a.oScroll.sX&&(d.style.width="100%",b.parentNode.style.width="100%");var t=O(a,h);g=0;for(f=t.length;gd.offsetHeight||"scroll"==i(d).css("overflow-y")))a.nTable.style.width=q(i(a.nTable).outerWidth()-a.oScroll.iBarWidth)}else""!==a.oScroll.sXInner?a.nTable.style.width=q(a.oScroll.sXInner):g==i(d).width()&&i(d).height()g-a.oScroll.iBarWidth&& +(a.nTable.style.width=q(g))):a.nTable.style.width=q(g);g=i(a.nTable).outerWidth();f=a.nTHead.getElementsByTagName("tr");h=h.getElementsByTagName("tr");N(function(a,b){m=a.style;m.paddingTop="0";m.paddingBottom="0";m.borderTopWidth="0";m.borderBottomWidth="0";m.height=0;k=i(a).width();b.style.width=q(k);n.push(k)},h,f);i(h).height(0);null!==a.nTFoot&&(e=j.getElementsByTagName("tr"),j=a.nTFoot.getElementsByTagName("tr"),N(function(a,b){m=a.style;m.paddingTop="0";m.paddingBottom="0";m.borderTopWidth= +"0";m.borderBottomWidth="0";m.height=0;k=i(a).width();b.style.width=q(k);n.push(k)},e,j),i(e).height(0));N(function(a){a.innerHTML="";a.style.width=q(n.shift())},h);null!==a.nTFoot&&N(function(a){a.innerHTML="";a.style.width=q(n.shift())},e);if(i(a.nTable).outerWidth()d.offsetHeight||"scroll"==i(d).css("overflow-y")?g+a.oScroll.iBarWidth:g;if(l&&(d.scrollHeight>d.offsetHeight||"scroll"==i(d).css("overflow-y")))a.nTable.style.width=q(e-a.oScroll.iBarWidth);d.style.width=q(e);b.parentNode.style.width= +q(e);null!==a.nTFoot&&(r.parentNode.style.width=q(e));""===a.oScroll.sX?E(a,1,"The table cannot fit into the current element which will cause column misalignment. The table has been drawn at its minimum possible width."):""!==a.oScroll.sXInner&&E(a,1,"The table cannot fit into the current element which will cause column misalignment. Increase the sScrollXInner value or remove it to allow automatic calculation")}else d.style.width=q("100%"),b.parentNode.style.width=q("100%"),null!==a.nTFoot&&(r.parentNode.style.width= +q("100%"));""===a.oScroll.sY&&l&&(d.style.height=q(a.nTable.offsetHeight+a.oScroll.iBarWidth));""!==a.oScroll.sY&&a.oScroll.bCollapse&&(d.style.height=q(a.oScroll.sY),l=""!==a.oScroll.sX&&a.nTable.offsetWidth>d.offsetWidth?a.oScroll.iBarWidth:0,a.nTable.offsetHeightd.clientHeight||"scroll"==i(d).css("overflow-y");b.style.paddingRight=c?a.oScroll.iBarWidth+ +"px":"0px";null!==a.nTFoot&&(p.style.width=q(l),r.style.width=q(l),r.style.paddingRight=c?a.oScroll.iBarWidth+"px":"0px");i(d).scroll();if(a.bSorted||a.bFiltered)d.scrollTop=0}function N(a,b,c){for(var d=0,g=b.length;dtd",b));h=O(a,f);for(f=d=0;fc)return null;if(null===a.aoData[c].nTr){var d=l.createElement("td");d.innerHTML=w(a,c,b,"");return d}return L(a,c)[b]}function Oa(a,b){for(var c= +-1,d=-1,g=0;g/g,"");f.length>c&&(c=f.length,d=g)}return d}function q(a){if(null===a)return"0px";if("number"==typeof a)return 0>a?"0px":a+"px";var b=a.charCodeAt(a.length-1);return 48>b||57/g,""),g=l[c].nTh,g.removeAttribute("aria-sort"),g.removeAttribute("aria-label"),l[c].bSortable?0=h)for(b=0;be&&e++}}}function qa(a){if(a.oFeatures.bStateSave&&!a.bDestroying){var b,c;b=a.oScroll.bInfinite;var d={iCreate:(new Date).getTime(),iStart:b?0:a._iDisplayStart, +iEnd:b?a._iDisplayLength:a._iDisplayEnd,iLength:a._iDisplayLength,aaSorting:i.extend(!0,[],a.aaSorting),oSearch:i.extend(!0,{},a.oPreviousSearch),aoSearchCols:i.extend(!0,[],a.aoPreSearchCols),abVisCols:[]};b=0;for(c=a.aoColumns.length;b=d.fnRecordsDisplay()&&(d._iDisplayStart-=d._iDisplayLength,0>d._iDisplayStart&&(d._iDisplayStart=0));if(c===n||c)A(d),y(d);return h};this.fnDestroy=function(a){var b=u(this[j.ext.iApiIndex]),c=b.nTableWrapper.parentNode,d=b.nTBody,g,e,a=a===n?!1:!0;b.bDestroying=!0;C(b,"aoDestroyCallback","destroy",[b]);g=0;for(e=b.aoColumns.length;gtr>td."+b.oClasses.sRowEmpty,b.nTable).parent().remove();b.nTable!=b.nTHead.parentNode&&(i(b.nTable).children("thead").remove(),b.nTable.appendChild(b.nTHead));b.nTFoot&&b.nTable!=b.nTFoot.parentNode&&(i(b.nTable).children("tfoot").remove(),b.nTable.appendChild(b.nTFoot));b.nTable.parentNode.removeChild(b.nTable);i(b.nTableWrapper).remove();b.aaSorting=[];b.aaSortingFixed=[];Q(b);i(S(b)).removeClass(b.asStripeClasses.join(" ")); +i("th, td",b.nTHead).removeClass([b.oClasses.sSortable,b.oClasses.sSortableAsc,b.oClasses.sSortableDesc,b.oClasses.sSortableNone].join(" "));b.bJUI&&(i("th span."+b.oClasses.sSortIcon+", td span."+b.oClasses.sSortIcon,b.nTHead).remove(),i("th, td",b.nTHead).each(function(){var a=i("div."+b.oClasses.sSortJUIWrapper,this),c=a.contents();i(this).append(c);a.remove()}));!a&&b.nTableReinsertBefore?c.insertBefore(b.nTable,b.nTableReinsertBefore):a||c.appendChild(b.nTable);g=0;for(e=b.aoData.length;g=v(d);if(!m)for(e=a;et<"F"ip>')):i.extend(h.oClasses,j.ext.oStdClasses);i(this).addClass(h.oClasses.sTable); +if(""!==h.oScroll.sX||""!==h.oScroll.sY)h.oScroll.iBarWidth=Pa();h.iInitDisplayStart===n&&(h.iInitDisplayStart=e.iDisplayStart,h._iDisplayStart=e.iDisplayStart);e.bStateSave&&(h.oFeatures.bStateSave=!0,Ra(h,e),B(h,"aoDrawCallback",qa,"state_save"));null!==e.iDeferLoading&&(h.bDeferLoading=!0,a=i.isArray(e.iDeferLoading),h._iRecordsDisplay=a?e.iDeferLoading[0]:e.iDeferLoading,h._iRecordsTotal=a?e.iDeferLoading[1]:e.iDeferLoading);null!==e.aaData&&(f=!0);""!==e.oLanguage.sUrl?(h.oLanguage.sUrl=e.oLanguage.sUrl, +i.getJSON(h.oLanguage.sUrl,null,function(a){oa(a);i.extend(true,h.oLanguage,e.oLanguage,a);aa(h)}),g=!0):i.extend(!0,h.oLanguage,e.oLanguage);null===e.asStripeClasses&&(h.asStripeClasses=[h.oClasses.sStripeOdd,h.oClasses.sStripeEven]);c=!1;d=i(this).children("tbody").children("tr");a=0;for(b=h.asStripeClasses.length;a=h.aoColumns.length&&(h.aaSorting[a][0]=0);var k=h.aoColumns[h.aaSorting[a][0]];h.aaSorting[a][2]===n&&(h.aaSorting[a][2]=0);e.aaSorting===n&&h.saved_aaSorting===n&&(h.aaSorting[a][1]=k.asSorting[0]);c=0;for(d=k.asSorting.length;c=parseInt(n,10)};j.fnIsDataTable=function(e){for(var i=j.settings,r=0;re)return e;for(var i=e+"",e=i.split(""),j="",i=i.length,k=0;k'+k.sPrevious+''+k.sNext+"":'';i(j).append(k);var t=i("a",j),k=t[0],t=t[1];e.oApi._fnBindAction(k,{action:"previous"},l);e.oApi._fnBindAction(t,{action:"next"},l); +e.aanFeatures.p||(j.id=e.sTableId+"_paginate",k.id=e.sTableId+"_previous",t.id=e.sTableId+"_next",k.setAttribute("aria-controls",e.sTableId),t.setAttribute("aria-controls",e.sTableId))},fnUpdate:function(e){if(e.aanFeatures.p)for(var i=e.oClasses,j=e.aanFeatures.p,k=0,n=j.length;k'+k.sFirst+''+k.sPrevious+''+k.sNext+''+k.sLast+"");var v=i("a",j),k=v[0],l=v[1],z=v[2],v=v[3];e.oApi._fnBindAction(k,{action:"first"},t);e.oApi._fnBindAction(l,{action:"previous"},t);e.oApi._fnBindAction(z,{action:"next"},t);e.oApi._fnBindAction(v,{action:"last"},t);e.aanFeatures.p||(j.id=e.sTableId+"_paginate",k.id=e.sTableId+"_first",l.id=e.sTableId+"_previous",z.id=e.sTableId+"_next",v.id=e.sTableId+"_last")},fnUpdate:function(e,o){if(e.aanFeatures.p){var l=j.ext.oPagination.iFullNumbersShowPages,k=Math.floor(l/ +2),n=Math.ceil(e.fnRecordsDisplay()/e._iDisplayLength),t=Math.ceil(e._iDisplayStart/e._iDisplayLength)+1,v="",z,D=e.oClasses,x,J=e.aanFeatures.p,H=function(i){e.oApi._fnBindAction(this,{page:i+z-1},function(i){e.oApi._fnPageChange(e,i.data.page);o(e);i.preventDefault()})};-1===e._iDisplayLength?t=k=z=1:n=n-k?(z=n-l+1,k=n):(z=t-Math.ceil(l/2)+1,k=z+l-1);for(l=z;l<=k;l++)v+=t!==l?''+e.fnFormatNumber(l)+"":''+e.fnFormatNumber(l)+"";l=0;for(k=J.length;li?1:0},"string-desc":function(e,i){return ei?-1:0},"html-pre":function(e){return e.replace(/<.*?>/g,"").toLowerCase()},"html-asc":function(e,i){return ei?1:0},"html-desc":function(e,i){return ei?-1:0},"date-pre":function(e){e=Date.parse(e);if(isNaN(e)||""===e)e=Date.parse("01/01/1970 00:00:00"); +return e},"date-asc":function(e,i){return e-i},"date-desc":function(e,i){return i-e},"numeric-pre":function(e){return"-"==e||""===e?0:1*e},"numeric-asc":function(e,i){return e-i},"numeric-desc":function(e,i){return i-e}});i.extend(j.ext.aTypes,[function(e){if("number"===typeof e)return"numeric";if("string"!==typeof e)return null;var i,j=!1;i=e.charAt(0);if(-1=="0123456789-".indexOf(i))return null;for(var k=1;k")?"html":null}]);i.fn.DataTable=j;i.fn.dataTable=j;i.fn.dataTableSettings=j.settings;i.fn.dataTableExt=j.ext})(jQuery,window,document,void 0); diff --git a/vendor/symposion/static/tabletools/js/TableTools.js b/vendor/symposion/static/tabletools/js/TableTools.js new file mode 100755 index 00000000..162b1c5e --- /dev/null +++ b/vendor/symposion/static/tabletools/js/TableTools.js @@ -0,0 +1,2424 @@ +/* + * File: TableTools.js + * Version: 2.1.3 + * Description: Tools and buttons for DataTables + * Author: Allan Jardine (www.sprymedia.co.uk) + * Language: Javascript + * License: GPL v2 or BSD 3 point style + * Project: DataTables + * + * Copyright 2009-2012 Allan Jardine, all rights reserved. + * + * This source file is free software, under either the GPL v2 license or a + * BSD style license, available at: + * http://datatables.net/license_gpl2 + * http://datatables.net/license_bsd + */ + +/* Global scope for TableTools */ +var TableTools; + +(function($, window, document) { + +/** + * TableTools provides flexible buttons and other tools for a DataTables enhanced table + * @class TableTools + * @constructor + * @param {Object} oDT DataTables instance + * @param {Object} oOpts TableTools options + * @param {String} oOpts.sSwfPath ZeroClipboard SWF path + * @param {String} oOpts.sRowSelect Row selection options - 'none', 'single' or 'multi' + * @param {Function} oOpts.fnPreRowSelect Callback function just prior to row selection + * @param {Function} oOpts.fnRowSelected Callback function just after row selection + * @param {Function} oOpts.fnRowDeselected Callback function when row is deselected + * @param {Array} oOpts.aButtons List of buttons to be used + */ +TableTools = function( oDT, oOpts ) +{ + /* Santiy check that we are a new instance */ + if ( ! this instanceof TableTools ) + { + alert( "Warning: TableTools must be initialised with the keyword 'new'" ); + } + + + /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * Public class variables + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + + /** + * @namespace Settings object which contains customisable information for TableTools instance + */ + this.s = { + /** + * Store 'this' so the instance can be retrieved from the settings object + * @property that + * @type object + * @default this + */ + "that": this, + + /** + * DataTables settings objects + * @property dt + * @type object + * @default From the oDT init option + */ + "dt": oDT.fnSettings(), + + /** + * @namespace Print specific information + */ + "print": { + /** + * DataTables draw 'start' point before the printing display was shown + * @property saveStart + * @type int + * @default -1 + */ + "saveStart": -1, + + /** + * DataTables draw 'length' point before the printing display was shown + * @property saveLength + * @type int + * @default -1 + */ + "saveLength": -1, + + /** + * Page scrolling point before the printing display was shown so it can be restored + * @property saveScroll + * @type int + * @default -1 + */ + "saveScroll": -1, + + /** + * Wrapped function to end the print display (to maintain scope) + * @property funcEnd + * @type Function + * @default function () {} + */ + "funcEnd": function () {} + }, + + /** + * A unique ID is assigned to each button in each instance + * @property buttonCounter + * @type int + * @default 0 + */ + "buttonCounter": 0, + + /** + * @namespace Select rows specific information + */ + "select": { + /** + * Select type - can be 'none', 'single' or 'multi' + * @property type + * @type string + * @default "" + */ + "type": "", + + /** + * Array of nodes which are currently selected + * @property selected + * @type array + * @default [] + */ + "selected": [], + + /** + * Function to run before the selection can take place. Will cancel the select if the + * function returns false + * @property preRowSelect + * @type Function + * @default null + */ + "preRowSelect": null, + + /** + * Function to run when a row is selected + * @property postSelected + * @type Function + * @default null + */ + "postSelected": null, + + /** + * Function to run when a row is deselected + * @property postDeselected + * @type Function + * @default null + */ + "postDeselected": null, + + /** + * Indicate if all rows are selected (needed for server-side processing) + * @property all + * @type boolean + * @default false + */ + "all": false, + + /** + * Class name to add to selected TR nodes + * @property selectedClass + * @type String + * @default "" + */ + "selectedClass": "" + }, + + /** + * Store of the user input customisation object + * @property custom + * @type object + * @default {} + */ + "custom": {}, + + /** + * SWF movie path + * @property swfPath + * @type string + * @default "" + */ + "swfPath": "", + + /** + * Default button set + * @property buttonSet + * @type array + * @default [] + */ + "buttonSet": [], + + /** + * When there is more than one TableTools instance for a DataTable, there must be a + * master which controls events (row selection etc) + * @property master + * @type boolean + * @default false + */ + "master": false, + + /** + * Tag names that are used for creating collections and buttons + * @namesapce + */ + "tags": {} + }; + + + /** + * @namespace Common and useful DOM elements for the class instance + */ + this.dom = { + /** + * DIV element that is create and all TableTools buttons (and their children) put into + * @property container + * @type node + * @default null + */ + "container": null, + + /** + * The table node to which TableTools will be applied + * @property table + * @type node + * @default null + */ + "table": null, + + /** + * @namespace Nodes used for the print display + */ + "print": { + /** + * Nodes which have been removed from the display by setting them to display none + * @property hidden + * @type array + * @default [] + */ + "hidden": [], + + /** + * The information display saying telling the user about the print display + * @property message + * @type node + * @default null + */ + "message": null + }, + + /** + * @namespace Nodes used for a collection display. This contains the currently used collection + */ + "collection": { + /** + * The div wrapper containing the buttons in the collection (i.e. the menu) + * @property collection + * @type node + * @default null + */ + "collection": null, + + /** + * Background display to provide focus and capture events + * @property background + * @type node + * @default null + */ + "background": null + } + }; + + /** + * @namespace Name space for the classes that this TableTools instance will use + * @extends TableTools.classes + */ + this.classes = $.extend( true, {}, TableTools.classes ); + if ( this.s.dt.bJUI ) + { + $.extend( true, this.classes, TableTools.classes_themeroller ); + } + + + /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * Public class methods + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + + /** + * Retreieve the settings object from an instance + * @method fnSettings + * @returns {object} TableTools settings object + */ + this.fnSettings = function () { + return this.s; + }; + + + /* Constructor logic */ + if ( typeof oOpts == 'undefined' ) + { + oOpts = {}; + } + + this._fnConstruct( oOpts ); + + return this; +}; + + + +TableTools.prototype = { + /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * Public methods + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + + /** + * Retreieve the settings object from an instance + * @returns {array} List of TR nodes which are currently selected + */ + "fnGetSelected": function () + { + var out=[]; + var data=this.s.dt.aoData; + var i, iLen; + + for ( i=0, iLen=data.length ; i 0 ) + { + sTitle = anTitle[0].innerHTML; + } + } + + /* Strip characters which the OS will object to - checking for UTF8 support in the scripting + * engine + */ + if ( "\u00A1".toString().length < 4 ) { + return sTitle.replace(/[^a-zA-Z0-9_\u00A1-\uFFFF\.,\-_ !\(\)]/g, ""); + } else { + return sTitle.replace(/[^a-zA-Z0-9_\.,\-_ !\(\)]/g, ""); + } + }, + + + /** + * Calculate a unity array with the column width by proportion for a set of columns to be + * included for a button. This is particularly useful for PDF creation, where we can use the + * column widths calculated by the browser to size the columns in the PDF. + * @param {Object} oConfig Button configuration object + * @returns {Array} Unity array of column ratios + */ + "fnCalcColRatios": function ( oConfig ) + { + var + aoCols = this.s.dt.aoColumns, + aColumnsInc = this._fnColumnTargets( oConfig.mColumns ), + aColWidths = [], + iWidth = 0, iTotal = 0, i, iLen; + + for ( i=0, iLen=aColumnsInc.length ; i