\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\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/(?PCopied '+len+' row'+plural+' to the clipboard.
', + 1500 + ); + } + } ), + + "pdf": $.extend( {}, TableTools.buttonBase, { + "sAction": "flash_pdf", + "sNewLine": "\n", + "sFileName": "*.pdf", + "sButtonClass": "DTTT_button_pdf", + "sButtonText": "PDF", + "sPdfOrientation": "portrait", + "sPdfSize": "A4", + "sPdfMessage": "", + "fnClick": function( nButton, oConfig, flash ) { + this.fnSetText( flash, + "title:"+ this.fnGetTitle(oConfig) +"\n"+ + "message:"+ oConfig.sPdfMessage +"\n"+ + "colWidth:"+ this.fnCalcColRatios(oConfig) +"\n"+ + "orientation:"+ oConfig.sPdfOrientation +"\n"+ + "size:"+ oConfig.sPdfSize +"\n"+ + "--/TableToolsOpts--\n" + + this.fnGetTableData(oConfig) + ); + } + } ), + + "print": $.extend( {}, TableTools.buttonBase, { + "sInfo": "Please use your browser's print function to "+
+ "print this table. Press escape when finished.",
+ "sMessage": null,
+ "bShowAll": true,
+ "sToolTip": "View print view",
+ "sButtonClass": "DTTT_button_print",
+ "sButtonText": "Print",
+ "fnClick": function ( nButton, oConfig ) {
+ this.fnPrint( true, oConfig );
+ }
+ } ),
+
+ "text": $.extend( {}, TableTools.buttonBase ),
+
+ "select": $.extend( {}, TableTools.buttonBase, {
+ "sButtonText": "Select button",
+ "fnSelect": function( nButton, oConfig ) {
+ if ( this.fnGetSelected().length !== 0 ) {
+ $(nButton).removeClass( this.classes.buttons.disabled );
+ } else {
+ $(nButton).addClass( this.classes.buttons.disabled );
+ }
+ },
+ "fnInit": function( nButton, oConfig ) {
+ $(nButton).addClass( this.classes.buttons.disabled );
+ }
+ } ),
+
+ "select_single": $.extend( {}, TableTools.buttonBase, {
+ "sButtonText": "Select button",
+ "fnSelect": function( nButton, oConfig ) {
+ var iSelected = this.fnGetSelected().length;
+ if ( iSelected == 1 ) {
+ $(nButton).removeClass( this.classes.buttons.disabled );
+ } else {
+ $(nButton).addClass( this.classes.buttons.disabled );
+ }
+ },
+ "fnInit": function( nButton, oConfig ) {
+ $(nButton).addClass( this.classes.buttons.disabled );
+ }
+ } ),
+
+ "select_all": $.extend( {}, TableTools.buttonBase, {
+ "sButtonText": "Select all",
+ "fnClick": function( nButton, oConfig ) {
+ this.fnSelectAll();
+ },
+ "fnSelect": function( nButton, oConfig ) {
+ if ( this.fnGetSelected().length == this.s.dt.fnRecordsDisplay() ) {
+ $(nButton).addClass( this.classes.buttons.disabled );
+ } else {
+ $(nButton).removeClass( this.classes.buttons.disabled );
+ }
+ }
+ } ),
+
+ "select_none": $.extend( {}, TableTools.buttonBase, {
+ "sButtonText": "Deselect all",
+ "fnClick": function( nButton, oConfig ) {
+ this.fnSelectNone();
+ },
+ "fnSelect": function( nButton, oConfig ) {
+ if ( this.fnGetSelected().length !== 0 ) {
+ $(nButton).removeClass( this.classes.buttons.disabled );
+ } else {
+ $(nButton).addClass( this.classes.buttons.disabled );
+ }
+ },
+ "fnInit": function( nButton, oConfig ) {
+ $(nButton).addClass( this.classes.buttons.disabled );
+ }
+ } ),
+
+ "ajax": $.extend( {}, TableTools.buttonBase, {
+ "sAjaxUrl": "/xhr.php",
+ "sButtonText": "Ajax button",
+ "fnClick": function( nButton, oConfig ) {
+ var sData = this.fnGetTableData(oConfig);
+ $.ajax( {
+ "url": oConfig.sAjaxUrl,
+ "data": [
+ { "name": "tableData", "value": sData }
+ ],
+ "success": oConfig.fnAjaxComplete,
+ "dataType": "json",
+ "type": "POST",
+ "cache": false,
+ "error": function () {
+ alert( "Error detected when sending table data to server" );
+ }
+ } );
+ },
+ "fnAjaxComplete": function( json ) {
+ alert( 'Ajax complete' );
+ }
+ } ),
+
+ "div": $.extend( {}, TableTools.buttonBase, {
+ "sAction": "div",
+ "sTag": "div",
+ "sButtonClass": "DTTT_nonbutton",
+ "sButtonText": "Text button"
+ } ),
+
+ "collection": $.extend( {}, TableTools.buttonBase, {
+ "sAction": "collection",
+ "sButtonClass": "DTTT_button_collection",
+ "sButtonText": "Collection",
+ "fnClick": function( nButton, oConfig ) {
+ this._fnCollectionShow(nButton, oConfig);
+ }
+ } )
+};
+/*
+ * on* callback parameters:
+ * 1. node - button element
+ * 2. object - configuration object for this button
+ * 3. object - ZeroClipboard reference (flash button only)
+ * 4. string - Returned string from Flash (flash button only - and only on 'complete')
+ */
+
+
+
+/**
+ * @namespace Classes used by TableTools - allows the styles to be override easily.
+ * Note that when TableTools initialises it will take a copy of the classes object
+ * and will use its internal copy for the remainder of its run time.
+ */
+TableTools.classes = {
+ "container": "DTTT_container",
+ "buttons": {
+ "normal": "DTTT_button",
+ "disabled": "DTTT_disabled"
+ },
+ "collection": {
+ "container": "DTTT_collection",
+ "background": "DTTT_collection_background",
+ "buttons": {
+ "normal": "DTTT_button",
+ "disabled": "DTTT_disabled"
+ }
+ },
+ "select": {
+ "table": "DTTT_selectable",
+ "row": "DTTT_selected"
+ },
+ "print": {
+ "body": "DTTT_Print",
+ "info": "DTTT_print_info",
+ "message": "DTTT_PrintMessage"
+ }
+};
+
+
+/**
+ * @namespace ThemeRoller classes - built in for compatibility with DataTables'
+ * bJQueryUI option.
+ */
+TableTools.classes_themeroller = {
+ "container": "DTTT_container ui-buttonset ui-buttonset-multi",
+ "buttons": {
+ "normal": "DTTT_button ui-button ui-state-default"
+ },
+ "collection": {
+ "container": "DTTT_collection ui-buttonset ui-buttonset-multi"
+ }
+};
+
+
+/**
+ * @namespace TableTools default settings for initialisation
+ */
+TableTools.DEFAULTS = {
+ "sSwfPath": "media/swf/copy_csv_xls_pdf.swf",
+ "sRowSelect": "none",
+ "sSelectedClass": null,
+ "fnPreRowSelect": null,
+ "fnRowSelected": null,
+ "fnRowDeselected": null,
+ "aButtons": [ "copy", "csv", "xls", "pdf", "print" ],
+ "oTags": {
+ "container": "div",
+ "button": "a", // We really want to use buttons here, but Firefox and IE ignore the
+ // click on the Flash element in the button (but not mouse[in|out]).
+ "liner": "span",
+ "collection": {
+ "container": "div",
+ "button": "a",
+ "liner": "span"
+ }
+ }
+};
+
+
+/**
+ * Name of this class
+ * @constant CLASS
+ * @type String
+ * @default TableTools
+ */
+TableTools.prototype.CLASS = "TableTools";
+
+
+/**
+ * TableTools version
+ * @constant VERSION
+ * @type String
+ * @default See code
+ */
+TableTools.VERSION = "2.1.3";
+TableTools.prototype.VERSION = TableTools.VERSION;
+
+
+
+
+/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ * Initialisation
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+/*
+ * Register a new feature with DataTables
+ */
+if ( typeof $.fn.dataTable == "function" &&
+ typeof $.fn.dataTableExt.fnVersionCheck == "function" &&
+ $.fn.dataTableExt.fnVersionCheck('1.9.0') )
+{
+ $.fn.dataTableExt.aoFeatures.push( {
+ "fnInit": function( oDTSettings ) {
+ var oOpts = typeof oDTSettings.oInit.oTableTools != 'undefined' ?
+ oDTSettings.oInit.oTableTools : {};
+
+ var oTT = new TableTools( oDTSettings.oInstance, oOpts );
+ TableTools._aInstances.push( oTT );
+
+ return oTT.dom.container;
+ },
+ "cFeature": "T",
+ "sFeature": "TableTools"
+ } );
+}
+else
+{
+ alert( "Warning: TableTools 2 requires DataTables 1.9.0 or newer - www.datatables.net/download");
+}
+
+$.fn.DataTable.TableTools = TableTools;
+
+})(jQuery, window, document);
diff --git a/vendor/symposion/static/tabletools/js/TableTools.min.js b/vendor/symposion/static/tabletools/js/TableTools.min.js
new file mode 100644
index 00000000..2440a36d
--- /dev/null
+++ b/vendor/symposion/static/tabletools/js/TableTools.min.js
@@ -0,0 +1,76 @@
+// Simple Set Clipboard System
+// Author: Joseph Huckaby
+var ZeroClipboard_TableTools={version:"1.0.4-TableTools2",clients:{},moviePath:"",nextId:1,$:function(a){"string"==typeof a&&(a=document.getElementById(a));a.addClass||(a.hide=function(){this.style.display="none"},a.show=function(){this.style.display=""},a.addClass=function(a){this.removeClass(a);this.className+=" "+a},a.removeClass=function(a){this.className=this.className.replace(RegExp("\\s*"+a+"\\s*")," ").replace(/^\s+/,"").replace(/\s+$/,"")},a.hasClass=function(a){return!!this.className.match(RegExp("\\s*"+
+a+"\\s*"))});return a},setMoviePath:function(a){this.moviePath=a},dispatch:function(a,b,c){(a=this.clients[a])&&a.receiveEvent(b,c)},register:function(a,b){this.clients[a]=b},getDOMObjectPosition:function(a){var b={left:0,top:0,width:a.width?a.width:a.offsetWidth,height:a.height?a.height:a.offsetHeight};""!=a.style.width&&(b.width=a.style.width.replace("px",""));""!=a.style.height&&(b.height=a.style.height.replace("px",""));for(;a;)b.left+=a.offsetLeft,b.top+=a.offsetTop,a=a.offsetParent;return b},
+Client:function(a){this.handlers={};this.id=ZeroClipboard_TableTools.nextId++;this.movieId="ZeroClipboard_TableToolsMovie_"+this.id;ZeroClipboard_TableTools.register(this.id,this);a&&this.glue(a)}};
+ZeroClipboard_TableTools.Client.prototype={id:0,ready:!1,movie:null,clipText:"",fileName:"",action:"copy",handCursorEnabled:!0,cssEffects:!0,handlers:null,sized:!1,glue:function(a,b){this.domElement=ZeroClipboard_TableTools.$(a);var c=99;this.domElement.style.zIndex&&(c=parseInt(this.domElement.style.zIndex)+1);var d=ZeroClipboard_TableTools.getDOMObjectPosition(this.domElement);this.div=document.createElement("div");var e=this.div.style;e.position="absolute";e.left="0px";e.top="0px";e.width=d.width+
+"px";e.height=d.height+"px";e.zIndex=c;"undefined"!=typeof b&&""!=b&&(this.div.title=b);0!=d.width&&0!=d.height&&(this.sized=!0);this.domElement&&(this.domElement.appendChild(this.div),this.div.innerHTML=this.getHTML(d.width,d.height))},positionElement:function(){var a=ZeroClipboard_TableTools.getDOMObjectPosition(this.domElement),b=this.div.style;b.position="absolute";b.width=a.width+"px";b.height=a.height+"px";0!=a.width&&0!=a.height&&(this.sized=!0,b=this.div.childNodes[0],b.width=a.width,b.height=
+a.height)},getHTML:function(a,b){var c="",d="id="+this.id+"&width="+a+"&height="+b;if(navigator.userAgent.match(/MSIE/))var e=location.href.match(/^https/i)?"https://":"http://",c=c+('');else c+='';return c},hide:function(){this.div&&(this.div.style.left="-2000px")},show:function(){this.reposition()},destroy:function(){if(this.domElement&&this.div){this.hide();this.div.innerHTML="";var a=document.getElementsByTagName("body")[0];try{a.removeChild(this.div)}catch(b){}this.div=this.domElement=null}},reposition:function(a){a&&((this.domElement=ZeroClipboard_TableTools.$(a))||this.hide());if(this.domElement&&this.div){var a=ZeroClipboard_TableTools.getDOMObjectPosition(this.domElement),
+b=this.div.style;b.left=""+a.left+"px";b.top=""+a.top+"px"}},clearText:function(){this.clipText="";this.ready&&this.movie.clearText()},appendText:function(a){this.clipText+=a;this.ready&&this.movie.appendText(a)},setText:function(a){this.clipText=a;this.ready&&this.movie.setText(a)},setCharSet:function(a){this.charSet=a;this.ready&&this.movie.setCharSet(a)},setBomInc:function(a){this.incBom=a;this.ready&&this.movie.setBomInc(a)},setFileName:function(a){this.fileName=a;this.ready&&this.movie.setFileName(a)},
+setAction:function(a){this.action=a;this.ready&&this.movie.setAction(a)},addEventListener:function(a,b){a=a.toString().toLowerCase().replace(/^on/,"");this.handlers[a]||(this.handlers[a]=[]);this.handlers[a].push(b)},setHandCursor:function(a){this.handCursorEnabled=a;this.ready&&this.movie.setHandCursor(a)},setCSSEffects:function(a){this.cssEffects=!!a},receiveEvent:function(a,b){a=a.toString().toLowerCase().replace(/^on/,"");switch(a){case "load":this.movie=document.getElementById(this.movieId);
+if(!this.movie){var c=this;setTimeout(function(){c.receiveEvent("load",null)},1);return}if(!this.ready&&navigator.userAgent.match(/Firefox/)&&navigator.userAgent.match(/Windows/)){c=this;setTimeout(function(){c.receiveEvent("load",null)},100);this.ready=!0;return}this.ready=!0;this.movie.clearText();this.movie.appendText(this.clipText);this.movie.setFileName(this.fileName);this.movie.setAction(this.action);this.movie.setCharSet(this.charSet);this.movie.setBomInc(this.incBom);this.movie.setHandCursor(this.handCursorEnabled);
+break;case "mouseover":this.domElement&&this.cssEffects&&this.recoverActive&&this.domElement.addClass("active");break;case "mouseout":this.domElement&&this.cssEffects&&(this.recoverActive=!1,this.domElement.hasClass("active")&&(this.domElement.removeClass("active"),this.recoverActive=!0));break;case "mousedown":this.domElement&&this.cssEffects&&this.domElement.addClass("active");break;case "mouseup":this.domElement&&this.cssEffects&&(this.domElement.removeClass("active"),this.recoverActive=!1)}if(this.handlers[a])for(var d=
+0,e=this.handlers[a].length;d Copied "+a+" row"+(1==a?"":"s")+" to the clipboard. Please use your browser's print function to print this table. Press escape when finished.",sMessage:null,bShowAll:!0,sToolTip:"View print view",sButtonClass:"DTTT_button_print",sButtonText:"Print",fnClick:function(a,b){this.fnPrint(!0,b)}}),text:e.extend({},TableTools.buttonBase),select:e.extend({},
+TableTools.buttonBase,{sButtonText:"Select button",fnSelect:function(a){0!==this.fnGetSelected().length?e(a).removeClass(this.classes.buttons.disabled):e(a).addClass(this.classes.buttons.disabled)},fnInit:function(a){e(a).addClass(this.classes.buttons.disabled)}}),select_single:e.extend({},TableTools.buttonBase,{sButtonText:"Select button",fnSelect:function(a){1==this.fnGetSelected().length?e(a).removeClass(this.classes.buttons.disabled):e(a).addClass(this.classes.buttons.disabled)},fnInit:function(a){e(a).addClass(this.classes.buttons.disabled)}}),
+select_all:e.extend({},TableTools.buttonBase,{sButtonText:"Select all",fnClick:function(){this.fnSelectAll()},fnSelect:function(a){this.fnGetSelected().length==this.s.dt.fnRecordsDisplay()?e(a).addClass(this.classes.buttons.disabled):e(a).removeClass(this.classes.buttons.disabled)}}),select_none:e.extend({},TableTools.buttonBase,{sButtonText:"Deselect all",fnClick:function(){this.fnSelectNone()},fnSelect:function(a){0!==this.fnGetSelected().length?e(a).removeClass(this.classes.buttons.disabled):e(a).addClass(this.classes.buttons.disabled)},
+fnInit:function(a){e(a).addClass(this.classes.buttons.disabled)}}),ajax:e.extend({},TableTools.buttonBase,{sAjaxUrl:"/xhr.php",sButtonText:"Ajax button",fnClick:function(a,b){var c=this.fnGetTableData(b);e.ajax({url:b.sAjaxUrl,data:[{name:"tableData",value:c}],success:b.fnAjaxComplete,dataType:"json",type:"POST",cache:!1,error:function(){alert("Error detected when sending table data to server")}})},fnAjaxComplete:function(){alert("Ajax complete")}}),div:e.extend({},TableTools.buttonBase,{sAction:"div",
+sTag:"div",sButtonClass:"DTTT_nonbutton",sButtonText:"Text button"}),collection:e.extend({},TableTools.buttonBase,{sAction:"collection",sButtonClass:"DTTT_button_collection",sButtonText:"Collection",fnClick:function(a,b){this._fnCollectionShow(a,b)}})};TableTools.classes={container:"DTTT_container",buttons:{normal:"DTTT_button",disabled:"DTTT_disabled"},collection:{container:"DTTT_collection",background:"DTTT_collection_background",buttons:{normal:"DTTT_button",disabled:"DTTT_disabled"}},select:{table:"DTTT_selectable",
+row:"DTTT_selected"},print:{body:"DTTT_Print",info:"DTTT_print_info",message:"DTTT_PrintMessage"}};TableTools.classes_themeroller={container:"DTTT_container ui-buttonset ui-buttonset-multi",buttons:{normal:"DTTT_button ui-button ui-state-default"},collection:{container:"DTTT_collection ui-buttonset ui-buttonset-multi"}};TableTools.DEFAULTS={sSwfPath:"media/swf/copy_csv_xls_pdf.swf",sRowSelect:"none",sSelectedClass:null,fnPreRowSelect:null,fnRowSelected:null,fnRowDeselected:null,aButtons:["copy","csv",
+"xls","pdf","print"],oTags:{container:"div",button:"a",liner:"span",collection:{container:"div",button:"a",liner:"span"}}};TableTools.prototype.CLASS="TableTools";TableTools.VERSION="2.1.3";TableTools.prototype.VERSION=TableTools.VERSION;"function"==typeof e.fn.dataTable&&"function"==typeof e.fn.dataTableExt.fnVersionCheck&&e.fn.dataTableExt.fnVersionCheck("1.9.0")?e.fn.dataTableExt.aoFeatures.push({fnInit:function(a){a=new TableTools(a.oInstance,"undefined"!=typeof a.oInit.oTableTools?a.oInit.oTableTools:
+{});TableTools._aInstances.push(a);return a.dom.container},cFeature:"T",sFeature:"TableTools"}):alert("Warning: TableTools 2 requires DataTables 1.9.0 or newer - www.datatables.net/download");e.fn.DataTable.TableTools=TableTools})(jQuery,window,document);
diff --git a/vendor/symposion/static/tabletools/js/TableTools.min.js.gz b/vendor/symposion/static/tabletools/js/TableTools.min.js.gz
new file mode 100644
index 00000000..16e55c23
Binary files /dev/null and b/vendor/symposion/static/tabletools/js/TableTools.min.js.gz differ
diff --git a/vendor/symposion/static/tabletools/js/ZeroClipboard.js b/vendor/symposion/static/tabletools/js/ZeroClipboard.js
new file mode 100755
index 00000000..de0f6b67
--- /dev/null
+++ b/vendor/symposion/static/tabletools/js/ZeroClipboard.js
@@ -0,0 +1,367 @@
+// Simple Set Clipboard System
+// Author: Joseph Huckaby
+
+var ZeroClipboard_TableTools = {
+
+ version: "1.0.4-TableTools2",
+ clients: {}, // registered upload clients on page, indexed by id
+ moviePath: '', // URL to movie
+ nextId: 1, // ID of next movie
+
+ $: function(thingy) {
+ // simple DOM lookup utility function
+ if (typeof(thingy) == 'string') thingy = document.getElementById(thingy);
+ if (!thingy.addClass) {
+ // extend element with a few useful methods
+ thingy.hide = function() { this.style.display = 'none'; };
+ thingy.show = function() { this.style.display = ''; };
+ thingy.addClass = function(name) { this.removeClass(name); this.className += ' ' + name; };
+ thingy.removeClass = function(name) {
+ this.className = this.className.replace( new RegExp("\\s*" + name + "\\s*"), " ").replace(/^\s+/, '').replace(/\s+$/, '');
+ };
+ thingy.hasClass = function(name) {
+ return !!this.className.match( new RegExp("\\s*" + name + "\\s*") );
+ }
+ }
+ return thingy;
+ },
+
+ setMoviePath: function(path) {
+ // set path to ZeroClipboard.swf
+ this.moviePath = path;
+ },
+
+ dispatch: function(id, eventName, args) {
+ // receive event from flash movie, send to client
+ var client = this.clients[id];
+ if (client) {
+ client.receiveEvent(eventName, args);
+ }
+ },
+
+ register: function(id, client) {
+ // register new client to receive events
+ this.clients[id] = client;
+ },
+
+ getDOMObjectPosition: function(obj) {
+ // get absolute coordinates for dom element
+ var info = {
+ left: 0,
+ top: 0,
+ width: obj.width ? obj.width : obj.offsetWidth,
+ height: obj.height ? obj.height : obj.offsetHeight
+ };
+
+ if ( obj.style.width != "" )
+ info.width = obj.style.width.replace("px","");
+
+ if ( obj.style.height != "" )
+ info.height = obj.style.height.replace("px","");
+
+ while (obj) {
+ info.left += obj.offsetLeft;
+ info.top += obj.offsetTop;
+ obj = obj.offsetParent;
+ }
+
+ return info;
+ },
+
+ Client: function(elem) {
+ // constructor for new simple upload client
+ this.handlers = {};
+
+ // unique ID
+ this.id = ZeroClipboard_TableTools.nextId++;
+ this.movieId = 'ZeroClipboard_TableToolsMovie_' + this.id;
+
+ // register client with singleton to receive flash events
+ ZeroClipboard_TableTools.register(this.id, this);
+
+ // create movie
+ if (elem) this.glue(elem);
+ }
+};
+
+ZeroClipboard_TableTools.Client.prototype = {
+
+ id: 0, // unique ID for us
+ ready: false, // whether movie is ready to receive events or not
+ movie: null, // reference to movie object
+ clipText: '', // text to copy to clipboard
+ fileName: '', // default file save name
+ action: 'copy', // action to perform
+ handCursorEnabled: true, // whether to show hand cursor, or default pointer cursor
+ cssEffects: true, // enable CSS mouse effects on dom container
+ handlers: null, // user event handlers
+ sized: false,
+
+ glue: function(elem, title) {
+ // glue to DOM element
+ // elem can be ID or actual DOM element object
+ this.domElement = ZeroClipboard_TableTools.$(elem);
+
+ // float just above object, or zIndex 99 if dom element isn't set
+ var zIndex = 99;
+ if (this.domElement.style.zIndex) {
+ zIndex = parseInt(this.domElement.style.zIndex) + 1;
+ }
+
+ // find X/Y position of domElement
+ var box = ZeroClipboard_TableTools.getDOMObjectPosition(this.domElement);
+
+ // create floating DIV above element
+ this.div = document.createElement('div');
+ var style = this.div.style;
+ style.position = 'absolute';
+ style.left = '0px';
+ style.top = '0px';
+ style.width = (box.width) + 'px';
+ style.height = box.height + 'px';
+ style.zIndex = zIndex;
+
+ if ( typeof title != "undefined" && title != "" ) {
+ this.div.title = title;
+ }
+ if ( box.width != 0 && box.height != 0 ) {
+ this.sized = true;
+ }
+
+ // style.backgroundColor = '#f00'; // debug
+ if ( this.domElement ) {
+ this.domElement.appendChild(this.div);
+ this.div.innerHTML = this.getHTML( box.width, box.height );
+ }
+ },
+
+ positionElement: function() {
+ var box = ZeroClipboard_TableTools.getDOMObjectPosition(this.domElement);
+ var style = this.div.style;
+
+ style.position = 'absolute';
+ //style.left = (this.domElement.offsetLeft)+'px';
+ //style.top = this.domElement.offsetTop+'px';
+ style.width = box.width + 'px';
+ style.height = box.height + 'px';
+
+ if ( box.width != 0 && box.height != 0 ) {
+ this.sized = true;
+ } else {
+ return;
+ }
+
+ var flash = this.div.childNodes[0];
+ flash.width = box.width;
+ flash.height = box.height;
+ },
+
+ getHTML: function(width, height) {
+ // return HTML for movie
+ var html = '';
+ var flashvars = 'id=' + this.id +
+ '&width=' + width +
+ '&height=' + height;
+
+ if (navigator.userAgent.match(/MSIE/)) {
+ // IE gets an OBJECT tag
+ var protocol = location.href.match(/^https/i) ? 'https://' : 'http://';
+ html += '';
+ }
+ else {
+ // all other browsers get an EMBED tag
+ html += '';
+ }
+ return html;
+ },
+
+ hide: function() {
+ // temporarily hide floater offscreen
+ if (this.div) {
+ this.div.style.left = '-2000px';
+ }
+ },
+
+ show: function() {
+ // show ourselves after a call to hide()
+ this.reposition();
+ },
+
+ destroy: function() {
+ // destroy control and floater
+ if (this.domElement && this.div) {
+ this.hide();
+ this.div.innerHTML = '';
+
+ var body = document.getElementsByTagName('body')[0];
+ try { body.removeChild( this.div ); } catch(e) {;}
+
+ this.domElement = null;
+ this.div = null;
+ }
+ },
+
+ reposition: function(elem) {
+ // reposition our floating div, optionally to new container
+ // warning: container CANNOT change size, only position
+ if (elem) {
+ this.domElement = ZeroClipboard_TableTools.$(elem);
+ if (!this.domElement) this.hide();
+ }
+
+ if (this.domElement && this.div) {
+ var box = ZeroClipboard_TableTools.getDOMObjectPosition(this.domElement);
+ var style = this.div.style;
+ style.left = '' + box.left + 'px';
+ style.top = '' + box.top + 'px';
+ }
+ },
+
+ clearText: function() {
+ // clear the text to be copy / saved
+ this.clipText = '';
+ if (this.ready) this.movie.clearText();
+ },
+
+ appendText: function(newText) {
+ // append text to that which is to be copied / saved
+ this.clipText += newText;
+ if (this.ready) { this.movie.appendText(newText) ;}
+ },
+
+ setText: function(newText) {
+ // set text to be copied to be copied / saved
+ this.clipText = newText;
+ if (this.ready) { this.movie.setText(newText) ;}
+ },
+
+ setCharSet: function(charSet) {
+ // set the character set (UTF16LE or UTF8)
+ this.charSet = charSet;
+ if (this.ready) { this.movie.setCharSet(charSet) ;}
+ },
+
+ setBomInc: function(bomInc) {
+ // set if the BOM should be included or not
+ this.incBom = bomInc;
+ if (this.ready) { this.movie.setBomInc(bomInc) ;}
+ },
+
+ setFileName: function(newText) {
+ // set the file name
+ this.fileName = newText;
+ if (this.ready) this.movie.setFileName(newText);
+ },
+
+ setAction: function(newText) {
+ // set action (save or copy)
+ this.action = newText;
+ if (this.ready) this.movie.setAction(newText);
+ },
+
+ addEventListener: function(eventName, func) {
+ // add user event listener for event
+ // event types: load, queueStart, fileStart, fileComplete, queueComplete, progress, error, cancel
+ eventName = eventName.toString().toLowerCase().replace(/^on/, '');
+ if (!this.handlers[eventName]) this.handlers[eventName] = [];
+ this.handlers[eventName].push(func);
+ },
+
+ setHandCursor: function(enabled) {
+ // enable hand cursor (true), or default arrow cursor (false)
+ this.handCursorEnabled = enabled;
+ if (this.ready) this.movie.setHandCursor(enabled);
+ },
+
+ setCSSEffects: function(enabled) {
+ // enable or disable CSS effects on DOM container
+ this.cssEffects = !!enabled;
+ },
+
+ receiveEvent: function(eventName, args) {
+ // receive event from flash
+ eventName = eventName.toString().toLowerCase().replace(/^on/, '');
+
+ // special behavior for certain events
+ switch (eventName) {
+ case 'load':
+ // movie claims it is ready, but in IE this isn't always the case...
+ // bug fix: Cannot extend EMBED DOM elements in Firefox, must use traditional function
+ this.movie = document.getElementById(this.movieId);
+ if (!this.movie) {
+ var self = this;
+ setTimeout( function() { self.receiveEvent('load', null); }, 1 );
+ return;
+ }
+
+ // firefox on pc needs a "kick" in order to set these in certain cases
+ if (!this.ready && navigator.userAgent.match(/Firefox/) && navigator.userAgent.match(/Windows/)) {
+ var self = this;
+ setTimeout( function() { self.receiveEvent('load', null); }, 100 );
+ this.ready = true;
+ return;
+ }
+
+ this.ready = true;
+ this.movie.clearText();
+ this.movie.appendText( this.clipText );
+ this.movie.setFileName( this.fileName );
+ this.movie.setAction( this.action );
+ this.movie.setCharSet( this.charSet );
+ this.movie.setBomInc( this.incBom );
+ this.movie.setHandCursor( this.handCursorEnabled );
+ break;
+
+ case 'mouseover':
+ if (this.domElement && this.cssEffects) {
+ //this.domElement.addClass('hover');
+ if (this.recoverActive) this.domElement.addClass('active');
+ }
+ break;
+
+ case 'mouseout':
+ if (this.domElement && this.cssEffects) {
+ this.recoverActive = false;
+ if (this.domElement.hasClass('active')) {
+ this.domElement.removeClass('active');
+ this.recoverActive = true;
+ }
+ //this.domElement.removeClass('hover');
+ }
+ break;
+
+ case 'mousedown':
+ if (this.domElement && this.cssEffects) {
+ this.domElement.addClass('active');
+ }
+ break;
+
+ case 'mouseup':
+ if (this.domElement && this.cssEffects) {
+ this.domElement.removeClass('active');
+ this.recoverActive = false;
+ }
+ break;
+ } // switch eventName
+
+ if (this.handlers[eventName]) {
+ for (var idx = 0, len = this.handlers[eventName].length; idx < len; idx++) {
+ var func = this.handlers[eventName][idx];
+
+ if (typeof(func) == 'function') {
+ // actual function reference
+ func(this, args);
+ }
+ else if ((typeof(func) == 'object') && (func.length == 2)) {
+ // PHP style object + method, i.e. [myObject, 'myMethod']
+ func[0][ func[1] ](this, args);
+ }
+ else if (typeof(func) == 'string') {
+ // name of function
+ window[func](this, args);
+ }
+ } // foreach event handler defined
+ } // user defined handler for event
+ }
+
+};
diff --git a/vendor/symposion/static/tabletools/swf/copy_csv_xls.swf b/vendor/symposion/static/tabletools/swf/copy_csv_xls.swf
new file mode 100644
index 00000000..b8fe47fe
Binary files /dev/null and b/vendor/symposion/static/tabletools/swf/copy_csv_xls.swf differ
diff --git a/vendor/symposion/static/tabletools/swf/copy_csv_xls_pdf.swf b/vendor/symposion/static/tabletools/swf/copy_csv_xls_pdf.swf
new file mode 100644
index 00000000..2aeb65bf
Binary files /dev/null and b/vendor/symposion/static/tabletools/swf/copy_csv_xls_pdf.swf differ
diff --git a/vendor/symposion/teams/__init__.py b/vendor/symposion/teams/__init__.py
new file mode 100644
index 00000000..403c783d
--- /dev/null
+++ b/vendor/symposion/teams/__init__.py
@@ -0,0 +1 @@
+# @@@ Replace this with pinax-teams
diff --git a/vendor/symposion/teams/admin.py b/vendor/symposion/teams/admin.py
new file mode 100644
index 00000000..a8c3bec7
--- /dev/null
+++ b/vendor/symposion/teams/admin.py
@@ -0,0 +1,17 @@
+from django.contrib import admin
+
+from reversion.admin import VersionAdmin
+
+from symposion.teams.models import Team, Membership
+
+admin.site.register(Team,
+ prepopulated_fields={"slug": ("name",)})
+
+
+class MembershipAdmin(VersionAdmin):
+ list_display = ["team", "user", "state"]
+ list_filter = ["team"]
+ search_fields = ["user__username"]
+
+
+admin.site.register(Membership, MembershipAdmin)
diff --git a/vendor/symposion/teams/backends.py b/vendor/symposion/teams/backends.py
new file mode 100644
index 00000000..a882bf5d
--- /dev/null
+++ b/vendor/symposion/teams/backends.py
@@ -0,0 +1,45 @@
+from django.db.models import Q
+
+from .models import Team
+
+
+class TeamPermissionsBackend(object):
+
+ def authenticate(self, username=None, password=None):
+ return None
+
+ def get_team_permissions(self, user_obj, obj=None):
+ """
+ Returns a set of permission strings that this user has through his/her
+ team memberships.
+ """
+ if user_obj.is_anonymous() or obj is not None:
+ return set()
+ if not hasattr(user_obj, "_team_perm_cache"):
+ # Member permissions
+ memberships = Team.objects.filter(
+ Q(memberships__user=user_obj),
+ Q(memberships__state="member"),
+ )
+ perms = memberships.values_list(
+ "permissions__content_type__app_label",
+ "permissions__codename"
+ ).order_by()
+ permissions = ["%s.%s" % (ct, name) for ct, name in perms]
+ # Manager permissions
+ memberships = Team.objects.filter(
+ Q(memberships__user=user_obj),
+ Q(memberships__state="manager"),
+ )
+ perms = memberships.values_list(
+ "manager_permissions__content_type__app_label",
+ "manager_permissions__codename"
+ ).order_by()
+ permissions += ["%s.%s" % (ct, name) for ct, name in perms]
+ user_obj._team_perm_cache = set(permissions)
+ return user_obj._team_perm_cache
+
+ def has_perm(self, user_obj, perm, obj=None):
+ if not user_obj.is_active:
+ return False
+ return perm in self.get_team_permissions(user_obj, obj)
diff --git a/vendor/symposion/teams/forms.py b/vendor/symposion/teams/forms.py
new file mode 100644
index 00000000..5d06c748
--- /dev/null
+++ b/vendor/symposion/teams/forms.py
@@ -0,0 +1,59 @@
+from django import forms
+
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext_lazy as _
+
+from django.contrib.auth.models import User
+
+from symposion.teams.models import Membership
+
+
+class TeamInvitationForm(forms.Form):
+
+ required_css_class = 'label-required'
+
+ email = forms.EmailField(label=_("Email"),
+ help_text=_("email address must be that of an account on this "
+ "conference site"))
+
+ def __init__(self, *args, **kwargs):
+ self.team = kwargs.pop("team")
+ super(TeamInvitationForm, self).__init__(*args, **kwargs)
+
+ def clean(self):
+ cleaned_data = super(TeamInvitationForm, self).clean()
+ email = cleaned_data.get("email")
+
+ if email is None:
+ raise forms.ValidationError(_("valid email address required"))
+
+ try:
+ user = User.objects.get(email=email)
+ except User.DoesNotExist:
+ # eventually we can invite them but for now assume they are
+ # already on the site
+ raise forms.ValidationError(
+ mark_safe(_("no account with email address %s found on this conference "
+ "site") % escape(email)))
+
+ state = self.team.get_state_for_user(user)
+
+ if state in ["member", "manager"]:
+ raise forms.ValidationError(_("user already in team"))
+
+ if state in ["invited"]:
+ raise forms.ValidationError(_("user already invited to team"))
+
+ self.user = user
+ self.state = state
+
+ return cleaned_data
+
+ def invite(self):
+ if self.state is None:
+ Membership.objects.create(team=self.team, user=self.user, state="invited")
+ elif self.state == "applied":
+ # if they applied we shortcut invitation process
+ membership = Membership.objects.filter(team=self.team, user=self.user)
+ membership.update(state="member")
diff --git a/vendor/symposion/teams/migrations/0001_initial.py b/vendor/symposion/teams/migrations/0001_initial.py
new file mode 100644
index 00000000..513093db
--- /dev/null
+++ b/vendor/symposion/teams/migrations/0001_initial.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from django.db import models, migrations
+import datetime
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0006_require_contenttypes_0002'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Membership',
+ fields=[
+ ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
+ ('state', models.CharField(max_length=20, choices=[('applied', 'applied'), ('invited', 'invited'), ('declined', 'declined'), ('rejected', 'rejected'), ('member', 'member'), ('manager', 'manager')], verbose_name='State')),
+ ('message', models.TextField(blank=True, verbose_name='Message')),
+ ],
+ options={
+ 'verbose_name_plural': 'Memberships',
+ 'verbose_name': 'Membership',
+ },
+ ),
+ migrations.CreateModel(
+ name='Team',
+ fields=[
+ ('id', models.AutoField(serialize=False, auto_created=True, primary_key=True, verbose_name='ID')),
+ ('slug', models.SlugField(unique=True, verbose_name='Slug')),
+ ('name', models.CharField(max_length=100, verbose_name='Name')),
+ ('description', models.TextField(blank=True, verbose_name='Description')),
+ ('access', models.CharField(max_length=20, choices=[('open', 'open'), ('application', 'by application'), ('invitation', 'by invitation')], verbose_name='Access')),
+ ('created', models.DateTimeField(editable=False, default=datetime.datetime.now, verbose_name='Created')),
+ ('manager_permissions', models.ManyToManyField(related_name='manager_teams', blank=True, to='auth.Permission', verbose_name='Manager permissions')),
+ ('permissions', models.ManyToManyField(related_name='member_teams', blank=True, to='auth.Permission', verbose_name='Permissions')),
+ ],
+ options={
+ 'verbose_name_plural': 'Teams',
+ 'verbose_name': 'Team',
+ },
+ ),
+ migrations.AddField(
+ model_name='membership',
+ name='team',
+ field=models.ForeignKey(verbose_name='Team', to='teams.Team', related_name='memberships'),
+ ),
+ migrations.AddField(
+ model_name='membership',
+ name='user',
+ field=models.ForeignKey(verbose_name='User', to=settings.AUTH_USER_MODEL, related_name='memberships'),
+ ),
+ ]
diff --git a/vendor/symposion/teams/migrations/__init__.py b/vendor/symposion/teams/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/vendor/symposion/teams/models.py b/vendor/symposion/teams/models.py
new file mode 100644
index 00000000..ca197272
--- /dev/null
+++ b/vendor/symposion/teams/models.py
@@ -0,0 +1,94 @@
+import datetime
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from django.contrib.auth.models import Permission, User
+
+from reversion import revisions as reversion
+
+
+TEAM_ACCESS_CHOICES = [
+ ("open", _("open")),
+ ("application", _("by application")),
+ ("invitation", _("by invitation"))
+]
+
+
+class Team(models.Model):
+
+ slug = models.SlugField(unique=True, verbose_name=_("Slug"))
+ name = models.CharField(max_length=100, verbose_name=_("Name"))
+ description = models.TextField(blank=True, verbose_name=_("Description"))
+ access = models.CharField(max_length=20, choices=TEAM_ACCESS_CHOICES,
+ verbose_name=_("Access"))
+
+ # member permissions
+ permissions = models.ManyToManyField(Permission, blank=True,
+ related_name="member_teams",
+ verbose_name=_("Permissions"))
+
+ # manager permissions
+ manager_permissions = models.ManyToManyField(Permission, blank=True,
+ related_name="manager_teams",
+ verbose_name=_("Manager permissions"))
+
+ created = models.DateTimeField(default=datetime.datetime.now,
+ editable=False, verbose_name=_("Created"))
+
+ @models.permalink
+ def get_absolute_url(self):
+ return ("team_detail", [self.slug])
+
+ def __str__(self):
+ return self.name
+
+ def get_state_for_user(self, user):
+ try:
+ return self.memberships.get(user=user).state
+ except Membership.DoesNotExist:
+ return None
+
+ def applicants(self):
+ return self.memberships.filter(state="applied")
+
+ def invitees(self):
+ return self.memberships.filter(state="invited")
+
+ def members(self):
+ return self.memberships.filter(state="member")
+
+ def managers(self):
+ return self.memberships.filter(state="manager")
+
+ class Meta:
+ verbose_name = _('Team')
+ verbose_name_plural = _('Teams')
+
+
+MEMBERSHIP_STATE_CHOICES = [
+ ("applied", _("applied")),
+ ("invited", _("invited")),
+ ("declined", _("declined")),
+ ("rejected", _("rejected")),
+ ("member", _("member")),
+ ("manager", _("manager")),
+]
+
+
+class Membership(models.Model):
+
+ user = models.ForeignKey(User, related_name="memberships",
+ verbose_name=_("User"))
+ team = models.ForeignKey(Team, related_name="memberships",
+ verbose_name=_("Team"))
+ state = models.CharField(max_length=20, choices=MEMBERSHIP_STATE_CHOICES,
+ verbose_name=_("State"))
+ message = models.TextField(blank=True, verbose_name=_("Message"))
+
+ class Meta:
+ verbose_name = _("Membership")
+ verbose_name_plural = _("Memberships")
+
+
+reversion.register(Membership)
diff --git a/vendor/symposion/teams/templatetags/__init__.py b/vendor/symposion/teams/templatetags/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/vendor/symposion/teams/templatetags/teams_tags.py b/vendor/symposion/teams/templatetags/teams_tags.py
new file mode 100644
index 00000000..b82a0eba
--- /dev/null
+++ b/vendor/symposion/teams/templatetags/teams_tags.py
@@ -0,0 +1,39 @@
+from django import template
+
+from symposion.teams.models import Team
+
+register = template.Library()
+
+
+class AvailableTeamsNode(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"]
+ teams = []
+ for team in Team.objects.all():
+ state = team.get_state_for_user(request.user)
+ if team.access == "open" and state is None:
+ teams.append(team)
+ elif request.user.is_staff and state is None:
+ teams.append(team)
+ context[self.context_var] = teams
+ return u""
+
+
+@register.tag
+def available_teams(parser, token):
+ """
+ {% available_teams as available_teams %}
+ """
+ return AvailableTeamsNode.handle_token(parser, token)
diff --git a/vendor/symposion/teams/urls.py b/vendor/symposion/teams/urls.py
new file mode 100644
index 00000000..60e441e9
--- /dev/null
+++ b/vendor/symposion/teams/urls.py
@@ -0,0 +1,25 @@
+from django.conf.urls import url
+
+from .views import (
+ team_detail,
+ team_join,
+ team_leave,
+ team_apply,
+ team_promote,
+ team_demote,
+ team_accept,
+ team_reject
+)
+urlpatterns = [
+ # team specific
+ url(r"^(?PPrint view