diff --git a/parsifal/__init__.py b/parsifal/__init__.py index 53bbd973..c7fc58f4 100644 --- a/parsifal/__init__.py +++ b/parsifal/__init__.py @@ -1,5 +1,5 @@ from parsifal.utils.version import get_version -VERSION = (2, 0, 5, "final", 0) +VERSION = (2, 1, 0, "alpha", 0) __version__ = get_version(VERSION) diff --git a/parsifal/apps/authentication/forms.py b/parsifal/apps/authentication/forms.py index 34416698..a9a55d08 100644 --- a/parsifal/apps/authentication/forms.py +++ b/parsifal/apps/authentication/forms.py @@ -1,3 +1,4 @@ +from django import forms from django.contrib.auth.forms import UserCreationForm, UsernameField from django.utils.translation import gettext @@ -7,6 +8,8 @@ validate_case_insensitive_username, validate_forbidden_usernames, ) +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.invites.models import Invite from parsifal.utils.recaptcha import recaptcha_is_valid @@ -19,6 +22,12 @@ def __init__(self, *args, **kwargs): class SignUpForm(UserCreationForm): + invite = forms.ModelChoiceField( + queryset=Invite.objects.filter(invitee=None, status=InviteStatus.PENDING), + required=False, + widget=forms.HiddenInput(), + ) + class Meta(UserCreationForm.Meta): fields = ("username", "email") field_classes = {"username": ASCIIUsernameField} @@ -35,3 +44,19 @@ def clean(self): if self.request and not recaptcha_is_valid(self.request): self.add_error(None, gettext("The Google reCAPTCHA did not validate. Please try again.")) return cleaned_data + + def accept_invite(self, user): + invite = self.cleaned_data.get("invite") + if invite: + invite.accept(user) + # check if there are currently any invitation with this user email + # and associate with their account + Invite.objects.filter(invitee=None, status=InviteStatus.PENDING, invitee_email__iexact=user.email).update( + invitee=user + ) + + def save(self, commit=True): + user = super().save(commit=commit) + if commit: + self.accept_invite(user) + return user diff --git a/parsifal/apps/authentication/models.py b/parsifal/apps/authentication/models.py index a40c5474..1d4c7a25 100644 --- a/parsifal/apps/authentication/models.py +++ b/parsifal/apps/authentication/models.py @@ -23,6 +23,9 @@ class Meta: verbose_name_plural = _("profiles") db_table = "auth_profile" + def __str__(self): + return self.get_screen_name() + def get_url(self): url = self.url if "http://" not in self.url and "https://" not in self.url and len(self.url) > 0: diff --git a/parsifal/apps/authentication/views.py b/parsifal/apps/authentication/views.py index 861175c3..b5815240 100644 --- a/parsifal/apps/authentication/views.py +++ b/parsifal/apps/authentication/views.py @@ -1,7 +1,9 @@ from django.contrib import messages from django.contrib.auth import login +from django.core.exceptions import ValidationError from django.shortcuts import redirect from django.utils.decorators import method_decorator +from django.utils.functional import cached_property from django.utils.translation import gettext as _ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect @@ -9,6 +11,8 @@ from django.views.generic import FormView from parsifal.apps.authentication.forms import SignUpForm +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.invites.models import Invite @method_decorator([sensitive_post_parameters(), csrf_protect, never_cache], name="dispatch") @@ -16,9 +20,17 @@ class SignUpView(FormView): form_class = SignUpForm template_name = "registration/signup.html" + @cached_property + def invite(self): + try: + code = self.request.GET.get("invite", self.request.POST.get("invite")) + return Invite.objects.filter(invitee=None, status=InviteStatus.PENDING).get(code=code) + except (Invite.DoesNotExist, ValidationError): + return None + def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs.update(request=self.request) + kwargs.update(request=self.request, initial={"invite": self.invite}) return kwargs def form_valid(self, form): @@ -36,3 +48,7 @@ def form_invalid(self, form): ), ) return super().form_invalid(form) + + def get_context_data(self, **kwargs): + kwargs.update(invite=self.invite) + return super().get_context_data(**kwargs) diff --git a/parsifal/apps/invites/__init__.py b/parsifal/apps/invites/__init__.py new file mode 100644 index 00000000..6ef2d866 --- /dev/null +++ b/parsifal/apps/invites/__init__.py @@ -0,0 +1 @@ +default_app_config = "parsifal.apps.invites.apps.InvitesConfig" diff --git a/parsifal/apps/invites/admin.py b/parsifal/apps/invites/admin.py new file mode 100644 index 00000000..e3e5db34 --- /dev/null +++ b/parsifal/apps/invites/admin.py @@ -0,0 +1,31 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from parsifal.apps.invites.models import Invite + + +@admin.register(Invite) +class InviteAdmin(admin.ModelAdmin): + date_hierarchy = "date_sent" + list_display = ("get_invitee_email", "invited_by", "review", "status", "date_sent", "date_answered") + list_select_related = ("invitee", "invited_by__profile", "review") + list_filter = ("status",) + raw_id_fields = ("invitee", "invited_by", "review") + search_fields = ( + "invitee_email", + "invitee__email", + "invitee__username", + "invited_by__email", + "invited_by__username", + ) + readonly_fields = ("status", "date_sent", "code") + fieldsets = ( + (None, {"fields": ("review", "invited_by", "status", "code")}), + (_("Invitee"), {"fields": ("invitee", "invitee_email")}), + (_("Important dates"), {"fields": ("date_sent", "date_answered")}), + ) + + def get_invitee_email(self, obj): + return obj.get_invitee_email() + + get_invitee_email.short_description = _("Invitee") diff --git a/parsifal/apps/invites/apps.py b/parsifal/apps/invites/apps.py new file mode 100644 index 00000000..7f8977be --- /dev/null +++ b/parsifal/apps/invites/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InvitesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "parsifal.apps.invites" diff --git a/parsifal/apps/invites/constants.py b/parsifal/apps/invites/constants.py new file mode 100644 index 00000000..e416c0b0 --- /dev/null +++ b/parsifal/apps/invites/constants.py @@ -0,0 +1,13 @@ +from django.utils.translation import gettext_lazy as _ + + +class InviteStatus: + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + + CHOICES = ( + (PENDING, _("Pending")), + (ACCEPTED, _("Accepted")), + (REJECTED, _("Rejected")), + ) diff --git a/parsifal/apps/invites/forms.py b/parsifal/apps/invites/forms.py new file mode 100644 index 00000000..9d62d423 --- /dev/null +++ b/parsifal/apps/invites/forms.py @@ -0,0 +1,116 @@ +from django import forms +from django.contrib.auth.models import User +from django.contrib.sites.shortcuts import get_current_site +from django.core.mail import EmailMultiAlternatives +from django.db.models.functions import Lower +from django.template.loader import render_to_string +from django.utils.translation import gettext as _ + +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.invites.models import Invite + + +class SendInviteForm(forms.ModelForm): + class Meta: + model = Invite + fields = ("invitee", "invitee_email") + + def __init__(self, *args, request, review, **kwargs): + self.request = request + self.review = review + super().__init__(*args, **kwargs) + user_ids = {user.pk for user in self.request.user.profile.get_following()} + self.fields["invitee"].queryset = ( + User.objects.filter(pk__in=user_ids) + .exclude(pk__in=self.review.co_authors.all()) + .annotate(lower_username=Lower("username")) + .order_by("lower_username") + ) + self.fields["invitee"].label = _("Contacts") + self.fields["invitee"].help_text = _("List of people that you are currently following on Parsifal.") + + self.fields["invitee_email"].label = _("Email address of the person you want to invite") + self.fields["invitee_email"].help_text = _( + "If the person you want to invite is not on Parsifal, you can inform their email address and we will send " + "an invitation link to their inbox." + ) + self.fields["invitee_email"].required = False + + def clean(self): + cleaned_data = super().clean() + if cleaned_data.get("invitee") and cleaned_data.get("invitee_email"): + self.add_error( + None, _("You must inform either a contact or an email address, but not both at the same time.") + ) + if not cleaned_data.get("invitee") and not cleaned_data.get("invitee_email"): + self.add_error(None, _("You must inform either a contact or an email address.")) + return cleaned_data + + def clean_invitee(self): + invitee = self.cleaned_data.get("invitee") + if invitee: + if self.review.is_author_or_coauthor(invitee): + self.add_error("invitee", _("This person is already a co-author of this review.")) + if Invite.objects.filter( + review=self.review, invitee_email__iexact=invitee.email, status=InviteStatus.PENDING + ).exists(): + self.add_error("invitee", _("This person already has a pending invite.")) + return invitee + + def clean_invitee_email(self): + invitee_email = self.cleaned_data.get("invitee_email") + if invitee_email: + invitee_email = User.objects.normalize_email(invitee_email) + if invitee_email.lower() == self.request.user.email.lower(): + self.add_error("invitee_email", _("You cannot invite yourself.")) + try: + user = User.objects.get(email__iexact=invitee_email) + if self.review.is_author_or_coauthor(user): + self.add_error("invitee_email", _("This person is already a co-author of this review.")) + except User.DoesNotExist: + pass + if Invite.objects.filter( + review=self.review, invitee_email__iexact=invitee_email, status=InviteStatus.PENDING + ).exists(): + self.add_error("invitee_email", _("This person already has a pending invite.")) + return invitee_email + + def send_mail(self): + """ + Send a django.core.mail.EmailMultiAlternatives to `to_email`. + """ + subject = render_to_string("invites/invite_subject.txt", {"invite": self.instance}) + # Email subject *must not* contain newlines + subject = "".join(subject.splitlines()) + + current_site = get_current_site(self.request) + site_name = current_site.name + domain = current_site.domain + invited_by_name = self.instance.invited_by.profile.get_screen_name() + from_email = f"{invited_by_name} via Parsifal " + to_email = self.instance.get_invitee_email() + body = render_to_string( + "invites/invite_email.html", + { + "invite": self.instance, + "site_name": site_name, + "domain": domain, + "protocol": "https" if self.request.is_secure() else "http", + }, + ) + + email_message = EmailMultiAlternatives(subject, body, from_email, [to_email]) + email_message.send() + + def save(self, commit=True): + self.instance = super().save(commit=False) + if self.instance.invitee: + self.instance.invitee_email = self.instance.invitee.email + else: + self.instance.invitee = User.objects.filter(email__iexact=self.instance.invitee_email).first() + self.instance.review = self.review + self.instance.invited_by = self.request.user + if commit: + self.instance.save() + self.send_mail() + return self.instance diff --git a/parsifal/apps/invites/migrations/0001_initial.py b/parsifal/apps/invites/migrations/0001_initial.py new file mode 100644 index 00000000..230db3bf --- /dev/null +++ b/parsifal/apps/invites/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.7 on 2021-09-07 17:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('reviews', '0036_auto_20210906_2320'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Invite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invitee_email', models.EmailField(blank=True, max_length=254, verbose_name='invitee email')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='pending', max_length=32, verbose_name='status')), + ('code', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='code')), + ('date_sent', models.DateTimeField(auto_now_add=True, verbose_name='date sent')), + ('date_answered', models.DateTimeField(blank=True, null=True, verbose_name='date answered')), + ('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites_sent', to=settings.AUTH_USER_MODEL, verbose_name='invited by')), + ('invitee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invites_received', to=settings.AUTH_USER_MODEL, verbose_name='invitee')), + ('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='reviews.review', verbose_name='review')), + ], + options={ + 'verbose_name': 'invite', + 'verbose_name_plural': 'invites', + }, + ), + ] diff --git a/parsifal/apps/invites/migrations/0002_alter_invite_invitee_email.py b/parsifal/apps/invites/migrations/0002_alter_invite_invitee_email.py new file mode 100644 index 00000000..d099ee3d --- /dev/null +++ b/parsifal/apps/invites/migrations/0002_alter_invite_invitee_email.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.7 on 2021-09-08 00:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invites', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='invite', + name='invitee_email', + field=models.EmailField(db_index=True, max_length=254, verbose_name='invitee email'), + ), + ] diff --git a/parsifal/apps/invites/migrations/__init__.py b/parsifal/apps/invites/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/parsifal/apps/invites/models.py b/parsifal/apps/invites/models.py new file mode 100644 index 00000000..f466cdde --- /dev/null +++ b/parsifal/apps/invites/models.py @@ -0,0 +1,64 @@ +import uuid + +from django.contrib.auth.models import User +from django.db import models, transaction +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.reviews.models import Review + + +class Invite(models.Model): + review = models.ForeignKey(Review, on_delete=models.CASCADE, verbose_name=_("review"), related_name="invites") + invited_by = models.ForeignKey( + User, on_delete=models.CASCADE, verbose_name=_("invited by"), related_name="invites_sent" + ) + invitee = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("invitee"), + related_name="invites_received", + ) + invitee_email = models.EmailField(_("invitee email"), db_index=True) + status = models.CharField(_("status"), max_length=32, choices=InviteStatus.CHOICES, default=InviteStatus.PENDING) + code = models.UUIDField(_("code"), default=uuid.uuid4, editable=False) + date_sent = models.DateTimeField(_("date sent"), auto_now_add=True) + date_answered = models.DateTimeField(_("date answered"), null=True, blank=True) + + class Meta: + verbose_name = _("invite") + verbose_name_plural = _("invites") + + def __str__(self): + return f"{self.review.name} - {self.get_invitee_email()} - {self.status}" + + def get_absolute_url(self): + return reverse("invite", args=(self.code,)) + + def get_invitee_email(self): + if self.invitee: + return self.invitee.email + return self.invitee_email + + @transaction.atomic() + def accept(self, user=None): + assert self.invitee or user, "The accept method cannot be called without a valid User account" + self.status = InviteStatus.ACCEPTED + self.date_answered = timezone.now() + if user and not self.invitee: + self.invitee = user + self.save() + self.review.co_authors.add(self.invitee) + + def reject(self): + self.status = InviteStatus.REJECTED + self.date_answered = timezone.now() + self.save() + + @property + def is_pending(self): + return self.status == InviteStatus.PENDING diff --git a/parsifal/apps/invites/templates/invites/invite_confirm_delete.html b/parsifal/apps/invites/templates/invites/invite_confirm_delete.html new file mode 100644 index 00000000..e8fbffb3 --- /dev/null +++ b/parsifal/apps/invites/templates/invites/invite_confirm_delete.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% load crispy_forms_filters i18n parsifal_invites static %} + +{% block title %}{% trans "Delete invite" %} · {{ review.title }}{% endblock %} + +{% block content %} + {% include "reviews/review_header.html" %} +
+
+ {% include "settings/includes/menu.html" with menu="manage_access" %} +
+
+ +
+ {% csrf_token %} +

+ {% blocktrans trimmed with invitee_email=invite.get_invitee_email %} + Are you sure you want to delete the invite sent to {{ invitee_email }}? + {% endblocktrans %} +

+
+ + {% trans "Never mind" %} +
+
+ +
+
+{% endblock content %} diff --git a/parsifal/apps/invites/templates/invites/invite_detail.html b/parsifal/apps/invites/templates/invites/invite_detail.html new file mode 100644 index 00000000..1fe051c8 --- /dev/null +++ b/parsifal/apps/invites/templates/invites/invite_detail.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} + +{% load compress i18n %} + +{% block title %}{% trans "Invite" %} · {{ invite.review.title }}{% endblock %} + +{% block stylesheet %} + {% compress css %} + + {% endcompress %} +{% endblock %} + +{% block body %} +
+
+ +
+
+ +

Parsifal

+
+
+

+ {% blocktrans trimmed with invited_by=invite.invited_by.profile.get_screen_name review_title=invite.review.title %} + {{ invited_by }} invited you to collaborate on the literature review "{{ review_title }}" + {% endblocktrans %} +

+ {% if invite.invitee %} + {% if user.is_authenticated and invite.invitee == user %} +
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ + {% blocktrans trimmed with username=invite.invitee.username %} + You are currently logged in as {{ username }}. + {% endblocktrans %} + + {% else %} +

+ {% blocktrans trimmed %} + To accept the invitation, you must log in to your account {{ invitee_masked_email }} + {% endblocktrans %} +

+ {% trans "Log in" %} + {% endif %} + {% else %} +

+ {% blocktrans trimmed %} + To accept the invitation, you must create an account on Parsifal + {% endblocktrans %} +

+

+ {% trans "Sign up" %} +

+ {% blocktrans %}This invitation was sent to {{ invitee_masked_email }}{% endblocktrans %} + {% endif %} +
+
+ +
+
+ + +
+
+
+ {% include 'includes/footer.html' %} +{% endblock %} diff --git a/parsifal/apps/invites/templates/invites/invite_email.html b/parsifal/apps/invites/templates/invites/invite_email.html new file mode 100644 index 00000000..74fe9160 --- /dev/null +++ b/parsifal/apps/invites/templates/invites/invite_email.html @@ -0,0 +1,13 @@ +{% load i18n %}{% autoescape off %} + +{% blocktranslate trimmed with name=invite.invited_by.profile.get_screen_name title=invite.review.title %} +{{ name }} invited you to a Parsifal Systematic Literature Review called "{{ title }}". +{% endblocktranslate %} + +{% translate "To accept the invitation, visit the link below:" %} + +{{ protocol }}://{{ domain }}{% url "invite" invite.code %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} + +{% endautoescape %} diff --git a/parsifal/apps/invites/templates/invites/invite_subject.txt b/parsifal/apps/invites/templates/invites/invite_subject.txt new file mode 100644 index 00000000..05854d6d --- /dev/null +++ b/parsifal/apps/invites/templates/invites/invite_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktranslate with name=invite.invited_by.profile.get_screen_name title=invite.review.title %}{{ name }} is inviting you to collaborate on the literature review {{ title }}{% endblocktranslate %} diff --git a/parsifal/apps/invites/templates/invites/manage_access.html b/parsifal/apps/invites/templates/invites/manage_access.html new file mode 100644 index 00000000..853138d2 --- /dev/null +++ b/parsifal/apps/invites/templates/invites/manage_access.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% load crispy_forms_filters i18n parsifal_invites static %} + +{% block title %}{% trans "Manage access" %} · {{ review.title }}{% endblock %} + +{% block content %} + {% include "reviews/review_header.html" %} +
+
+ {% include "settings/includes/menu.html" with menu="manage_access" %} +
+
+ +
+
+ +
+ {% trans "Pick someone from your contact list or inform the person email address." %} +
+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+
+ +
+
+

{% trans "Invitations" %}

+
+ + + + + + + + + + + + {% for invite in invites %} + + + + + + + + {% endfor %} + +
{% trans "Invitee" %}{% trans "Date sent" %}{% trans "Invited by" %}{% trans "Status" %}
{{ invite.get_invitee_email }}{{ invite.date_sent|date:"SHORT_DATETIME_FORMAT" }}{{ invite.invited_by.profile }}{% invite_status invite %} + {% if invite.is_pending %} + + + + + + + {% endif %} +
+
+ +
+
+{% endblock content %} diff --git a/parsifal/apps/invites/templates/invites/user_invite_list.html b/parsifal/apps/invites/templates/invites/user_invite_list.html new file mode 100644 index 00000000..86443ef1 --- /dev/null +++ b/parsifal/apps/invites/templates/invites/user_invite_list.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %}{% trans "Invites" %} · Parsifal{% endblock %} + +{% block content %} + +
+
+ + + {% if invites %} + + + + + + + + + + + {% for invite in invites %} + + + + + + + {% endfor %} + +
{% trans "Review" %}{% trans "Invited by" %}{% trans "Date" %}
{{ invite.review.title }}{{ invite.invited_by.profile.get_screen_name }}{{ invite.date_sent|date:"SHORT_DATE_FORMAT" }} +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ {% else %} +
+ +

{% trans "You have no pending invites at this time." %}

+
+ {% endif %} + +
+
+ +{% endblock %} diff --git a/parsifal/apps/invites/templatetags/__init__.py b/parsifal/apps/invites/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/parsifal/apps/invites/templatetags/parsifal_invites.py b/parsifal/apps/invites/templatetags/parsifal_invites.py new file mode 100644 index 00000000..f114d86f --- /dev/null +++ b/parsifal/apps/invites/templatetags/parsifal_invites.py @@ -0,0 +1,20 @@ +from django import template +from django.utils.html import format_html + +from parsifal.apps.invites.constants import InviteStatus + +register = template.Library() + + +@register.simple_tag() +def invite_status(invite): + css_classes = { + InviteStatus.PENDING: "label-warning", + InviteStatus.ACCEPTED: "label-success", + InviteStatus.REJECTED: "label-danger", + } + return format_html( + "{label}", + css_class=css_classes.get(invite.status), + label=invite.get_status_display(), + ) diff --git a/parsifal/apps/invites/tests/__init__.py b/parsifal/apps/invites/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/parsifal/apps/invites/tests/factories.py b/parsifal/apps/invites/tests/factories.py new file mode 100644 index 00000000..4c1429b8 --- /dev/null +++ b/parsifal/apps/invites/tests/factories.py @@ -0,0 +1,14 @@ +import factory +from factory.django import DjangoModelFactory + +from parsifal.apps.invites.models import Invite +from parsifal.apps.reviews.tests.factories import ReviewFactory + + +class InviteFactory(DjangoModelFactory): + review = factory.SubFactory(ReviewFactory) + invited_by = factory.SelfAttribute("review.author") + invitee_email = factory.Sequence(lambda n: f"invitee-{n}@example.com") + + class Meta: + model = Invite diff --git a/parsifal/apps/invites/tests/test_invite_delete_view.py b/parsifal/apps/invites/tests/test_invite_delete_view.py new file mode 100644 index 00000000..64b27b46 --- /dev/null +++ b/parsifal/apps/invites/tests/test_invite_delete_view.py @@ -0,0 +1,73 @@ +from django.test.testcases import TestCase +from django.urls import reverse + +from parsifal.apps.authentication.tests.factories import UserFactory +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.invites.models import Invite +from parsifal.apps.invites.tests.factories import InviteFactory +from parsifal.utils.test import login_redirect_url + + +class TestInviteDeleteView(TestCase): + @classmethod + def setUpTestData(cls): + cls.invite = InviteFactory(invitee_email="invitee@parsif.al") + cls.co_author = UserFactory() + cls.invite.review.co_authors.add(cls.co_author) + cls.url = reverse( + "invites:invite_delete", + args=( + cls.invite.review.author.username, + cls.invite.review.name, + cls.invite.pk, + ), + ) + + def test_login_required(self): + response = self.client.get(self.url) + self.assertRedirects(response, login_redirect_url(self.url)) + + def test_main_author_required(self): + self.client.force_login(self.co_author) + response = self.client.get(self.url) + self.assertEqual(403, response.status_code) + + def test_get_success(self): + self.client.force_login(self.invite.review.author) + + response = self.client.get(self.url) + + with self.subTest(msg="Test get status code"): + self.assertEqual(200, response.status_code) + + with self.subTest(msg="Test response body"): + self.assertContains( + response, "Are you sure you want to delete the invite sent to invitee@parsif.al" + ) + + def test_post_success(self): + self.client.force_login(self.invite.review.author) + response = self.client.post(self.url, follow=True) + with self.subTest(msg="Test post status code"): + self.assertEqual(302, response.redirect_chain[0][1]) + + with self.subTest(msg="Test post redirect status code"): + self.assertEqual(200, response.status_code) + + with self.subTest(msg="Test success message"): + self.assertContains(response, "The invitation was removed with success.") + + with self.subTest(msg="Test invite removed"): + self.assertFalse(Invite.objects.filter(invitee_email="invitee@parsif.al").exists()) + + def test_post_fail(self): + self.invite.status = InviteStatus.ACCEPTED + self.invite.save() + self.client.force_login(self.invite.review.author) + response = self.client.post(self.url) + + with self.subTest(msg="Test post not found status code"): + self.assertEqual(404, response.status_code) + + with self.subTest(msg="Test not invite removed"): + self.assertTrue(Invite.objects.filter(invitee_email="invitee@parsif.al").exists()) diff --git a/parsifal/apps/invites/tests/test_invite_detail_view.py b/parsifal/apps/invites/tests/test_invite_detail_view.py new file mode 100644 index 00000000..c401a943 --- /dev/null +++ b/parsifal/apps/invites/tests/test_invite_detail_view.py @@ -0,0 +1,77 @@ +from django.test.testcases import TestCase +from django.urls import reverse + +from parsifal.apps.authentication.tests.factories import UserFactory +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.invites.tests.factories import InviteFactory + + +class TestInviteDetailView(TestCase): + @classmethod + def setUpTestData(cls): + cls.invite = InviteFactory() + cls.url = reverse("invite", args=(cls.invite.code,)) + cls.login_url = reverse("login") + cls.signup_url = reverse("signup") + cls.accept_url = reverse("accept_user_invite", args=(cls.invite.pk,)) + cls.reject_url = reverse("reject_user_invite", args=(cls.invite.pk,)) + + def test_get_invitee_email(self): + response = self.client.get(self.url) + + with self.subTest(msg="Test get status code"): + self.assertEqual(200, response.status_code) + + with self.subTest(msg="Test response body"): + self.assertContains(response, "To accept the invitation, you must create an account on Parsifal") + self.assertContains(response, self.signup_url) + self.assertNotContains(response, self.login_url) + self.assertNotContains(response, self.accept_url) + self.assertNotContains(response, self.reject_url) + + def test_get_invitee(self): + user = UserFactory() + self.invite.invitee = user + self.invite.save() + response = self.client.get(self.url) + + with self.subTest(msg="Test get status code"): + self.assertEqual(200, response.status_code) + + with self.subTest(msg="Test response body"): + self.assertContains(response, "To accept the invitation, you must log in to your account") + self.assertNotContains(response, self.signup_url) + self.assertContains(response, self.login_url) + self.assertNotContains(response, self.accept_url) + self.assertNotContains(response, self.reject_url) + + def test_get_invitee_authenticated(self): + user = UserFactory(username="invitee_user") + self.invite.invitee = user + self.invite.save() + + self.client.force_login(user) + response = self.client.get(self.url) + + with self.subTest(msg="Test get status code"): + self.assertEqual(200, response.status_code) + + with self.subTest(msg="Test response body"): + self.assertContains(response, "You are currently logged in as invitee_user") + self.assertNotContains(response, "To accept the invitation, you must log in to your account") + self.assertNotContains(response, self.signup_url) + self.assertNotContains(response, self.login_url) + self.assertContains(response, self.accept_url) + self.assertContains(response, self.reject_url) + + def test_cant_access_accepted_invites(self): + self.invite.status = InviteStatus.ACCEPTED + self.invite.save() + response = self.client.get(self.url) + self.assertEqual(404, response.status_code) + + def test_cant_access_rejected_invites(self): + self.invite.status = InviteStatus.REJECTED + self.invite.save() + response = self.client.get(self.url) + self.assertEqual(404, response.status_code) diff --git a/parsifal/apps/invites/tests/test_manage_access_view.py b/parsifal/apps/invites/tests/test_manage_access_view.py new file mode 100644 index 00000000..35f0e926 --- /dev/null +++ b/parsifal/apps/invites/tests/test_manage_access_view.py @@ -0,0 +1,102 @@ +from django.core import mail +from django.test.testcases import TestCase +from django.urls import reverse + +from parsifal.apps.activities.constants import ActivityTypes +from parsifal.apps.activities.models import Activity +from parsifal.apps.authentication.tests.factories import UserFactory +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.invites.models import Invite +from parsifal.apps.invites.tests.factories import InviteFactory +from parsifal.utils.test import login_redirect_url + + +class TestManageAccessView(TestCase): + @classmethod + def setUpTestData(cls): + cls.invite = InviteFactory() + cls.co_author = UserFactory() + cls.invite.review.co_authors.add(cls.co_author) + cls.url = reverse( + "invites:manage_access", + args=( + cls.invite.review.author.username, + cls.invite.review.name, + ), + ) + + def test_login_required(self): + response = self.client.get(self.url) + self.assertRedirects(response, login_redirect_url(self.url)) + + def test_main_author_required(self): + self.client.force_login(self.co_author) + response = self.client.get(self.url) + self.assertEqual(403, response.status_code) + + def test_get_success(self): + self.client.force_login(self.invite.review.author) + response = self.client.get(self.url) + with self.subTest(msg="Test get status code"): + self.assertEqual(200, response.status_code) + + parts = ("csrfmiddlewaretoken", 'name="invitee"', 'name="invitee_email"', self.invite.get_invitee_email()) + for part in parts: + with self.subTest(msg="Test response body", part=part): + self.assertContains(response, part) + + def test_post_success_invitee_email(self): + data = { + "invitee_email": "doe.john@example.com", + } + self.client.force_login(self.invite.review.author) + response = self.client.post(self.url, data, follow=True) + with self.subTest(msg="Test post status code"): + self.assertEqual(302, response.redirect_chain[0][1]) + + with self.subTest(msg="Test post redirect status code"): + self.assertEqual(200, response.status_code) + + with self.subTest(msg="Test success message"): + self.assertContains(response, "An invitation was sent to doe.john@example.com.") + + with self.subTest(msg="Test invite created"): + self.assertTrue( + Invite.objects.filter(invitee_email="doe.john@example.com", status=InviteStatus.PENDING).exists() + ) + + with self.subTest(msg="Test email sent"): + self.assertEqual(1, len(mail.outbox)) + + def test_post_success_invitee(self): + contact = UserFactory(email="contact@parsif.al") + Activity.objects.create( + from_user=self.invite.review.author, to_user=contact, activity_type=ActivityTypes.FOLLOW + ) + + with self.subTest(msg="Test setup"): + self.assertFalse(self.invite.review.is_author_or_coauthor(contact)) + + data = { + "invitee": contact.pk, + } + self.client.force_login(self.invite.review.author) + response = self.client.post(self.url, data, follow=True) + with self.subTest(msg="Test post status code"): + self.assertEqual(302, response.redirect_chain[0][1]) + + with self.subTest(msg="Test post redirect status code"): + self.assertEqual(200, response.status_code) + + with self.subTest(msg="Test success message"): + self.assertContains(response, "An invitation was sent to contact@parsif.al.") + + with self.subTest(msg="Test invite created"): + self.assertTrue( + Invite.objects.filter( + invitee=contact, invitee_email="contact@parsif.al", status=InviteStatus.PENDING + ).exists() + ) + + with self.subTest(msg="Test email sent"): + self.assertEqual(1, len(mail.outbox)) diff --git a/parsifal/apps/invites/tests/test_models.py b/parsifal/apps/invites/tests/test_models.py new file mode 100644 index 00000000..09c1e216 --- /dev/null +++ b/parsifal/apps/invites/tests/test_models.py @@ -0,0 +1,85 @@ +from django.test.testcases import TestCase + +from parsifal.apps.authentication.tests.factories import UserFactory +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.invites.tests.factories import InviteFactory + + +class TestInviteModel(TestCase): + @classmethod + def setUpTestData(cls): + cls.invite = InviteFactory( + review__name="test-review", + invitee_email="invitee@parsif.al", + status=InviteStatus.PENDING, + ) + + def test_setup(self): + self.assertIsNone(self.invite.date_answered) + + def test_is_pending(self): + self.assertTrue(self.invite.is_pending) + + def test_accept_invalid(self): + with self.assertRaises(AssertionError): + self.invite.accept() + + def test_accept_user_param_success(self): + invitee = UserFactory() + self.invite.accept(invitee) + + with self.subTest(msg="Test status"): + self.assertEqual(InviteStatus.ACCEPTED, self.invite.status) + + with self.subTest(msg="Test invitee assigned"): + self.assertEqual(invitee, self.invite.invitee) + + with self.subTest(msg="Test co-author added"): + self.assertTrue(self.invite.review.co_authors.filter(pk=invitee.pk).exists()) + + with self.subTest(msg="Test date answered"): + self.assertIsNotNone(self.invite.date_answered) + + def test_accept_without_user_param_success(self): + invitee = UserFactory() + self.invite.invitee = invitee + self.invite.save() + + self.invite.accept() + + with self.subTest(msg="Test status"): + self.assertEqual(InviteStatus.ACCEPTED, self.invite.status) + + with self.subTest(msg="Test invitee assigned"): + self.assertEqual(invitee, self.invite.invitee) + + with self.subTest(msg="Test co-author added"): + self.assertTrue(self.invite.review.co_authors.filter(pk=invitee.pk).exists()) + + with self.subTest(msg="Test date answered"): + self.assertIsNotNone(self.invite.date_answered) + + def test_str(self): + expected = "test-review - invitee@parsif.al - pending" + actual = str(self.invite) + self.assertEqual(expected, actual) + + def test_get_absolute_url(self): + expected = f"/invites/{self.invite.code}/" + actual = self.invite.get_absolute_url() + self.assertEqual(expected, actual) + + def test_get_invitee_email(self): + self.assertEqual("invitee@parsif.al", self.invite.get_invitee_email()) + + def test_get_invitee_email_with_invitee_instance(self): + user = UserFactory(email="invitee2@parsif.al") + self.invite.invitee = user + self.invite.save() + + with self.subTest(msg="Test setup"): + self.assertEqual("invitee@parsif.al", self.invite.invitee_email) + self.assertEqual("invitee2@parsif.al", self.invite.invitee.email) + + with self.subTest(msg="Test get_invitee_email"): + self.assertEqual("invitee2@parsif.al", self.invite.get_invitee_email()) diff --git a/parsifal/apps/invites/urls.py b/parsifal/apps/invites/urls.py new file mode 100644 index 00000000..d8031bfd --- /dev/null +++ b/parsifal/apps/invites/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from parsifal.apps.invites import views + +app_name = "invites" + +urlpatterns = [ + path("", views.ManageAccessView.as_view(), name="manage_access"), + path("/delete/", views.InviteDeleteView.as_view(), name="invite_delete"), +] diff --git a/parsifal/apps/invites/views.py b/parsifal/apps/invites/views.py new file mode 100644 index 00000000..cd784c9b --- /dev/null +++ b/parsifal/apps/invites/views.py @@ -0,0 +1,96 @@ +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views import View +from django.views.generic import CreateView, DeleteView, DetailView, ListView + +from parsifal.apps.invites.constants import InviteStatus +from parsifal.apps.invites.forms import SendInviteForm +from parsifal.apps.invites.models import Invite +from parsifal.apps.reviews.mixins import MainAuthorRequiredMixin, ReviewMixin +from parsifal.utils.mask import mask_email + + +class ManageAccessView(LoginRequiredMixin, MainAuthorRequiredMixin, ReviewMixin, SuccessMessageMixin, CreateView): + model = Invite + form_class = SendInviteForm + template_name = "invites/manage_access.html" + + def get_success_url(self): + return reverse("invites:manage_access", args=(self.review.author.username, self.review.name)) + + def get_success_message(self, cleaned_data): + return _("An invitation was sent to %s.") % self.object.get_invitee_email() + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update(request=self.request, review=self.review) + return kwargs + + def get_context_data(self, **kwargs): + invites = self.review.invites.select_related("invited_by__profile", "invitee__profile").order_by("-date_sent") + kwargs.update(invites=invites) + return super().get_context_data(**kwargs) + + +class InviteDeleteView(LoginRequiredMixin, MainAuthorRequiredMixin, ReviewMixin, DeleteView): + model = Invite + pk_url_kwarg = "invite_id" + context_object_name = "invite" + + def get_queryset(self): + return self.review.invites.filter(status=InviteStatus.PENDING) + + def get_success_url(self): + return reverse("invites:manage_access", args=(self.review.author.username, self.review.name)) + + def delete(self, request, *args, **kwargs): + response = super().delete(request, *args, **kwargs) + messages.success(request, _("The invitation was removed with success.")) + return response + + +class InviteDetailView(DetailView): + model = Invite + slug_field = "code" + slug_url_kwarg = "code" + context_object_name = "invite" + + def get_queryset(self): + return Invite.objects.filter(status=InviteStatus.PENDING) + + def get_context_data(self, **kwargs): + kwargs.update(invitee_masked_email=mask_email(self.object.get_invitee_email())) + return super().get_context_data(**kwargs) + + +class UserInviteListView(LoginRequiredMixin, ListView): + model = Invite + context_object_name = "invites" + template_name = "invites/user_invite_list.html" + + def get_queryset(self): + return Invite.objects.select_related("invited_by__profile").filter( + status=InviteStatus.PENDING, invitee=self.request.user + ) + + +class AcceptUserInviteView(LoginRequiredMixin, View): + def post(self, request, invite_id): + queryset = Invite.objects.filter(status=InviteStatus.PENDING, invitee=self.request.user) + invite = get_object_or_404(queryset, pk=invite_id) + invite.accept() + messages.success(request, _("You have joined the review %s.") % invite.review.title) + return redirect(invite.review) + + +class RejectUserInviteView(LoginRequiredMixin, View): + def post(self, request, invite_id): + queryset = Invite.objects.filter(status=InviteStatus.PENDING, invitee=self.request.user) + invite = get_object_or_404(queryset, pk=invite_id) + invite.reject() + messages.success(request, _("You have rejected the invitation to join the review %s.") % invite.review.title) + return redirect("user_invites") diff --git a/parsifal/apps/reviews/mixins.py b/parsifal/apps/reviews/mixins.py new file mode 100644 index 00000000..cd47054f --- /dev/null +++ b/parsifal/apps/reviews/mixins.py @@ -0,0 +1,43 @@ +from django.contrib.auth.mixins import UserPassesTestMixin +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property + +from parsifal.apps.reviews.models import Review + + +class ReviewMixin: + """ + This mixin is usually used together with a view that inherits from + ContextMixin. + """ + + @cached_property + def review(self): + queryset = Review.objects.select_related("author__profile").prefetch_related("co_authors__profile") + return get_object_or_404( + queryset, name=self.kwargs.get("review_name"), author__username=self.kwargs.get("username") + ) + + def get_context_data(self, **kwargs): + kwargs.update(review=self.review) + return super().get_context_data(**kwargs) + + +class MainAuthorRequiredMixin(UserPassesTestMixin): + """ + This mixin depends on having a review property on the view class. + It is usually used together with the ReviewMixin + """ + + def test_func(self): + return self.review.author == self.request.user + + +class AuthorRequiredMixin(UserPassesTestMixin): + """ + This mixin depends on having a review property on the view class. + It is usually used together with the ReviewMixin + """ + + def test_func(self): + return self.review.is_author_or_coauthor(self.request.user) diff --git a/parsifal/apps/reviews/settings/templates/settings/includes/menu.html b/parsifal/apps/reviews/settings/templates/settings/includes/menu.html new file mode 100644 index 00000000..ef1f2bcc --- /dev/null +++ b/parsifal/apps/reviews/settings/templates/settings/includes/menu.html @@ -0,0 +1,8 @@ +{% load i18n %} + + diff --git a/parsifal/apps/reviews/settings/templates/settings/review_settings.html b/parsifal/apps/reviews/settings/templates/settings/review_settings.html index 43e3af28..8df31786 100644 --- a/parsifal/apps/reviews/settings/templates/settings/review_settings.html +++ b/parsifal/apps/reviews/settings/templates/settings/review_settings.html @@ -1,8 +1,8 @@ {% extends 'base.html' %} -{% load static %} +{% load i18n static %} -{% block title %}Review Settings · {{ review.title }}{% endblock %} +{% block title %}{% trans "Review Settings" %} · {{ review.title }}{% endblock %} {% block javascript %} - + {% compress js %} + + {% endcompress %} {% endblock %} {% block content %} @@ -29,7 +20,7 @@ {% csrf_token %}
-

Review details

+

{% trans "Review details" %}

{% include 'form_vertical.html' with form=form %} @@ -37,7 +28,7 @@

Review details

@@ -46,14 +37,14 @@

Review details

-

Authors

+

{% trans "Authors" %}

{% if user.id == review.author.id %} {% endif %}
diff --git a/parsifal/apps/reviews/templates/reviews/reviews.html b/parsifal/apps/reviews/templates/reviews/reviews.html index 8518650c..9115a0db 100644 --- a/parsifal/apps/reviews/templates/reviews/reviews.html +++ b/parsifal/apps/reviews/templates/reviews/reviews.html @@ -1,127 +1,140 @@ {% extends 'base.html' %} -{% load static %} +{% load compress i18n static %} {% block title %}{{ page_user.profile.get_screen_name }} · Parsifal{% endblock %} {% block javascript %} - + {% compress js %} + + {% endcompress %} {% endblock javascript %} {% block content %} -
-
- - {{ page_user.profile.get_screen_name }} - -
-
-
-
-

- {{ page_user.get_full_name }} - {% if user.is_authenticated %} - {% if user.id == page_user.id %} - - - Edit profile - + + {% if pending_invites_count > 0 %} +
+ {% url "user_invites" as invites_url %} + {% blocktrans count pending_invites_count=pending_invites_count %} + You have {{ pending_invites_count }} pending invite to collaborate on a literature review. Review invite. + {% plural %} + You have {{ pending_invites_count }} pending invites to collaborate on some literature reviews. Review invites. + {% endblocktrans %} +
+ {% endif %} + +
+
+ + {{ page_user.profile.get_screen_name }} + +
+
+
+
+

+ {{ page_user.get_full_name }} + {% if user.is_authenticated %} + {% if user.id == page_user.id %} + + + Edit profile + + {% else %} + {% if is_following %} + {% else %} - {% if is_following %} - - {% else %} - - {% endif %} + {% endif %} {% endif %} -

-
+ {% endif %} +

+
+
+
+
+
+ {% if page_user.is_staff %}STAFF{% endif %} + + Followers + {{ followers_count }} + + + + Following + {{ following_count }} + +
-
-
-
- {% if page_user.is_staff %}STAFF{% endif %} - - Followers - {{ followers_count }} - - - - Following - {{ following_count }} - -
-
+
+
+
+ {{ page_user.username }} + {% if page_user.profile.public_email %} {{ page_user.profile.public_email }}{% endif %} + {% if page_user.profile.url %} {{ page_user.profile.get_url }}{% endif %}
-
-
- {{ page_user.username }} - {% if page_user.profile.public_email %} {{ page_user.profile.public_email }}{% endif %} - {% if page_user.profile.url %} {{ page_user.profile.get_url }}{% endif %} -
-
- {% if page_user.profile.institution %} {{ page_user.profile.institution }}{% endif %} - {% if page_user.profile.location %} {{ page_user.profile.location }}{% endif %} - Joined on {{ page_user.date_joined|date:"d M Y" }} -
+
+ {% if page_user.profile.institution %} {{ page_user.profile.institution }}{% endif %} + {% if page_user.profile.location %} {{ page_user.profile.location }}{% endif %} + Joined on {{ page_user.date_joined|date:"d M Y" }}
+
- {% if user.id == page_user.id %} - - {% if user_reviews %} -
-
-

Work in progress

-
- - + {% if user.id == page_user.id %} + + {% if user_reviews %} +
+
+

Work in progress

+
+
+ + + + + + + + + {% for review in user_reviews %} - - - + + + - - - {% for review in user_reviews %} - - - - - - {% endfor %} - -
TitleAuthorsLast update
TitleAuthorsLast update{{ review.title }} + {{ review.author.profile.get_screen_name }}{% for author in review.co_authors.all %}, {{ author.profile.get_screen_name }}{% endfor %} + {{ review.last_update|date:"D d M Y" }}
{{ review.title }} - {{ review.author.profile.get_screen_name }}{% for author in review.co_authors.all %}, {{ author.profile.get_screen_name }}{% endfor %} - {{ review.last_update|date:"D d M Y" }}
-
-
-
-

Published reviews

-
-
You haven't published any systematic literature review yet.
-
- {% else %} -
- -

You don't have any systematic literature review yet.

- Start a review -
- {% endif %} - {% else %} + {% endfor %} + + +

Published reviews

-
{{ page_user.profile.get_screen_name }} haven't published any systematic literature review yet.
+
You haven't published any systematic literature review yet.
+
+ {% else %} +
+ +

You don't have any systematic literature review yet.

+ Start a review
{% endif %} - + {% else %} +
+
+

Published reviews

+
+
{{ page_user.profile.get_screen_name }} haven't published any systematic literature review yet.
+
+ {% endif %} {% endblock content %} diff --git a/parsifal/apps/reviews/urls.py b/parsifal/apps/reviews/urls.py index 391bfa15..be947b4b 100644 --- a/parsifal/apps/reviews/urls.py +++ b/parsifal/apps/reviews/urls.py @@ -6,7 +6,6 @@ urlpatterns = [ path("new/", views.new, name="new"), - path("add_author/", views.add_author_to_review, name="add_author_to_review"), path("remove_author/", views.remove_author_from_review, name="remove_author_from_review"), path("save_description/", views.save_description, name="save_description"), path("leave/", views.leave, name="leave"), diff --git a/parsifal/apps/reviews/views.py b/parsifal/apps/reviews/views.py index 94dc2f20..9e4d165c 100644 --- a/parsifal/apps/reviews/views.py +++ b/parsifal/apps/reviews/views.py @@ -1,13 +1,13 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User -from django.core.mail import EmailMultiAlternatives from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse as r from django.utils.text import slugify from django.views.decorators.http import require_POST +from parsifal.apps.invites.constants import InviteStatus from parsifal.apps.reviews.decorators import author_required, main_author_required from parsifal.apps.reviews.forms import CreateReviewForm, ReviewForm from parsifal.apps.reviews.models import Review @@ -25,6 +25,9 @@ def reviews(request, username): user_reviews = user.profile.get_reviews() + pending_invites = user.invites_received.filter(status=InviteStatus.PENDING) + pending_invites_count = pending_invites.count() + return render( request, "reviews/reviews.html", @@ -34,6 +37,8 @@ def reviews(request, username): "is_following": is_following, "following_count": following_count, "followers_count": followers_count, + "pending_invites": pending_invites, + "pending_invites_count": pending_invites_count, }, ) @@ -77,64 +82,6 @@ def review(request, username, review_name): return render(request, "reviews/review.html", {"review": review, "form": form}) -@main_author_required -@login_required -@require_POST -def add_author_to_review(request): - emails = request.POST.getlist("users") - review_id = request.POST.get("review-id") - review = get_object_or_404(Review, pk=review_id) - authors_added = [] - authors_invited = [] - - inviter_name = request.user.profile.get_screen_name() - - for email in emails: - try: - user = User.objects.get(email__iexact=email) - if user.id != review.author.id: - authors_added.append(user.profile.get_screen_name()) - review.co_authors.add(user) - except User.DoesNotExist: - authors_invited.append(email) - - subject = "{0} wants to add you as co-author on the systematic literature review {1}".format( - inviter_name, review.title - ) - from_email = "{0} via Parsifal ".format(inviter_name) - - text_content = """Hi {0}, - {1} invited you to a Parsifal Systematic Literature Review called "{2}". - View the review at https://parsif.al/{3}/{4}/""".format( - email, inviter_name, review.title, request.user.username, review.name - ) - - html_content = """

Hi {0},

-

{1} invited you to a Parsifal Systematic Literature Review called "{2}".

-

View the review at https://parsif.al/{3}/{4}/

-

Sincerely,

-

The Parsifal Team

""".format( - email, inviter_name, review.title, request.user.username, review.name - ) - - msg = EmailMultiAlternatives(subject, text_content, from_email, [email]) - msg.attach_alternative(html_content, "text/html") - msg.send() - - review.save() - - if not authors_added and not authors_invited: - messages.info(request, "No author invited or added to the review. Nothing really changed.") - - if authors_added: - messages.success(request, "The authors {0} were added successfully.".format(", ".join(authors_added))) - - if authors_invited: - messages.success(request, "{0} were invited successfully.".format(", ".join(authors_invited))) - - return redirect(r("review", args=(review.author.username, review.name))) - - @main_author_required @login_required @require_POST diff --git a/parsifal/newsfragments/40.feature b/parsifal/newsfragments/40.feature new file mode 100644 index 00000000..aae8da01 --- /dev/null +++ b/parsifal/newsfragments/40.feature @@ -0,0 +1 @@ +Add new invite co-author feature diff --git a/parsifal/settings/base.py b/parsifal/settings/base.py index 71003bb5..02bbdd40 100644 --- a/parsifal/settings/base.py +++ b/parsifal/settings/base.py @@ -47,6 +47,7 @@ "parsifal.apps.blog", "parsifal.apps.core", "parsifal.apps.help", + "parsifal.apps.invites", "parsifal.apps.library", ] diff --git a/parsifal/static/css/parsifal.css b/parsifal/static/css/parsifal.css index ff6734f4..6416d982 100644 --- a/parsifal/static/css/parsifal.css +++ b/parsifal/static/css/parsifal.css @@ -165,10 +165,10 @@ article .table, article .alert { } @keyframes bouncedelay { - 0%, 80%, 100% { + 0%, 80%, 100% { transform: scale(0.0); -webkit-transform: scale(0.0); - } 40% { + } 40% { transform: scale(1.0); -webkit-transform: scale(1.0); } @@ -180,7 +180,7 @@ article .table, article .alert { margin-left: auto; margin-right: auto; background-color: #333; - border-radius: 100%; + border-radius: 100%; -webkit-animation: scaleout 1.0s infinite ease-in-out; animation: scaleout 1.0s infinite ease-in-out; } @@ -194,7 +194,7 @@ article .table, article .alert { } @keyframes scaleout { - 0% { + 0% { transform: scale(0.0); -webkit-transform: scale(0.0); } 100% { @@ -243,7 +243,7 @@ article .table, article .alert { background-color: #fcf8e3; } -#library-menu div.list-group a.list-group-item.active { +div.list-group a.list-group-item.active { background-color: transparent; color: #555; border-color: #ddd; @@ -277,12 +277,12 @@ a.library-documents-indexes:active { /* Parsifal cover */ .cover { - padding-top: 60px; + padding-top: 60px; padding-bottom: 80px; } .cover h1 { - font-size: 64px; + font-size: 64px; margin-bottom: 30px; } @@ -309,8 +309,8 @@ section.features article span.glyphicon { /* Parsifal reviews app */ .review-title { - margin-top: 0; - margin-bottom: 20px; + margin-top: 0; + margin-bottom: 20px; line-height: 1.42857; } @@ -383,7 +383,7 @@ ul.review-menu li.active { top: 20px; } -#protocol.affix, +#protocol.affix, #protocol.affix-bottom { width: 213px; } @@ -393,14 +393,14 @@ ul.review-menu li.active { } @media (min-width:992px) { - #protocol.affix, + #protocol.affix, #protocol.affix-bottom { width: 293px; } } @media (min-width:1200px) { - #protocol.affix, + #protocol.affix, #protocol.affix-bottom { width: 263px; } @@ -439,60 +439,6 @@ ul.review-menu li.active { margin-bottom: 0; } -/* Selectize options */ - -.selectize-control.contacts .selectize-input > div { - padding: 1px 10px; - font-size: 13px; - font-weight: normal; - -webkit-font-smoothing: auto; - color: #f7fbff; - text-shadow: 0 1px 0 rgba(8,32,65,0.2); - background: #337AB7; - background: -moz-linear-gradient(top, #2183f5 0%, #1d77f3 100%); - background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#2183f5), color-stop(100%,#1d77f3)); - background: -webkit-linear-gradient(top, #2183f5 0%,#1d77f3 100%); - background: -o-linear-gradient(top, #2183f5 0%,#1d77f3 100%); - background: -ms-linear-gradient(top, #2183f5 0%,#1d77f3 100%); - background: linear-gradient(to bottom, #2183f5 0%,#1d77f3 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#2183f5', endColorstr='#1d77f3',GradientType=0 ); - border: 1px solid #0f65d2; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - -webkit-box-shadow: 0 1px 1px rgba(0,0,0,0.15); - -moz-box-shadow: 0 1px 1px rgba(0,0,0,0.15); - box-shadow: 0 1px 1px rgba(0,0,0,0.15); -} -.selectize-control.contacts .selectize-input > div.active { - background: #0059c7; - background: -moz-linear-gradient(top, #0059c7 0%, #0051c1 100%); - background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#0059c7), color-stop(100%,#0051c1)); - background: -webkit-linear-gradient(top, #0059c7 0%,#0051c1 100%); - background: -o-linear-gradient(top, #0059c7 0%,#0051c1 100%); - background: -ms-linear-gradient(top, #0059c7 0%,#0051c1 100%); - background: linear-gradient(to bottom, #0059c7 0%,#0051c1 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#0059c7', endColorstr='#0051c1',GradientType=0 ); - border-color: #0051c1; -} -.selectize-control.contacts .selectize-input > div .email { - opacity: 0.8; -} -.selectize-control.contacts .selectize-input > div .name + .email { - margin-left: 5px; -} -.selectize-control.contacts .selectize-input > div .email:before { - content: '<'; -} -.selectize-control.contacts .selectize-input > div .email:after { - content: '>'; -} -.selectize-control.contacts .selectize-dropdown .caption { - font-size: 12px; - display: block; - color: #a0a0a0; -} - /* Bootstrap overrides */ .navbar-default .navbar-brand { @@ -515,7 +461,7 @@ ul.review-menu li.active { /* Bootstrap extension */ -table.table-v-align-middle thead tr th, +table.table-v-align-middle thead tr th, table.table-v-align-middle tbody tr td { vertical-align: middle; } diff --git a/parsifal/static/css/selectize.bootstrap3.css b/parsifal/static/css/selectize.bootstrap3.css deleted file mode 100644 index cfb2bfa2..00000000 --- a/parsifal/static/css/selectize.bootstrap3.css +++ /dev/null @@ -1,401 +0,0 @@ -/** - * selectize.bootstrap3.css (v0.12.1) - Bootstrap 3 Theme - * Copyright (c) 2013–2015 Brian Reavis & contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this - * file except in compliance with the License. You may obtain a copy of the License at: - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF - * ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - * - * @author Brian Reavis - */ -.selectize-control.plugin-drag_drop.multi > .selectize-input > div.ui-sortable-placeholder { - visibility: visible !important; - background: #f2f2f2 !important; - background: rgba(0, 0, 0, 0.06) !important; - border: 0 none !important; - -webkit-box-shadow: inset 0 0 12px 4px #ffffff; - box-shadow: inset 0 0 12px 4px #ffffff; -} -.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after { - content: '!'; - visibility: hidden; -} -.selectize-control.plugin-drag_drop .ui-sortable-helper { - -webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); -} -.selectize-dropdown-header { - position: relative; - padding: 3px 12px; - border-bottom: 1px solid #d0d0d0; - background: #f8f8f8; - -webkit-border-radius: 4px 4px 0 0; - -moz-border-radius: 4px 4px 0 0; - border-radius: 4px 4px 0 0; -} -.selectize-dropdown-header-close { - position: absolute; - right: 12px; - top: 50%; - color: #333333; - opacity: 0.4; - margin-top: -12px; - line-height: 20px; - font-size: 20px !important; -} -.selectize-dropdown-header-close:hover { - color: #000000; -} -.selectize-dropdown.plugin-optgroup_columns .optgroup { - border-right: 1px solid #f2f2f2; - border-top: 0 none; - float: left; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} -.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child { - border-right: 0 none; -} -.selectize-dropdown.plugin-optgroup_columns .optgroup:before { - display: none; -} -.selectize-dropdown.plugin-optgroup_columns .optgroup-header { - border-top: 0 none; -} -.selectize-control.plugin-remove_button [data-value] { - position: relative; - padding-right: 24px !important; -} -.selectize-control.plugin-remove_button [data-value] .remove { - z-index: 1; - /* fixes ie bug (see #392) */ - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 17px; - text-align: center; - font-weight: bold; - font-size: 12px; - color: inherit; - text-decoration: none; - vertical-align: middle; - display: inline-block; - padding: 1px 0 0 0; - border-left: 1px solid rgba(0, 0, 0, 0); - -webkit-border-radius: 0 2px 2px 0; - -moz-border-radius: 0 2px 2px 0; - border-radius: 0 2px 2px 0; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} -.selectize-control.plugin-remove_button [data-value] .remove:hover { - background: rgba(0, 0, 0, 0.05); -} -.selectize-control.plugin-remove_button [data-value].active .remove { - border-left-color: rgba(0, 0, 0, 0); -} -.selectize-control.plugin-remove_button .disabled [data-value] .remove:hover { - background: none; -} -.selectize-control.plugin-remove_button .disabled [data-value] .remove { - border-left-color: rgba(77, 77, 77, 0); -} -.selectize-control { - position: relative; -} -.selectize-dropdown, -.selectize-input, -.selectize-input input { - color: #333333; - font-family: inherit; - font-size: inherit; - line-height: 20px; - -webkit-font-smoothing: inherit; -} -.selectize-input, -.selectize-control.single .selectize-input.input-active { - background: #ffffff; - cursor: text; - display: inline-block; -} -.selectize-input { - border: 1px solid #cccccc; - padding: 6px 12px; - display: inline-block; - width: 100%; - overflow: hidden; - position: relative; - z-index: 1; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - -webkit-box-shadow: none; - box-shadow: none; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} -.selectize-control.multi .selectize-input.has-items { - padding: 5px 12px 2px; -} -.selectize-input.full { - background-color: #ffffff; -} -.selectize-input.disabled, -.selectize-input.disabled * { - cursor: default !important; -} -.selectize-input.focus { - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); -} -.selectize-input.dropdown-active { - -webkit-border-radius: 4px 4px 0 0; - -moz-border-radius: 4px 4px 0 0; - border-radius: 4px 4px 0 0; -} -.selectize-input > * { - vertical-align: baseline; - display: -moz-inline-stack; - display: inline-block; - zoom: 1; - *display: inline; -} -.selectize-control.multi .selectize-input > div { - cursor: pointer; - margin: 0 3px 3px 0; - padding: 1px 3px; - background: #efefef; - color: #333333; - border: 0 solid rgba(0, 0, 0, 0); -} -.selectize-control.multi .selectize-input > div.active { - background: #428bca; - color: #ffffff; - border: 0 solid rgba(0, 0, 0, 0); -} -.selectize-control.multi .selectize-input.disabled > div, -.selectize-control.multi .selectize-input.disabled > div.active { - color: #808080; - background: #ffffff; - border: 0 solid rgba(77, 77, 77, 0); -} -.selectize-input > input { - display: inline-block !important; - padding: 0 !important; - min-height: 0 !important; - max-height: none !important; - max-width: 100% !important; - margin: 0 !important; - text-indent: 0 !important; - border: 0 none !important; - background: none !important; - line-height: inherit !important; - -webkit-user-select: auto !important; - -webkit-box-shadow: none !important; - box-shadow: none !important; -} -.selectize-input > input::-ms-clear { - display: none; -} -.selectize-input > input:focus { - outline: none !important; -} -.selectize-input::after { - content: ' '; - display: block; - clear: left; -} -.selectize-input.dropdown-active::before { - content: ' '; - display: block; - position: absolute; - background: #ffffff; - height: 1px; - bottom: 0; - left: 0; - right: 0; -} -.selectize-dropdown { - position: absolute; - z-index: 10; - border: 1px solid #d0d0d0; - background: #ffffff; - margin: -1px 0 0 0; - border-top: 0 none; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - -webkit-border-radius: 0 0 4px 4px; - -moz-border-radius: 0 0 4px 4px; - border-radius: 0 0 4px 4px; -} -.selectize-dropdown [data-selectable] { - cursor: pointer; - overflow: hidden; -} -.selectize-dropdown [data-selectable] .highlight { - background: rgba(255, 237, 40, 0.4); - -webkit-border-radius: 1px; - -moz-border-radius: 1px; - border-radius: 1px; -} -.selectize-dropdown [data-selectable], -.selectize-dropdown .optgroup-header { - padding: 3px 12px; -} -.selectize-dropdown .optgroup:first-child .optgroup-header { - border-top: 0 none; -} -.selectize-dropdown .optgroup-header { - color: #777777; - background: #ffffff; - cursor: default; -} -.selectize-dropdown .active { - background-color: #f5f5f5; - color: #262626; -} -.selectize-dropdown .active.create { - color: #262626; -} -.selectize-dropdown .create { - color: rgba(51, 51, 51, 0.5); -} -.selectize-dropdown-content { - overflow-y: auto; - overflow-x: hidden; - max-height: 200px; -} -.selectize-control.single .selectize-input, -.selectize-control.single .selectize-input input { - cursor: pointer; -} -.selectize-control.single .selectize-input.input-active, -.selectize-control.single .selectize-input.input-active input { - cursor: text; -} -.selectize-control.single .selectize-input:after { - content: ' '; - display: block; - position: absolute; - top: 50%; - right: 17px; - margin-top: -3px; - width: 0; - height: 0; - border-style: solid; - border-width: 5px 5px 0 5px; - border-color: #333333 transparent transparent transparent; -} -.selectize-control.single .selectize-input.dropdown-active:after { - margin-top: -4px; - border-width: 0 5px 5px 5px; - border-color: transparent transparent #333333 transparent; -} -.selectize-control.rtl.single .selectize-input:after { - left: 17px; - right: auto; -} -.selectize-control.rtl .selectize-input > input { - margin: 0 4px 0 -2px !important; -} -.selectize-control .selectize-input.disabled { - opacity: 0.5; - background-color: #ffffff; -} -.selectize-dropdown, -.selectize-dropdown.form-control { - height: auto; - padding: 0; - margin: 2px 0 0 0; - z-index: 1000; - background: #ffffff; - border: 1px solid #cccccc; - border: 1px solid rgba(0, 0, 0, 0.15); - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); -} -.selectize-dropdown .optgroup-header { - font-size: 12px; - line-height: 1.42857143; -} -.selectize-dropdown .optgroup:first-child:before { - display: none; -} -.selectize-dropdown .optgroup:before { - content: ' '; - display: block; - height: 1px; - margin: 9px 0; - overflow: hidden; - background-color: #e5e5e5; - margin-left: -12px; - margin-right: -12px; -} -.selectize-dropdown-content { - padding: 5px 0; -} -.selectize-dropdown-header { - padding: 6px 12px; -} -.selectize-input { - min-height: 34px; -} -.selectize-input.dropdown-active { - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} -.selectize-input.dropdown-active::before { - display: none; -} -.selectize-input.focus { - border-color: #66afe9; - outline: 0; - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6); - box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6); -} -.has-error .selectize-input { - border-color: #a94442; - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); -} -.has-error .selectize-input:focus { - border-color: #843534; - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483; -} -.selectize-control.multi .selectize-input.has-items { - padding-left: 9px; - padding-right: 9px; -} -.selectize-control.multi .selectize-input > div { - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; -} -.form-control.selectize-control { - padding: 0; - height: auto; - border: none; - background: none; - -webkit-box-shadow: none; - box-shadow: none; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} diff --git a/parsifal/static/js/selectize.min.js b/parsifal/static/js/selectize.min.js deleted file mode 100644 index fe955bc0..00000000 --- a/parsifal/static/js/selectize.min.js +++ /dev/null @@ -1,3 +0,0 @@ -/*! selectize.js - v0.12.1 | https://github.com/brianreavis/selectize.js | Apache License (v2) */ -!function(a,b){"function"==typeof define&&define.amd?define("sifter",b):"object"==typeof exports?module.exports=b():a.Sifter=b()}(this,function(){var a=function(a,b){this.items=a,this.settings=b||{diacritics:!0}};a.prototype.tokenize=function(a){if(a=d(String(a||"").toLowerCase()),!a||!a.length)return[];var b,c,f,h,i=[],j=a.split(/ +/);for(b=0,c=j.length;c>b;b++){if(f=e(j[b]),this.settings.diacritics)for(h in g)g.hasOwnProperty(h)&&(f=f.replace(new RegExp(h,"g"),g[h]));i.push({string:j[b],regex:new RegExp(f,"i")})}return i},a.prototype.iterator=function(a,b){var c;c=f(a)?Array.prototype.forEach||function(a){for(var b=0,c=this.length;c>b;b++)a(this[b],b,this)}:function(a){for(var b in this)this.hasOwnProperty(b)&&a(this[b],b,this)},c.apply(a,[b])},a.prototype.getScoreFunction=function(a,b){var c,d,e,f;c=this,a=c.prepareSearch(a,b),e=a.tokens,d=a.options.fields,f=e.length;var g=function(a,b){var c,d;return a?(a=String(a||""),d=a.search(b.regex),-1===d?0:(c=b.string.length/a.length,0===d&&(c+=.5),c)):0},h=function(){var a=d.length;return a?1===a?function(a,b){return g(b[d[0]],a)}:function(b,c){for(var e=0,f=0;a>e;e++)f+=g(c[d[e]],b);return f/a}:function(){return 0}}();return f?1===f?function(a){return h(e[0],a)}:"and"===a.options.conjunction?function(a){for(var b,c=0,d=0;f>c;c++){if(b=h(e[c],a),0>=b)return 0;d+=b}return d/f}:function(a){for(var b=0,c=0;f>b;b++)c+=h(e[b],a);return c/f}:function(){return 0}},a.prototype.getSortFunction=function(a,c){var d,e,f,g,h,i,j,k,l,m,n;if(f=this,a=f.prepareSearch(a,c),n=!a.query&&c.sort_empty||c.sort,l=function(a,b){return"$score"===a?b.score:f.items[b.id][a]},h=[],n)for(d=0,e=n.length;e>d;d++)(a.query||"$score"!==n[d].field)&&h.push(n[d]);if(a.query){for(m=!0,d=0,e=h.length;e>d;d++)if("$score"===h[d].field){m=!1;break}m&&h.unshift({field:"$score",direction:"desc"})}else for(d=0,e=h.length;e>d;d++)if("$score"===h[d].field){h.splice(d,1);break}for(k=[],d=0,e=h.length;e>d;d++)k.push("desc"===h[d].direction?-1:1);return i=h.length,i?1===i?(g=h[0].field,j=k[0],function(a,c){return j*b(l(g,a),l(g,c))}):function(a,c){var d,e,f;for(d=0;i>d;d++)if(f=h[d].field,e=k[d]*b(l(f,a),l(f,c)))return e;return 0}:null},a.prototype.prepareSearch=function(a,b){if("object"==typeof a)return a;b=c({},b);var d=b.fields,e=b.sort,g=b.sort_empty;return d&&!f(d)&&(b.fields=[d]),e&&!f(e)&&(b.sort=[e]),g&&!f(g)&&(b.sort_empty=[g]),{options:b,query:String(a||"").toLowerCase(),tokens:this.tokenize(a),total:0,items:[]}},a.prototype.search=function(a,b){var c,d,e,f,g=this;return d=this.prepareSearch(a,b),b=d.options,a=d.query,f=b.score||g.getScoreFunction(d),a.length?g.iterator(g.items,function(a,e){c=f(a),(b.filter===!1||c>0)&&d.items.push({score:c,id:e})}):g.iterator(g.items,function(a,b){d.items.push({score:1,id:b})}),e=g.getSortFunction(d,b),e&&d.items.sort(e),d.total=d.items.length,"number"==typeof b.limit&&(d.items=d.items.slice(0,b.limit)),d};var b=function(a,b){return"number"==typeof a&&"number"==typeof b?a>b?1:b>a?-1:0:(a=h(String(a||"")),b=h(String(b||"")),a>b?1:b>a?-1:0)},c=function(a){var b,c,d,e;for(b=1,c=arguments.length;c>b;b++)if(e=arguments[b])for(d in e)e.hasOwnProperty(d)&&(a[d]=e[d]);return a},d=function(a){return(a+"").replace(/^\s+|\s+$|/g,"")},e=function(a){return(a+"").replace(/([.?*+^$[\]\\(){}|-])/g,"\\$1")},f=Array.isArray||$&&$.isArray||function(a){return"[object Array]"===Object.prototype.toString.call(a)},g={a:"[aÀÁÂÃÄÅàáâãäåĀāąĄ]",c:"[cÇçćĆčČ]",d:"[dđĐďĎ]",e:"[eÈÉÊËèéêëěĚĒēęĘ]",i:"[iÌÍÎÏìíîïĪī]",l:"[lłŁ]",n:"[nÑñňŇńŃ]",o:"[oÒÓÔÕÕÖØòóôõöøŌō]",r:"[rřŘ]",s:"[sŠšśŚ]",t:"[tťŤ]",u:"[uÙÚÛÜùúûüůŮŪū]",y:"[yŸÿýÝ]",z:"[zŽžżŻźŹ]"},h=function(){var a,b,c,d,e="",f={};for(c in g)if(g.hasOwnProperty(c))for(d=g[c].substring(2,g[c].length-1),e+=d,a=0,b=d.length;b>a;a++)f[d.charAt(a)]=c;var h=new RegExp("["+e+"]","g");return function(a){return a.replace(h,function(a){return f[a]}).toLowerCase()}}();return a}),function(a,b){"function"==typeof define&&define.amd?define("microplugin",b):"object"==typeof exports?module.exports=b():a.MicroPlugin=b()}(this,function(){var a={};a.mixin=function(a){a.plugins={},a.prototype.initializePlugins=function(a){var c,d,e,f=this,g=[];if(f.plugins={names:[],settings:{},requested:{},loaded:{}},b.isArray(a))for(c=0,d=a.length;d>c;c++)"string"==typeof a[c]?g.push(a[c]):(f.plugins.settings[a[c].name]=a[c].options,g.push(a[c].name));else if(a)for(e in a)a.hasOwnProperty(e)&&(f.plugins.settings[e]=a[e],g.push(e));for(;g.length;)f.require(g.shift())},a.prototype.loadPlugin=function(b){var c=this,d=c.plugins,e=a.plugins[b];if(!a.plugins.hasOwnProperty(b))throw new Error('Unable to find "'+b+'" plugin');d.requested[b]=!0,d.loaded[b]=e.fn.apply(c,[c.plugins.settings[b]||{}]),d.names.push(b)},a.prototype.require=function(a){var b=this,c=b.plugins;if(!b.plugins.loaded.hasOwnProperty(a)){if(c.requested[a])throw new Error('Plugin has circular dependency ("'+a+'")');b.loadPlugin(a)}return c.loaded[a]},a.define=function(b,c){a.plugins[b]={name:b,fn:c}}};var b={isArray:Array.isArray||function(a){return"[object Array]"===Object.prototype.toString.call(a)}};return a}),function(a,b){"function"==typeof define&&define.amd?define("selectize",["jquery","sifter","microplugin"],b):"object"==typeof exports?module.exports=b(require("jquery"),require("sifter"),require("microplugin")):a.Selectize=b(a.jQuery,a.Sifter,a.MicroPlugin)}(this,function(a,b,c){"use strict";var d=function(a,b){if("string"!=typeof b||b.length){var c="string"==typeof b?new RegExp(b,"i"):b,d=function(a){var b=0;if(3===a.nodeType){var e=a.data.search(c);if(e>=0&&a.data.length>0){var f=a.data.match(c),g=document.createElement("span");g.className="highlight";var h=a.splitText(e),i=(h.splitText(f[0].length),h.cloneNode(!0));g.appendChild(i),h.parentNode.replaceChild(g,h),b=1}}else if(1===a.nodeType&&a.childNodes&&!/(script|style)/i.test(a.tagName))for(var j=0;j/g,">").replace(/"/g,""")},B=function(a){return(a+"").replace(/\$/g,"$$$$")},C={};C.before=function(a,b,c){var d=a[b];a[b]=function(){return c.apply(a,arguments),d.apply(a,arguments)}},C.after=function(a,b,c){var d=a[b];a[b]=function(){var b=d.apply(a,arguments);return c.apply(a,arguments),b}};var D=function(a){var b=!1;return function(){b||(b=!0,a.apply(this,arguments))}},E=function(a,b){var c;return function(){var d=this,e=arguments;window.clearTimeout(c),c=window.setTimeout(function(){a.apply(d,e)},b)}},F=function(a,b,c){var d,e=a.trigger,f={};a.trigger=function(){var c=arguments[0];return-1===b.indexOf(c)?e.apply(a,arguments):void(f[c]=arguments)},c.apply(a,[]),a.trigger=e;for(d in f)f.hasOwnProperty(d)&&e.apply(a,f[d])},G=function(a,b,c,d){a.on(b,c,function(b){for(var c=b.target;c&&c.parentNode!==a[0];)c=c.parentNode;return b.currentTarget=c,d.apply(this,[b])})},H=function(a){var b={};if("selectionStart"in a)b.start=a.selectionStart,b.length=a.selectionEnd-b.start;else if(document.selection){a.focus();var c=document.selection.createRange(),d=document.selection.createRange().text.length;c.moveStart("character",-a.value.length),b.start=c.text.length-d,b.length=d}return b},I=function(a,b,c){var d,e,f={};if(c)for(d=0,e=c.length;e>d;d++)f[c[d]]=a.css(c[d]);else f=a.css();b.css(f)},J=function(b,c){if(!b)return 0;var d=a("").css({position:"absolute",top:-99999,left:-99999,width:"auto",padding:0,whiteSpace:"pre"}).text(b).appendTo("body");I(c,d,["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"]);var e=d.width();return d.remove(),e},K=function(a){var b=null,c=function(c,d){var e,f,g,h,i,j,k,l;c=c||window.event||{},d=d||{},c.metaKey||c.altKey||(d.force||a.data("grow")!==!1)&&(e=a.val(),c.type&&"keydown"===c.type.toLowerCase()&&(f=c.keyCode,g=f>=97&&122>=f||f>=65&&90>=f||f>=48&&57>=f||32===f,f===q||f===p?(l=H(a[0]),l.length?e=e.substring(0,l.start)+e.substring(l.start+l.length):f===p&&l.start?e=e.substring(0,l.start-1)+e.substring(l.start+1):f===q&&"undefined"!=typeof l.start&&(e=e.substring(0,l.start)+e.substring(l.start+1))):g&&(j=c.shiftKey,k=String.fromCharCode(c.keyCode),k=j?k.toUpperCase():k.toLowerCase(),e+=k)),h=a.attr("placeholder"),!e&&h&&(e=h),i=J(e,a)+4,i!==b&&(b=i,a.width(i),a.triggerHandler("resize")))};a.on("keydown keyup update blur",c),c()},L=function(c,d){var e,f,g,h,i=this;h=c[0],h.selectize=i;var j=window.getComputedStyle&&window.getComputedStyle(h,null);if(g=j?j.getPropertyValue("direction"):h.currentStyle&&h.currentStyle.direction,g=g||c.parents("[dir]:first").attr("dir")||"",a.extend(i,{order:0,settings:d,$input:c,tabIndex:c.attr("tabindex")||"",tagType:"select"===h.tagName.toLowerCase()?v:w,rtl:/rtl/i.test(g),eventNS:".selectize"+ ++L.count,highlightedValue:null,isOpen:!1,isDisabled:!1,isRequired:c.is("[required]"),isInvalid:!1,isLocked:!1,isFocused:!1,isInputHidden:!1,isSetup:!1,isShiftDown:!1,isCmdDown:!1,isCtrlDown:!1,ignoreFocus:!1,ignoreBlur:!1,ignoreHover:!1,hasOptions:!1,currentResults:null,lastValue:"",caretPos:0,loading:0,loadedSearches:{},$activeOption:null,$activeItems:[],optgroups:{},options:{},userOptions:{},items:[],renderCache:{},onSearchChange:null===d.loadThrottle?i.onSearchChange:E(i.onSearchChange,d.loadThrottle)}),i.sifter=new b(this.options,{diacritics:d.diacritics}),i.settings.options){for(e=0,f=i.settings.options.length;f>e;e++)i.registerOption(i.settings.options[e]);delete i.settings.options}if(i.settings.optgroups){for(e=0,f=i.settings.optgroups.length;f>e;e++)i.registerOptionGroup(i.settings.optgroups[e]);delete i.settings.optgroups}i.settings.mode=i.settings.mode||(1===i.settings.maxItems?"single":"multi"),"boolean"!=typeof i.settings.hideSelected&&(i.settings.hideSelected="multi"===i.settings.mode),i.initializePlugins(i.settings.plugins),i.setupCallbacks(),i.setupTemplates(),i.setup()};return e.mixin(L),c.mixin(L),a.extend(L.prototype,{setup:function(){var b,c,d,e,g,h,i,j,k,l=this,m=l.settings,n=l.eventNS,o=a(window),p=a(document),q=l.$input;if(i=l.settings.mode,j=q.attr("class")||"",b=a("
").addClass(m.wrapperClass).addClass(j).addClass(i),c=a("
").addClass(m.inputClass).addClass("items").appendTo(b),d=a('').appendTo(c).attr("tabindex",q.is(":disabled")?"-1":l.tabIndex),h=a(m.dropdownParent||b),e=a("
").addClass(m.dropdownClass).addClass(i).hide().appendTo(h),g=a("
").addClass(m.dropdownContentClass).appendTo(e),l.settings.copyClassesToDropdown&&e.addClass(j),b.css({width:q[0].style.width}),l.plugins.names.length&&(k="plugin-"+l.plugins.names.join(" plugin-"),b.addClass(k),e.addClass(k)),(null===m.maxItems||m.maxItems>1)&&l.tagType===v&&q.attr("multiple","multiple"),l.settings.placeholder&&d.attr("placeholder",m.placeholder),!l.settings.splitOn&&l.settings.delimiter){var u=l.settings.delimiter.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&");l.settings.splitOn=new RegExp("\\s*"+u+"+\\s*")}q.attr("autocorrect")&&d.attr("autocorrect",q.attr("autocorrect")),q.attr("autocapitalize")&&d.attr("autocapitalize",q.attr("autocapitalize")),l.$wrapper=b,l.$control=c,l.$control_input=d,l.$dropdown=e,l.$dropdown_content=g,e.on("mouseenter","[data-selectable]",function(){return l.onOptionHover.apply(l,arguments)}),e.on("mousedown click","[data-selectable]",function(){return l.onOptionSelect.apply(l,arguments)}),G(c,"mousedown","*:not(input)",function(){return l.onItemSelect.apply(l,arguments)}),K(d),c.on({mousedown:function(){return l.onMouseDown.apply(l,arguments)},click:function(){return l.onClick.apply(l,arguments)}}),d.on({mousedown:function(a){a.stopPropagation()},keydown:function(){return l.onKeyDown.apply(l,arguments)},keyup:function(){return l.onKeyUp.apply(l,arguments)},keypress:function(){return l.onKeyPress.apply(l,arguments)},resize:function(){l.positionDropdown.apply(l,[])},blur:function(){return l.onBlur.apply(l,arguments)},focus:function(){return l.ignoreBlur=!1,l.onFocus.apply(l,arguments)},paste:function(){return l.onPaste.apply(l,arguments)}}),p.on("keydown"+n,function(a){l.isCmdDown=a[f?"metaKey":"ctrlKey"],l.isCtrlDown=a[f?"altKey":"ctrlKey"],l.isShiftDown=a.shiftKey}),p.on("keyup"+n,function(a){a.keyCode===t&&(l.isCtrlDown=!1),a.keyCode===r&&(l.isShiftDown=!1),a.keyCode===s&&(l.isCmdDown=!1)}),p.on("mousedown"+n,function(a){if(l.isFocused){if(a.target===l.$dropdown[0]||a.target.parentNode===l.$dropdown[0])return!1;l.$control.has(a.target).length||a.target===l.$control[0]||l.blur(a.target)}}),o.on(["scroll"+n,"resize"+n].join(" "),function(){l.isOpen&&l.positionDropdown.apply(l,arguments)}),o.on("mousemove"+n,function(){l.ignoreHover=!1}),this.revertSettings={$children:q.children().detach(),tabindex:q.attr("tabindex")},q.attr("tabindex",-1).hide().after(l.$wrapper),a.isArray(m.items)&&(l.setValue(m.items),delete m.items),x&&q.on("invalid"+n,function(a){a.preventDefault(),l.isInvalid=!0,l.refreshState()}),l.updateOriginalInput(),l.refreshItems(),l.refreshState(),l.updatePlaceholder(),l.isSetup=!0,q.is(":disabled")&&l.disable(),l.on("change",this.onChange),q.data("selectize",l),q.addClass("selectized"),l.trigger("initialize"),m.preload===!0&&l.onSearchChange("")},setupTemplates:function(){var b=this,c=b.settings.labelField,d=b.settings.optgroupLabelField,e={optgroup:function(a){return'
'+a.html+"
"},optgroup_header:function(a,b){return'
'+b(a[d])+"
"},option:function(a,b){return'
'+b(a[c])+"
"},item:function(a,b){return'
'+b(a[c])+"
"},option_create:function(a,b){return'
Add '+b(a.input)+"
"}};b.settings.render=a.extend({},e,b.settings.render)},setupCallbacks:function(){var a,b,c={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"};for(a in c)c.hasOwnProperty(a)&&(b=this.settings[c[a]],b&&this.on(a,b))},onClick:function(a){var b=this;b.isFocused||(b.focus(),a.preventDefault())},onMouseDown:function(b){{var c=this,d=b.isDefaultPrevented();a(b.target)}if(c.isFocused){if(b.target!==c.$control_input[0])return"single"===c.settings.mode?c.isOpen?c.close():c.open():d||c.setActiveItem(null),!1}else d||window.setTimeout(function(){c.focus()},0)},onChange:function(){this.$input.trigger("change")},onPaste:function(b){var c=this;c.isFull()||c.isInputHidden||c.isLocked?b.preventDefault():c.settings.splitOn&&setTimeout(function(){for(var b=a.trim(c.$control_input.val()||"").split(c.settings.splitOn),d=0,e=b.length;e>d;d++)c.createItem(b[d])},0)},onKeyPress:function(a){if(this.isLocked)return a&&a.preventDefault();var b=String.fromCharCode(a.keyCode||a.which);return this.settings.create&&"multi"===this.settings.mode&&b===this.settings.delimiter?(this.createItem(),a.preventDefault(),!1):void 0},onKeyDown:function(a){var b=(a.target===this.$control_input[0],this);if(b.isLocked)return void(a.keyCode!==u&&a.preventDefault());switch(a.keyCode){case g:if(b.isCmdDown)return void b.selectAll();break;case i:return void(b.isOpen&&(a.preventDefault(),a.stopPropagation(),b.close()));case o:if(!a.ctrlKey||a.altKey)break;case n:if(!b.isOpen&&b.hasOptions)b.open();else if(b.$activeOption){b.ignoreHover=!0;var c=b.getAdjacentOption(b.$activeOption,1);c.length&&b.setActiveOption(c,!0,!0)}return void a.preventDefault();case l:if(!a.ctrlKey||a.altKey)break;case k:if(b.$activeOption){b.ignoreHover=!0;var d=b.getAdjacentOption(b.$activeOption,-1);d.length&&b.setActiveOption(d,!0,!0)}return void a.preventDefault();case h:return void(b.isOpen&&b.$activeOption&&(b.onOptionSelect({currentTarget:b.$activeOption}),a.preventDefault()));case j:return void b.advanceSelection(-1,a);case m:return void b.advanceSelection(1,a);case u:return b.settings.selectOnTab&&b.isOpen&&b.$activeOption&&(b.onOptionSelect({currentTarget:b.$activeOption}),b.isFull()||a.preventDefault()),void(b.settings.create&&b.createItem()&&a.preventDefault());case p:case q:return void b.deleteSelection(a)}return!b.isFull()&&!b.isInputHidden||(f?a.metaKey:a.ctrlKey)?void 0:void a.preventDefault()},onKeyUp:function(a){var b=this;if(b.isLocked)return a&&a.preventDefault();var c=b.$control_input.val()||"";b.lastValue!==c&&(b.lastValue=c,b.onSearchChange(c),b.refreshOptions(),b.trigger("type",c))},onSearchChange:function(a){var b=this,c=b.settings.load;c&&(b.loadedSearches.hasOwnProperty(a)||(b.loadedSearches[a]=!0,b.load(function(d){c.apply(b,[a,d])})))},onFocus:function(a){var b=this,c=b.isFocused;return b.isDisabled?(b.blur(),a&&a.preventDefault(),!1):void(b.ignoreFocus||(b.isFocused=!0,"focus"===b.settings.preload&&b.onSearchChange(""),c||b.trigger("focus"),b.$activeItems.length||(b.showInput(),b.setActiveItem(null),b.refreshOptions(!!b.settings.openOnFocus)),b.refreshState()))},onBlur:function(a,b){var c=this;if(c.isFocused&&(c.isFocused=!1,!c.ignoreFocus)){if(!c.ignoreBlur&&document.activeElement===c.$dropdown_content[0])return c.ignoreBlur=!0,void c.onFocus(a);var d=function(){c.close(),c.setTextboxValue(""),c.setActiveItem(null),c.setActiveOption(null),c.setCaret(c.items.length),c.refreshState(),(b||document.body).focus(),c.ignoreFocus=!1,c.trigger("blur")};c.ignoreFocus=!0,c.settings.create&&c.settings.createOnBlur?c.createItem(null,!1,d):d()}},onOptionHover:function(a){this.ignoreHover||this.setActiveOption(a.currentTarget,!1)},onOptionSelect:function(b){var c,d,e=this;b.preventDefault&&(b.preventDefault(),b.stopPropagation()),d=a(b.currentTarget),d.hasClass("create")?e.createItem(null,function(){e.settings.closeAfterSelect&&e.close()}):(c=d.attr("data-value"),"undefined"!=typeof c&&(e.lastQuery=null,e.setTextboxValue(""),e.addItem(c),e.settings.closeAfterSelect?e.close():!e.settings.hideSelected&&b.type&&/mouse/.test(b.type)&&e.setActiveOption(e.getOption(c))))},onItemSelect:function(a){var b=this;b.isLocked||"multi"===b.settings.mode&&(a.preventDefault(),b.setActiveItem(a.currentTarget,a))},load:function(a){var b=this,c=b.$wrapper.addClass(b.settings.loadingClass);b.loading++,a.apply(b,[function(a){b.loading=Math.max(b.loading-1,0),a&&a.length&&(b.addOption(a),b.refreshOptions(b.isFocused&&!b.isInputHidden)),b.loading||c.removeClass(b.settings.loadingClass),b.trigger("load",a)}])},setTextboxValue:function(a){var b=this.$control_input,c=b.val()!==a;c&&(b.val(a).triggerHandler("update"),this.lastValue=a)},getValue:function(){return this.tagType===v&&this.$input.attr("multiple")?this.items:this.items.join(this.settings.delimiter)},setValue:function(a,b){var c=b?[]:["change"];F(this,c,function(){this.clear(b),this.addItems(a,b)})},setActiveItem:function(b,c){var d,e,f,g,h,i,j,k,l=this;if("single"!==l.settings.mode){if(b=a(b),!b.length)return a(l.$activeItems).removeClass("active"),l.$activeItems=[],void(l.isFocused&&l.showInput());if(d=c&&c.type.toLowerCase(),"mousedown"===d&&l.isShiftDown&&l.$activeItems.length){for(k=l.$control.children(".active:last"),g=Array.prototype.indexOf.apply(l.$control[0].childNodes,[k[0]]),h=Array.prototype.indexOf.apply(l.$control[0].childNodes,[b[0]]),g>h&&(j=g,g=h,h=j),e=g;h>=e;e++)i=l.$control[0].childNodes[e],-1===l.$activeItems.indexOf(i)&&(a(i).addClass("active"),l.$activeItems.push(i));c.preventDefault()}else"mousedown"===d&&l.isCtrlDown||"keydown"===d&&this.isShiftDown?b.hasClass("active")?(f=l.$activeItems.indexOf(b[0]),l.$activeItems.splice(f,1),b.removeClass("active")):l.$activeItems.push(b.addClass("active")[0]):(a(l.$activeItems).removeClass("active"),l.$activeItems=[b.addClass("active")[0]]);l.hideInput(),this.isFocused||l.focus()}},setActiveOption:function(b,c,d){var e,f,g,h,i,j=this;j.$activeOption&&j.$activeOption.removeClass("active"),j.$activeOption=null,b=a(b),b.length&&(j.$activeOption=b.addClass("active"),(c||!y(c))&&(e=j.$dropdown_content.height(),f=j.$activeOption.outerHeight(!0),c=j.$dropdown_content.scrollTop()||0,g=j.$activeOption.offset().top-j.$dropdown_content.offset().top+c,h=g,i=g-e+f,g+f>e+c?j.$dropdown_content.stop().animate({scrollTop:i},d?j.settings.scrollDuration:0):c>g&&j.$dropdown_content.stop().animate({scrollTop:h},d?j.settings.scrollDuration:0)))},selectAll:function(){var a=this;"single"!==a.settings.mode&&(a.$activeItems=Array.prototype.slice.apply(a.$control.children(":not(input)").addClass("active")),a.$activeItems.length&&(a.hideInput(),a.close()),a.focus())},hideInput:function(){var a=this;a.setTextboxValue(""),a.$control_input.css({opacity:0,position:"absolute",left:a.rtl?1e4:-1e4}),a.isInputHidden=!0},showInput:function(){this.$control_input.css({opacity:1,position:"relative",left:0}),this.isInputHidden=!1},focus:function(){var a=this;a.isDisabled||(a.ignoreFocus=!0,a.$control_input[0].focus(),window.setTimeout(function(){a.ignoreFocus=!1,a.onFocus()},0))},blur:function(a){this.$control_input[0].blur(),this.onBlur(null,a)},getScoreFunction:function(a){return this.sifter.getScoreFunction(a,this.getSearchOptions())},getSearchOptions:function(){var a=this.settings,b=a.sortField;return"string"==typeof b&&(b=[{field:b}]),{fields:a.searchField,conjunction:a.searchConjunction,sort:b}},search:function(b){var c,d,e,f=this,g=f.settings,h=this.getSearchOptions();if(g.score&&(e=f.settings.score.apply(this,[b]),"function"!=typeof e))throw new Error('Selectize "score" setting must be a function that returns a function');if(b!==f.lastQuery?(f.lastQuery=b,d=f.sifter.search(b,a.extend(h,{score:e})),f.currentResults=d):d=a.extend(!0,{},f.currentResults),g.hideSelected)for(c=d.items.length-1;c>=0;c--)-1!==f.items.indexOf(z(d.items[c].id))&&d.items.splice(c,1);return d},refreshOptions:function(b){var c,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s;"undefined"==typeof b&&(b=!0);var t=this,u=a.trim(t.$control_input.val()),v=t.search(u),w=t.$dropdown_content,x=t.$activeOption&&z(t.$activeOption.attr("data-value"));for(g=v.items.length,"number"==typeof t.settings.maxOptions&&(g=Math.min(g,t.settings.maxOptions)),h={},i=[],c=0;g>c;c++)for(j=t.options[v.items[c].id],k=t.render("option",j),l=j[t.settings.optgroupField]||"",m=a.isArray(l)?l:[l],e=0,f=m&&m.length;f>e;e++)l=m[e],t.optgroups.hasOwnProperty(l)||(l=""),h.hasOwnProperty(l)||(h[l]=[],i.push(l)),h[l].push(k);for(this.settings.lockOptgroupOrder&&i.sort(function(a,b){var c=t.optgroups[a].$order||0,d=t.optgroups[b].$order||0;return c-d}),n=[],c=0,g=i.length;g>c;c++)l=i[c],t.optgroups.hasOwnProperty(l)&&h[l].length?(o=t.render("optgroup_header",t.optgroups[l])||"",o+=h[l].join(""),n.push(t.render("optgroup",a.extend({},t.optgroups[l],{html:o})))):n.push(h[l].join(""));if(w.html(n.join("")),t.settings.highlight&&v.query.length&&v.tokens.length)for(c=0,g=v.tokens.length;g>c;c++)d(w,v.tokens[c].regex);if(!t.settings.hideSelected)for(c=0,g=t.items.length;g>c;c++)t.getOption(t.items[c]).addClass("selected");p=t.canCreate(u),p&&(w.prepend(t.render("option_create",{input:u})),s=a(w[0].childNodes[0])),t.hasOptions=v.items.length>0||p,t.hasOptions?(v.items.length>0?(r=x&&t.getOption(x),r&&r.length?q=r:"single"===t.settings.mode&&t.items.length&&(q=t.getOption(t.items[0])),q&&q.length||(q=s&&!t.settings.addPrecedence?t.getAdjacentOption(s,1):w.find("[data-selectable]:first"))):q=s,t.setActiveOption(q),b&&!t.isOpen&&t.open()):(t.setActiveOption(null),b&&t.isOpen&&t.close())},addOption:function(b){var c,d,e,f=this;if(a.isArray(b))for(c=0,d=b.length;d>c;c++)f.addOption(b[c]);else(e=f.registerOption(b))&&(f.userOptions[e]=!0,f.lastQuery=null,f.trigger("option_add",e,b))},registerOption:function(a){var b=z(a[this.settings.valueField]);return!b||this.options.hasOwnProperty(b)?!1:(a.$order=a.$order||++this.order,this.options[b]=a,b)},registerOptionGroup:function(a){var b=z(a[this.settings.optgroupValueField]);return b?(a.$order=a.$order||++this.order,this.optgroups[b]=a,b):!1},addOptionGroup:function(a,b){b[this.settings.optgroupValueField]=a,(a=this.registerOptionGroup(b))&&this.trigger("optgroup_add",a,b)},removeOptionGroup:function(a){this.optgroups.hasOwnProperty(a)&&(delete this.optgroups[a],this.renderCache={},this.trigger("optgroup_remove",a))},clearOptionGroups:function(){this.optgroups={},this.renderCache={},this.trigger("optgroup_clear")},updateOption:function(b,c){var d,e,f,g,h,i,j,k=this;if(b=z(b),f=z(c[k.settings.valueField]),null!==b&&k.options.hasOwnProperty(b)){if("string"!=typeof f)throw new Error("Value must be set in option data");j=k.options[b].$order,f!==b&&(delete k.options[b],g=k.items.indexOf(b),-1!==g&&k.items.splice(g,1,f)),c.$order=c.$order||j,k.options[f]=c,h=k.renderCache.item,i=k.renderCache.option,h&&(delete h[b],delete h[f]),i&&(delete i[b],delete i[f]),-1!==k.items.indexOf(f)&&(d=k.getItem(b),e=a(k.render("item",c)),d.hasClass("active")&&e.addClass("active"),d.replaceWith(e)),k.lastQuery=null,k.isOpen&&k.refreshOptions(!1)}},removeOption:function(a,b){var c=this;a=z(a);var d=c.renderCache.item,e=c.renderCache.option;d&&delete d[a],e&&delete e[a],delete c.userOptions[a],delete c.options[a],c.lastQuery=null,c.trigger("option_remove",a),c.removeItem(a,b)},clearOptions:function(){var a=this;a.loadedSearches={},a.userOptions={},a.renderCache={},a.options=a.sifter.items={},a.lastQuery=null,a.trigger("option_clear"),a.clear()},getOption:function(a){return this.getElementWithValue(a,this.$dropdown_content.find("[data-selectable]"))},getAdjacentOption:function(b,c){var d=this.$dropdown.find("[data-selectable]"),e=d.index(b)+c;return e>=0&&ed;d++)if(c[d].getAttribute("data-value")===b)return a(c[d]);return a()},getItem:function(a){return this.getElementWithValue(a,this.$control.children())},addItems:function(b,c){for(var d=a.isArray(b)?b:[b],e=0,f=d.length;f>e;e++)this.isPending=f-1>e,this.addItem(d[e],c)},addItem:function(b,c){var d=c?[]:["change"];F(this,d,function(){var d,e,f,g,h,i=this,j=i.settings.mode;return b=z(b),-1!==i.items.indexOf(b)?void("single"===j&&i.close()):void(i.options.hasOwnProperty(b)&&("single"===j&&i.clear(c),"multi"===j&&i.isFull()||(d=a(i.render("item",i.options[b])),h=i.isFull(),i.items.splice(i.caretPos,0,b),i.insertAtCaret(d),(!i.isPending||!h&&i.isFull())&&i.refreshState(),i.isSetup&&(f=i.$dropdown_content.find("[data-selectable]"),i.isPending||(e=i.getOption(b),g=i.getAdjacentOption(e,1).attr("data-value"),i.refreshOptions(i.isFocused&&"single"!==j),g&&i.setActiveOption(i.getOption(g))),!f.length||i.isFull()?i.close():i.positionDropdown(),i.updatePlaceholder(),i.trigger("item_add",b,d),i.updateOriginalInput({silent:c})))))})},removeItem:function(a,b){var c,d,e,f=this;c="object"==typeof a?a:f.getItem(a),a=z(c.attr("data-value")),d=f.items.indexOf(a),-1!==d&&(c.remove(),c.hasClass("active")&&(e=f.$activeItems.indexOf(c[0]),f.$activeItems.splice(e,1)),f.items.splice(d,1),f.lastQuery=null,!f.settings.persist&&f.userOptions.hasOwnProperty(a)&&f.removeOption(a,b),d0),b.$control_input.data("grow",!c&&!d)},isFull:function(){return null!==this.settings.maxItems&&this.items.length>=this.settings.maxItems},updateOriginalInput:function(a){var b,c,d,e,f=this;if(a=a||{},f.tagType===v){for(d=[],b=0,c=f.items.length;c>b;b++)e=f.options[f.items[b]][f.settings.labelField]||"",d.push('");d.length||this.$input.attr("multiple")||d.push(''),f.$input.html(d.join(""))}else f.$input.val(f.getValue()),f.$input.attr("value",f.$input.val());f.isSetup&&(a.silent||f.trigger("change",f.$input.val()))},updatePlaceholder:function(){if(this.settings.placeholder){var a=this.$control_input;this.items.length?a.removeAttr("placeholder"):a.attr("placeholder",this.settings.placeholder),a.triggerHandler("update",{force:!0})}},open:function(){var a=this;a.isLocked||a.isOpen||"multi"===a.settings.mode&&a.isFull()||(a.focus(),a.isOpen=!0,a.refreshState(),a.$dropdown.css({visibility:"hidden",display:"block"}),a.positionDropdown(),a.$dropdown.css({visibility:"visible"}),a.trigger("dropdown_open",a.$dropdown))},close:function(){var a=this,b=a.isOpen;"single"===a.settings.mode&&a.items.length&&a.hideInput(),a.isOpen=!1,a.$dropdown.hide(),a.setActiveOption(null),a.refreshState(),b&&a.trigger("dropdown_close",a.$dropdown)},positionDropdown:function(){var a=this.$control,b="body"===this.settings.dropdownParent?a.offset():a.position();b.top+=a.outerHeight(!0),this.$dropdown.css({width:a.outerWidth(),top:b.top,left:b.left})},clear:function(a){var b=this;b.items.length&&(b.$control.children(":not(input)").remove(),b.items=[],b.lastQuery=null,b.setCaret(0),b.setActiveItem(null),b.updatePlaceholder(),b.updateOriginalInput({silent:a}),b.refreshState(),b.showInput(),b.trigger("clear"))},insertAtCaret:function(b){var c=Math.min(this.caretPos,this.items.length);0===c?this.$control.prepend(b):a(this.$control[0].childNodes[c]).before(b),this.setCaret(c+1)},deleteSelection:function(b){var c,d,e,f,g,h,i,j,k,l=this;if(e=b&&b.keyCode===p?-1:1,f=H(l.$control_input[0]),l.$activeOption&&!l.settings.hideSelected&&(i=l.getAdjacentOption(l.$activeOption,-1).attr("data-value")),g=[],l.$activeItems.length){for(k=l.$control.children(".active:"+(e>0?"last":"first")),h=l.$control.children(":not(input)").index(k),e>0&&h++,c=0,d=l.$activeItems.length;d>c;c++)g.push(a(l.$activeItems[c]).attr("data-value")); -b&&(b.preventDefault(),b.stopPropagation())}else(l.isFocused||"single"===l.settings.mode)&&l.items.length&&(0>e&&0===f.start&&0===f.length?g.push(l.items[l.caretPos-1]):e>0&&f.start===l.$control_input.val().length&&g.push(l.items[l.caretPos]));if(!g.length||"function"==typeof l.settings.onDelete&&l.settings.onDelete.apply(l,[g])===!1)return!1;for("undefined"!=typeof h&&l.setCaret(h);g.length;)l.removeItem(g.pop());return l.showInput(),l.positionDropdown(),l.refreshOptions(!0),i&&(j=l.getOption(i),j.length&&l.setActiveOption(j)),!0},advanceSelection:function(a,b){var c,d,e,f,g,h,i=this;0!==a&&(i.rtl&&(a*=-1),c=a>0?"last":"first",d=H(i.$control_input[0]),i.isFocused&&!i.isInputHidden?(f=i.$control_input.val().length,g=0>a?0===d.start&&0===d.length:d.start===f,g&&!f&&i.advanceCaret(a,b)):(h=i.$control.children(".active:"+c),h.length&&(e=i.$control.children(":not(input)").index(h),i.setActiveItem(null),i.setCaret(a>0?e+1:e))))},advanceCaret:function(a,b){var c,d,e=this;0!==a&&(c=a>0?"next":"prev",e.isShiftDown?(d=e.$control_input[c](),d.length&&(e.hideInput(),e.setActiveItem(d),b&&b.preventDefault())):e.setCaret(e.caretPos+a))},setCaret:function(b){var c=this;if(b="single"===c.settings.mode?c.items.length:Math.max(0,Math.min(c.items.length,b)),!c.isPending){var d,e,f,g;for(f=c.$control.children(":not(input)"),d=0,e=f.length;e>d;d++)g=a(f[d]).detach(),b>d?c.$control_input.before(g):c.$control.append(g)}c.caretPos=b},lock:function(){this.close(),this.isLocked=!0,this.refreshState()},unlock:function(){this.isLocked=!1,this.refreshState()},disable:function(){var a=this;a.$input.prop("disabled",!0),a.$control_input.prop("disabled",!0).prop("tabindex",-1),a.isDisabled=!0,a.lock()},enable:function(){var a=this;a.$input.prop("disabled",!1),a.$control_input.prop("disabled",!1).prop("tabindex",a.tabIndex),a.isDisabled=!1,a.unlock()},destroy:function(){var b=this,c=b.eventNS,d=b.revertSettings;b.trigger("destroy"),b.off(),b.$wrapper.remove(),b.$dropdown.remove(),b.$input.html("").append(d.$children).removeAttr("tabindex").removeClass("selectized").attr({tabindex:d.tabindex}).show(),b.$control_input.removeData("grow"),b.$input.removeData("selectize"),a(window).off(c),a(document).off(c),a(document.body).off(c),delete b.$input[0].selectize},render:function(a,b){var c,d,e="",f=!1,g=this,h=/^[\t \r\n]*<([a-z][a-z0-9\-_]*(?:\:[a-z][a-z0-9\-_]*)?)/i;return("option"===a||"item"===a)&&(c=z(b[g.settings.valueField]),f=!!c),f&&(y(g.renderCache[a])||(g.renderCache[a]={}),g.renderCache[a].hasOwnProperty(c))?g.renderCache[a][c]:(e=g.settings.render[a].apply(this,[b,A]),("option"===a||"option_create"===a)&&(e=e.replace(h,"<$1 data-selectable")),"optgroup"===a&&(d=b[g.settings.optgroupValueField]||"",e=e.replace(h,'<$1 data-group="'+B(A(d))+'"')),("option"===a||"item"===a)&&(e=e.replace(h,'<$1 data-value="'+B(A(c||""))+'"')),f&&(g.renderCache[a][c]=e),e)},clearCache:function(a){var b=this;"undefined"==typeof a?b.renderCache={}:delete b.renderCache[a]},canCreate:function(a){var b=this;if(!b.settings.create)return!1;var c=b.settings.createFilter;return!(!a.length||"function"==typeof c&&!c.apply(b,[a])||"string"==typeof c&&!new RegExp(c).test(a)||c instanceof RegExp&&!c.test(a))}}),L.count=0,L.defaults={options:[],optgroups:[],plugins:[],delimiter:",",splitOn:null,persist:!0,diacritics:!0,create:!1,createOnBlur:!1,createFilter:null,highlight:!0,openOnFocus:!0,maxOptions:1e3,maxItems:null,hideSelected:null,addPrecedence:!1,selectOnTab:!1,preload:!1,allowEmptyOption:!1,closeAfterSelect:!1,scrollDuration:60,loadThrottle:300,loadingClass:"loading",dataAttr:"data-data",optgroupField:"optgroup",valueField:"value",labelField:"text",optgroupLabelField:"label",optgroupValueField:"value",lockOptgroupOrder:!1,sortField:"$order",searchField:["text"],searchConjunction:"and",mode:null,wrapperClass:"selectize-control",inputClass:"selectize-input",dropdownClass:"selectize-dropdown",dropdownContentClass:"selectize-dropdown-content",dropdownParent:null,copyClassesToDropdown:!0,render:{}},a.fn.selectize=function(b){var c=a.fn.selectize.defaults,d=a.extend({},c,b),e=d.dataAttr,f=d.labelField,g=d.valueField,h=d.optgroupField,i=d.optgroupLabelField,j=d.optgroupValueField,k=function(b,c){var h,i,j,k,l=b.attr(e);if(l)for(c.options=JSON.parse(l),h=0,i=c.options.length;i>h;h++)c.items.push(c.options[h][g]);else{var m=a.trim(b.val()||"");if(!d.allowEmptyOption&&!m.length)return;for(j=m.split(d.delimiter),h=0,i=j.length;i>h;h++)k={},k[f]=j[h],k[g]=j[h],c.options.push(k);c.items=j}},l=function(b,c){var k,l,m,n,o=c.options,p={},q=function(a){var b=e&&a.attr(e);return"string"==typeof b&&b.length?JSON.parse(b):null},r=function(b,e){b=a(b);var i=z(b.attr("value"));if(i||d.allowEmptyOption)if(p.hasOwnProperty(i)){if(e){var j=p[i][h];j?a.isArray(j)?j.push(e):p[i][h]=[j,e]:p[i][h]=e}}else{var k=q(b)||{};k[f]=k[f]||b.text(),k[g]=k[g]||i,k[h]=k[h]||e,p[i]=k,o.push(k),b.is(":selected")&&c.items.push(i)}},s=function(b){var d,e,f,g,h;for(b=a(b),f=b.attr("label"),f&&(g=q(b)||{},g[i]=f,g[j]=f,c.optgroups.push(g)),h=a("option",b),d=0,e=h.length;e>d;d++)r(h[d],f)};for(c.maxItems=b.attr("multiple")?null:1,n=b.children(),k=0,l=n.length;l>k;k++)m=n[k].tagName.toLowerCase(),"optgroup"===m?s(n[k]):"option"===m&&r(n[k])};return this.each(function(){if(!this.selectize){var e,f=a(this),g=this.tagName.toLowerCase(),h=f.attr("placeholder")||f.attr("data-placeholder");h||d.allowEmptyOption||(h=f.children('option[value=""]').text());var i={placeholder:h,options:[],optgroups:[],items:[]};"select"===g?l(f,i):k(f,i),e=new L(f,a.extend(!0,{},c,i,b))}})},a.fn.selectize.defaults=L.defaults,a.fn.selectize.support={validity:x},L.define("drag_drop",function(){if(!a.fn.sortable)throw new Error('The "drag_drop" plugin requires jQuery UI "sortable".');if("multi"===this.settings.mode){var b=this;b.lock=function(){var a=b.lock;return function(){var c=b.$control.data("sortable");return c&&c.disable(),a.apply(b,arguments)}}(),b.unlock=function(){var a=b.unlock;return function(){var c=b.$control.data("sortable");return c&&c.enable(),a.apply(b,arguments)}}(),b.setup=function(){var c=b.setup;return function(){c.apply(this,arguments);var d=b.$control.sortable({items:"[data-value]",forcePlaceholderSize:!0,disabled:b.isLocked,start:function(a,b){b.placeholder.css("width",b.helper.css("width")),d.css({overflow:"visible"})},stop:function(){d.css({overflow:"hidden"});var c=b.$activeItems?b.$activeItems.slice():null,e=[];d.children("[data-value]").each(function(){e.push(a(this).attr("data-value"))}),b.setValue(e),b.setActiveItem(c)}})}}()}}),L.define("dropdown_header",function(b){var c=this;b=a.extend({title:"Untitled",headerClass:"selectize-dropdown-header",titleRowClass:"selectize-dropdown-header-title",labelClass:"selectize-dropdown-header-label",closeClass:"selectize-dropdown-header-close",html:function(a){return'
'+a.title+'×
'}},b),c.setup=function(){var d=c.setup;return function(){d.apply(c,arguments),c.$dropdown_header=a(b.html(b)),c.$dropdown.prepend(c.$dropdown_header)}}()}),L.define("optgroup_columns",function(b){var c=this;b=a.extend({equalizeWidth:!0,equalizeHeight:!0},b),this.getAdjacentOption=function(b,c){var d=b.closest("[data-group]").find("[data-selectable]"),e=d.index(b)+c;return e>=0&&e
',a=a.firstChild,c.body.appendChild(a),b=d.width=a.offsetWidth-a.clientWidth,c.body.removeChild(a)),b},e=function(){var e,f,g,h,i,j,k;if(k=a("[data-group]",c.$dropdown_content),f=k.length,f&&c.$dropdown_content.width()){if(b.equalizeHeight){for(g=0,e=0;f>e;e++)g=Math.max(g,k.eq(e).height());k.css({height:g})}b.equalizeWidth&&(j=c.$dropdown_content.innerWidth()-d(),h=Math.round(j/f),k.css({width:h}),f>1&&(i=j-h*(f-1),k.eq(f-1).css({width:i})))}};(b.equalizeHeight||b.equalizeWidth)&&(C.after(this,"positionDropdown",e),C.after(this,"refreshOptions",e))}),L.define("remove_button",function(b){if("single"!==this.settings.mode){b=a.extend({label:"×",title:"Remove",className:"remove",append:!0},b);var c=this,d=''+b.label+"",e=function(a,b){var c=a.search(/(<\/[^>]+>\s*)$/);return a.substring(0,c)+b+a.substring(c)};this.setup=function(){var f=c.setup;return function(){if(b.append){var g=c.settings.render.item;c.settings.render.item=function(){return e(g.apply(this,arguments),d)}}f.apply(this,arguments),this.$control.on("click","."+b.className,function(b){if(b.preventDefault(),!c.isLocked){var d=a(b.currentTarget).parent();c.setActiveItem(d),c.deleteSelection()&&c.setCaret(c.items.length)}})}}()}}),L.define("restore_on_backspace",function(a){var b=this;a.text=a.text||function(a){return a[this.settings.labelField]},this.onKeyDown=function(){var c=b.onKeyDown;return function(b){var d,e;return b.keyCode===p&&""===this.$control_input.val()&&!this.$activeItems.length&&(d=this.caretPos-1,d>=0&&d - {% endcompress %} {% block stylesheet %}{% endblock %} @@ -68,29 +67,31 @@ -
- {% include 'includes/header.html' %} - {% comment %} -
-
- Scheduled Maintenance: - In order to upgrade our servers, the application will be offline for approximately 20 minutes on Oct 17, 2015 07:00 UTC. + {% block body %} +
+ {% include 'includes/header.html' %} + {% comment %} +
+
+ Scheduled Maintenance: + In order to upgrade our servers, the application will be offline for approximately 20 minutes on Oct 17, 2015 07:00 UTC. +
-
- {% endcomment %} - {% block fullwidthheader %}{% endblock %} -
-
-
- {% include 'includes/messages.html' %} + {% endcomment %} + {% block fullwidthheader %}{% endblock %} +
+
+
+ {% include 'includes/messages.html' %} +
+ {% block content %}{% endblock %}
- {% block content %}{% endblock %} -
-
- {% block fullwidthfooter %}{% endblock %} -
-
- {% include 'includes/footer.html' %} + + {% block fullwidthfooter %}{% endblock %} +
+
+ {% include 'includes/footer.html' %} + {% endblock body %}