diff --git a/ephios/core/forms/events.py b/ephios/core/forms/events.py index fca5344f7..18359e27f 100644 --- a/ephios/core/forms/events.py +++ b/ephios/core/forms/events.py @@ -2,9 +2,6 @@ import re from datetime import datetime, timedelta -from crispy_forms.bootstrap import FormActions -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Field, Layout, Submit from django import forms from django.contrib.auth import get_user_model from django.contrib.auth.models import Group @@ -25,7 +22,6 @@ from ephios.core.signup.structure import enabled_shift_structures, shift_structure_from_slug from ephios.core.widgets import MultiUserProfileWidget from ephios.extra.colors import clear_eventtype_color_css_fragment_cache -from ephios.extra.crispy import AbortLink from ephios.extra.permissions import get_groups_with_perms from ephios.extra.widgets import CustomDateInput, CustomTimeInput, MarkdownTextarea, RecurrenceField from ephios.modellogging.log import add_log_recorder, update_log @@ -315,40 +311,3 @@ def is_function_active(self): With the default template, if this is True, the collapse is expanded on page load. """ return False - - -class EventNotificationForm(forms.Form): - NEW_EVENT = "new" - REMINDER = "remind" - PARTICIPANTS = "participants" - action = forms.ChoiceField( - choices=[ - (NEW_EVENT, _("Send notification about new event to everyone")), - (REMINDER, _("Send reminder to everyone that is not participating")), - (PARTICIPANTS, _("Send a message to all participants")), - ], - widget=forms.RadioSelect, - label=False, - ) - mail_content = forms.CharField(required=False, widget=forms.Textarea, label=_("Mail content")) - - def __init__(self, *args, **kwargs): - self.event = kwargs.pop("event") - super().__init__(*args, **kwargs) - self.helper = FormHelper(self) - self.helper.layout = Layout( - Field("action"), - Field("mail_content"), - FormActions( - Submit("submit", _("Send"), css_class="float-end"), - AbortLink(href=self.event.get_absolute_url()), - ), - ) - - def clean(self): - if ( - self.cleaned_data.get("action") == self.PARTICIPANTS - and not self.cleaned_data["mail_content"] - ): - raise ValidationError(_("You cannot send an empty mail.")) - return super().clean() diff --git a/ephios/core/services/health/__init__.py b/ephios/core/services/health/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/core/services/mail/__init__.py b/ephios/core/services/mail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/core/services/notifications/types.py b/ephios/core/services/notifications/types.py index f72875335..8334d63cc 100644 --- a/ephios/core/services/notifications/types.py +++ b/ephios/core/services/notifications/types.py @@ -1,13 +1,11 @@ -from typing import List +from typing import Collection, List from urllib.parse import urlparse from django.contrib.auth.tokens import default_token_generator from django.template.loader import render_to_string from django.urls import reverse from django.utils.encoding import force_bytes -from django.utils.formats import date_format from django.utils.http import urlsafe_base64_encode -from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ from dynamic_preferences.registries import global_preferences_registry from guardian.shortcuts import get_users_with_perms @@ -17,7 +15,7 @@ from ephios.core.models import AbstractParticipation, Event, LocalParticipation, UserProfile from ephios.core.models.users import Consequence, Notification from ephios.core.signals import register_notification_types -from ephios.core.signup.participants import LocalUserParticipant +from ephios.core.signup.participants import AbstractParticipant, LocalUserParticipant from ephios.core.templatetags.settings_extras import make_absolute NOTIFICATION_READ_PARAM_NAME = "fromNotification" @@ -186,51 +184,6 @@ def _get_reset_url(cls, notification): return make_absolute(reset_link) -class NewEventNotification(AbstractNotificationHandler): - slug = "ephios_new_event" - title = _("A new event has been added") - email_template_name = "core/mails/new_event.html" - - @classmethod - def send(cls, event: Event, **kwargs): - notifications = [] - for user in get_users_with_perms(event, only_with_perms_in=["view_event"]): - notifications.append( - Notification(slug=cls.slug, user=user, data={"event_id": event.id, **kwargs}) - ) - Notification.objects.bulk_create(notifications) - - @classmethod - def get_subject(cls, notification): - event = Event.objects.get(pk=notification.data.get("event_id")) - return _("New {type}: {title}").format(type=event.type, title=event.title) - - @classmethod - def get_body(cls, notification): - event = Event.objects.get(pk=notification.data.get("event_id")) - return _( - "A new {type} ({title}, {location}) has been added.\n" - "Further information: {description}" - ).format( - type=event.type, - title=event.title, - location=event.location, - description=event.description, - ) - - @classmethod - def get_render_context(cls, notification): - context = super().get_render_context(notification) - event = Event.objects.get(pk=notification.data.get("event_id")) - context["event"] = event - return context - - @classmethod - def get_actions(cls, notification): - event = Event.objects.get(pk=notification.data.get("event_id")) - return [(str(_("View event")), make_absolute(event.get_absolute_url()))] - - class ParticipationMixin: @classmethod def is_obsolete(cls, notification): @@ -514,101 +467,109 @@ def get_body(cls, notification): return message -class EventReminderNotification(AbstractNotificationHandler): - slug = "ephios_event_reminder" - title = _("An event has vacant spots") +class SubjectBodyDataMixin: + + @classmethod + def get_subject(cls, notification): + return notification.data.get("subject") + + @classmethod + def get_body(cls, notification): + return notification.data.get("body") + + +class GenericMassNotification(SubjectBodyDataMixin, AbstractNotificationHandler): + slug = "ephios_custom_event_reminder" + title = _("Information on an event you are not participating in") unsubscribe_allowed = False @classmethod - def send(cls, event: Event): - users_not_participating = UserProfile.objects.exclude( - pk__in=AbstractParticipation.objects.filter(shift__event=event).values_list( - "localparticipation__user", flat=True - ) - ).filter(pk__in=get_users_with_perms(event, only_with_perms_in=["view_event"])) + def send(cls, users: Collection[UserProfile], subject, body): notifications = [] - for user in users_not_participating: + for user in users: notifications.append( - Notification(slug=cls.slug, user=user, data={"event_id": event.id}) + Notification( + slug=cls.slug, + user=user, + data={ + "email": user.email, + "subject": subject, + "body": body, + }, + ) ) Notification.objects.bulk_create(notifications) @classmethod - def get_subject(cls, notification): - event = Event.objects.get(pk=notification.data.get("event_id")) - return _("Help needed for {title}").format(title=event.title) + def get_actions(cls, notification): + return [ + ( + str(_("View message")), + make_absolute(reverse("core:notification_detail", kwargs={"pk": notification.pk})), + ) + ] + + +class CustomEventReminderNotification(SubjectBodyDataMixin, AbstractNotificationHandler): + slug = "ephios_custom_event_reminder" + title = _("Information on an event you are not participating in") + unsubscribe_allowed = False @classmethod - def get_body(cls, notification): - event = Event.objects.get(pk=notification.data.get("event_id")) - return _("Your support is needed for {title} ({start} - {end}).").format( - title=event.title, - start=date_format(localtime(event.get_start_time()), "SHORT_DATETIME_FORMAT"), - end=date_format(localtime(event.get_end_time()), "SHORT_DATETIME_FORMAT"), - ) + def send(cls, event: Event, participants: Collection[LocalUserParticipant], subject, body): + notifications = [] + for participant in participants: + notifications.append( + Notification( + slug=cls.slug, + user=getattr(participant, "user", None), + data={ + "event_id": event.id, + "email": participant.email, + "subject": subject, + "body": body, + }, + ) + ) + Notification.objects.bulk_create(notifications) @classmethod def get_actions(cls, notification): event = Event.objects.get(pk=notification.data.get("event_id")) - return [(str(_("View event")), make_absolute(event.get_absolute_url()))] + return [ + ( + str(_("View message")), + make_absolute(reverse("core:notification_detail", kwargs={"pk": notification.pk})), + ), + (str(_("View event")), make_absolute(event.get_absolute_url())), + ] -class CustomEventParticipantNotification(AbstractNotificationHandler): +class CustomEventParticipantNotification(SubjectBodyDataMixin, AbstractNotificationHandler): slug = "ephios_custom_event_participant" title = _("Message to all participants") unsubscribe_allowed = False @classmethod - def send(cls, event: Event, content: str): - participants = set() + def send( + cls, event: Event, participants: Collection[AbstractParticipant], subject: str, body: str + ): notifications = [] - responsible_users = get_users_with_perms( - event, with_superusers=False, only_with_perms_in=["change_event"] - ) - for participation in AbstractParticipation.objects.filter( - shift__event=event, state=AbstractParticipation.States.CONFIRMED - ): - participant = participation.participant - if participant not in participants: - participants.add(participant) - user = participant.user if isinstance(participant, LocalUserParticipant) else None - if user in responsible_users: - continue - notifications.append( - Notification( - slug=cls.slug, - user=user, - data={ - "email": participant.email, - "participation_id": participation.id, - "event_id": event.id, - "content": content, - }, - ) - ) - for responsible in responsible_users: + for participant in participants: notifications.append( Notification( slug=cls.slug, - user=responsible, + user=getattr(participant, "user", None), data={ - "email": responsible.email, + "email": participant.email, "event_id": event.id, - "content": content, + "subject": subject, + "body": body, }, ) ) Notification.objects.bulk_create(notifications) - @classmethod - def get_subject(cls, notification): - event = Event.objects.get(pk=notification.data.get("event_id")) - return _("Information regarding {title}").format(title=event.title) - - @classmethod - def get_body(cls, notification): - return notification.data.get("content") - @classmethod def get_actions(cls, notification): event = Event.objects.get(pk=notification.data.get("event_id")) @@ -680,8 +641,9 @@ def get_body(cls, notification): ResponsibleParticipationStateChangeNotification, ResponsibleConfirmedParticipationDeclinedNotification, ResponsibleConfirmedParticipationCustomizedNotification, - NewEventNotification, - EventReminderNotification, + GenericMassNotification, + CustomEventParticipantNotification, + CustomEventReminderNotification, CustomEventParticipantNotification, ConsequenceApprovedNotification, ConsequenceDeniedNotification, diff --git a/ephios/core/signup/participants.py b/ephios/core/signup/participants.py index b83109cc0..09f27d3e5 100644 --- a/ephios/core/signup/participants.py +++ b/ephios/core/signup/participants.py @@ -25,6 +25,15 @@ class AbstractParticipant: date_of_birth: Optional[date] email: Optional[str] # if set to None, no notifications are sent + @property + def identifier(self): + """ + Return a string identifying this participant. It should be unique to the ephios instance, and should not + change if changeable attributes like qualifications change. + The string must only contain alphanumeric characters and -_ special characters. + """ + raise NotImplementedError + def get_age(self, today: date = None): if self.date_of_birth is None: return None @@ -80,6 +89,10 @@ def icon(self): class LocalUserParticipant(AbstractParticipant): user: get_user_model() + @property + def identifier(self): + return f"localuser-{self.user.pk}" + def new_participation(self, shift): return LocalParticipation(shift=shift, user=self.user) @@ -101,6 +114,11 @@ def reverse_event_detail(self, event): @dataclasses.dataclass(frozen=True) class PlaceholderParticipant(AbstractParticipant): + + @property + def identifier(self): + return f"placeholder-{hash(self)}" + def new_participation(self, shift): return PlaceholderParticipation(shift=shift, display_name=self.display_name) diff --git a/ephios/core/templates/core/event_detail.html b/ephios/core/templates/core/event_detail.html index 2df0297cc..d6215adec 100644 --- a/ephios/core/templates/core/event_detail.html +++ b/ephios/core/templates/core/event_detail.html @@ -76,7 +76,7 @@

{% translate "Download PDF" %}
  • + href="{% url "core:notification_mass" %}{% querystring None event_id=event.id %}"> {% translate "Send notifications" %}
  • diff --git a/ephios/core/templates/core/mass_notification_write.html b/ephios/core/templates/core/mass_notification_write.html new file mode 100644 index 000000000..68025ac92 --- /dev/null +++ b/ephios/core/templates/core/mass_notification_write.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% load ephios_crispy %} +{% load crispy_forms_tags %} +{% load static %} +{% load i18n %} + +{% block javascript %} + +{% endblock %} + +{% block title %} + {% translate "Mass notification" %} +{% endblock %} + +{% block content %} + +
    + {% csrf_token %} + {{ form.subject|as_crispy_field }} + {{ form.body|as_crispy_field }} + + + + + {% if form.event %} +
    + + + +
    + {% endif %} + {% crispy_field form.to_participants show_labels=False %} + + {% translate "Cancel" %} +
    +{% endblock %} diff --git a/ephios/core/urls.py b/ephios/core/urls.py index 14d6d9c86..e9a332a62 100644 --- a/ephios/core/urls.py +++ b/ephios/core/urls.py @@ -40,7 +40,6 @@ EventDeleteView, EventDetailView, EventListView, - EventNotificationView, EventUpdateView, HomeView, ) @@ -53,6 +52,7 @@ from ephios.core.views.healthcheck import HealthCheckView from ephios.core.views.log import LogView from ephios.core.views.notifications import ( + MassNotificationWriteView, NotificationDetailView, NotificationListView, NotificationMarkAllAsReadView, @@ -118,11 +118,6 @@ EventActivateView.as_view(), name="event_activate", ), - path( - "events//notifications/", - EventNotificationView.as_view(), - name="event_notifications", - ), path("events//pdf/", pdf.EventDetailPDFView.as_view(), name="event_detail_pdf"), path( "events//copy/", @@ -293,6 +288,7 @@ path("oidc/logout/", OIDCLogoutView.as_view(), name="oidc_logout"), path("accounts/login/", OIDCLoginView.as_view(), name="oidc_login"), path("notifications/", NotificationListView.as_view(), name="notification_list"), + path("notifications/mass/", MassNotificationWriteView.as_view(), name="notification_mass"), path( "notifications/read/", NotificationMarkAllAsReadView.as_view(), name="notification_all_read" ), diff --git a/ephios/core/views/event.py b/ephios/core/views/event.py index 5cfa31567..19582c116 100644 --- a/ephios/core/views/event.py +++ b/ephios/core/views/event.py @@ -33,13 +33,8 @@ from guardian.shortcuts import assign_perm, get_objects_for_user, get_users_with_perms from ephios.core.calendar import ShiftCalendar -from ephios.core.forms.events import EventCopyForm, EventForm, EventNotificationForm +from ephios.core.forms.events import EventCopyForm, EventForm from ephios.core.models import AbstractParticipation, Event, EventType, Shift -from ephios.core.services.notifications.types import ( - CustomEventParticipantNotification, - EventReminderNotification, - NewEventNotification, -) from ephios.core.signals import event_forms from ephios.core.views.signup import request_to_participant from ephios.extra.csp import csp_allow_unsafe_eval @@ -690,28 +685,3 @@ def form_valid(self, form): class HomeView(LoginRequiredMixin, TemplateView): template_name = "core/home.html" - - -class EventNotificationView(CustomPermissionRequiredMixin, SingleObjectMixin, FormView): - model = Event - permission_required = "core.change_event" - template_name = "core/event_notification.html" - form_class = EventNotificationForm - - def get_form_kwargs(self): - return {**super().get_form_kwargs(), "event": self.object} - - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) - self.object = self.get_object() - - def form_valid(self, form): - action = form.cleaned_data["action"] - if action == form.NEW_EVENT: - NewEventNotification.send(self.object) - elif action == form.REMINDER: - EventReminderNotification.send(self.object) - elif action == form.PARTICIPANTS: - CustomEventParticipantNotification.send(self.object, form.cleaned_data["mail_content"]) - messages.success(self.request, _("Notifications sent succesfully.")) - return redirect(self.object.get_absolute_url()) diff --git a/ephios/core/views/notifications.py b/ephios/core/views/notifications.py index b9ffc4bf7..183bea833 100644 --- a/ephios/core/views/notifications.py +++ b/ephios/core/views/notifications.py @@ -1,9 +1,28 @@ +from functools import cached_property +from operator import attrgetter + +from build.lib.guardian.shortcuts import get_objects_for_user, get_users_with_perms +from django import forms +from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin +from django.shortcuts import redirect from django.urls import reverse -from django.views.generic import DetailView, ListView, RedirectView +from django.utils.formats import date_format +from django.utils.functional import partition +from django.utils.timezone import localtime +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext_lazy +from django.views.generic import DetailView, FormView, ListView, RedirectView from django.views.generic.detail import SingleObjectMixin +from django_select2.forms import Select2MultipleWidget -from ephios.core.models import Notification +from ephios.core.models import AbstractParticipation, Notification +from ephios.core.services.notifications.types import ( + CustomEventParticipantNotification, + CustomEventReminderNotification, + GenericMassNotification, +) +from ephios.extra.mixins import CustomCheckPermissionMixin class OwnNotificationMixin(LoginRequiredMixin): @@ -33,3 +52,171 @@ class NotificationMarkAllAsReadView(LoginRequiredMixin, RedirectView): def get_redirect_url(self, *args, **kwargs): Notification.objects.filter(user=self.request.user).update(read=True) return reverse("core:notification_list") + + +class MassNotificationForm(forms.Form): + subject = forms.CharField() + body = forms.CharField( + widget=forms.Textarea(attrs={"rows": 8}), + ) + to_participants = forms.MultipleChoiceField( + widget=Select2MultipleWidget( + attrs={ + "data-placeholder": _("Add recipients"), + "data-allow-clear": "true", + }, + ), + choices=[], # added in __init__ + ) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + self.event = kwargs.pop("event", None) + super().__init__(*args, **kwargs) + self._configure_choices() + + def _configure_choices(self): + choices = {} + self.participants_by_identifier = {} + + user_qs = get_objects_for_user(user=self.request.user, perms=["core.view_userprofile"]) + if self.event: + # users must also be able to see the event + user_qs = user_qs.filter( + pk__in=get_users_with_perms(self.event, only_with_perms_in=["view_event"]) + ) + for user in user_qs: + participant = user.as_participant() + choices[participant.identifier] = str(participant) + self.participants_by_identifier[participant.identifier] = participant + + self.event_confirmed = set() + self.event_requested = set() + self.event_nonfeedback = set(choices.keys()) + if self.event: + event_participations = AbstractParticipation.objects.filter( + shift__event=self.event, + ) + for participation in event_participations: + participant = participation.participant + if not participant.email: + continue # doesn't make sense to include participants we don't have email for like Placeholders + + self.event_nonfeedback -= {participant.identifier} + match participation.state: + case AbstractParticipation.States.CONFIRMED: + self.event_confirmed.add(participant.identifier) + case AbstractParticipation.States.REQUESTED: + self.event_requested.add(participant.identifier) + choices[participant.identifier] = str(participant) + self.participants_by_identifier[participant.identifier] = participant + + # because a participant might be in multiple participations states with multiple shifts, + # we adjust the sets to have them in the most important group + self.event_nonfeedback -= self.event_confirmed | self.event_requested + self.event_requested -= self.event_confirmed + + sorted_names = sorted(choices.values()) + + def sort_key(item): + identifier, name = item + if identifier in self.event_confirmed: + return -len(sorted_names) + sorted_names.index(name) + if identifier in self.event_requested: + return sorted_names.index(name) + return len(sorted_names) + sorted_names.index(name) + + self.fields["to_participants"].choices = sorted(choices.items(), key=sort_key) + + def clean_to_participants(self): + return list(map(self.participants_by_identifier.get, self.cleaned_data["to_participants"])) + + +class MassNotificationWriteView(CustomCheckPermissionMixin, FormView): + """ + - [] next url parameter, get from target object? + - [] permission check based on target object? + """ + + form_class = MassNotificationForm + template_name = "core/mass_notification_write.html" + + def has_permission(self): + # either has permission "core.view_userprofile" + # or event is given and user is responsible + if self.event and self.request.user.has_perm("core.change_event", obj=self.event): + return True + return self.request.user.has_perm("core.view_userprofile") + + @cached_property + def event(self): + return ( + get_objects_for_user(self.request.user, ["core.change_event"]) + .filter(id=self.request.GET.get("event_id", None)) + .first() + ) + + def get_context_data(self, **kwargs): + return super().get_context_data(cancel_url=self.get_success_url(), **kwargs) + + def get_form_kwargs(self): + return {"request": self.request, "event": self.event, **super().get_form_kwargs()} + + def get_initial(self): + initial = {} + if self.event: + initial["subject"] = _("Information on {event_title}").format( + event_title=self.event.title + ) + initial["body"] = _( + "Hello,\n\nregarding {event_title} starting at {event_start} we want to communicate...\n\nKind regards\n" + ).format( + event_title=self.event.title, + event_start=date_format( + localtime(self.event.get_start_time()), "SHORT_DATETIME_FORMAT" + ), + ) + return initial + + def form_valid(self, form): + subject_and_body = { + "subject": form.cleaned_data["subject"], + "body": form.cleaned_data["body"], + } + recipients = form.cleaned_data["to_participants"] + if self.event: + others, confirmed_or_requested = partition( + lambda p: p.identifier in (form.event_confirmed | form.event_requested), + recipients, + ) + CustomEventParticipantNotification.send( + self.event, confirmed_or_requested, **subject_and_body + ) + CustomEventReminderNotification.send(self.event, others, **subject_and_body) + messages.success( + self.request, + ngettext_lazy( + "Send notification to {count} participant.", + "Send notification to {count} participants.", + len(recipients), + ).format(count=len(recipients)), + ) + else: + GenericMassNotification.send( + users=list(map(attrgetter("user"), recipients)), + **subject_and_body, + ) + messages.success( + self.request, + ngettext_lazy( + "Send notification to {count} user.", + "Send notification to {count} users.", + len(recipients), + ).format(count=len(recipients)), + ) + return redirect(self.get_success_url()) + + def get_success_url(self): + if self.event: + return self.event.get_absolute_url() + return reverse("core:home") diff --git a/ephios/extra/utils.py b/ephios/extra/utils.py index 5f4074697..715b310b4 100644 --- a/ephios/extra/utils.py +++ b/ephios/extra/utils.py @@ -17,13 +17,6 @@ def pairwise(iterable): return zip(a, b) -def partition(pred, iterable): - "Use a predicate to partition entries into false entries and true entries" - # partition(is_odd, range(10)) --> 0 2 4 6 8 and 1 3 5 7 9 - t1, t2 = itertools.tee(iterable) - return itertools.filterfalse(pred, t1), filter(pred, t2) - - def format_anything(value): """Return some built-in types in a human readable way.""" if isinstance(value, bool): diff --git a/ephios/plugins/federation/models.py b/ephios/plugins/federation/models.py index 4c5c57b59..20d5b0c98 100644 --- a/ephios/plugins/federation/models.py +++ b/ephios/plugins/federation/models.py @@ -166,6 +166,10 @@ class Meta: class FederatedParticipant(AbstractParticipant): federated_user: FederatedUser + @property + def identifier(self): + return f"federateduser-{self.federated_user.pk}" + def new_participation(self, shift): return FederatedParticipation(shift=shift, federated_user=self.federated_user) diff --git a/ephios/plugins/guests/models.py b/ephios/plugins/guests/models.py index 95ef5785d..f8a091532 100644 --- a/ephios/plugins/guests/models.py +++ b/ephios/plugins/guests/models.py @@ -100,6 +100,10 @@ class Meta: class GuestParticipant(AbstractParticipant): guest_user: GuestUser + @property + def identifier(self): + return f"guestuser-{self.guest_user.pk}" + def new_participation(self, shift): return GuestParticipation(shift=shift, guest_user=self.guest_user) diff --git a/ephios/static/ephios/js/mass_notification_write.js b/ephios/static/ephios/js/mass_notification_write.js new file mode 100644 index 000000000..3b23616b4 --- /dev/null +++ b/ephios/static/ephios/js/mass_notification_write.js @@ -0,0 +1,30 @@ +$(document).ready(function () { + const jSelect = $("#id_to_participants"); + const confirmedIDs = $("#btn-participants-confirmed").data("participants").trim().split(" ") + const requestedIDs = $("#btn-participants-requested").data("participants").trim().split(" ") + + function formatState(state) { + let $state = $(''); + const span = $state.find("span"); + span.text(state.text); + if (confirmedIDs.indexOf(state.id) >= 0) { + span.addClass("text-success"); + } else if (requestedIDs.indexOf(state.id) >= 0) { + span.addClass("text-warning"); + } + return $state; + } + + jSelect.select2({ + templateSelection: formatState, + sorter: data => data.sort((a, b) => a.text.localeCompare(b.text)) + } + ) + Array.from(document.getElementsByClassName("btn-add-recipients")).forEach(button => { + button.addEventListener("click", function (e) { + e.preventDefault(); + const namesToSelect = button.dataset.participants.trim().split(" "); + jSelect.val(jSelect.val().concat(namesToSelect)).trigger('change'); + }) + }); +}); diff --git a/tests/core/test_notifications.py b/tests/core/test_notifications.py index 2e79ab26c..e2dc4f5a4 100644 --- a/tests/core/test_notifications.py +++ b/tests/core/test_notifications.py @@ -11,7 +11,7 @@ ConsequenceApprovedNotification, ConsequenceDeniedNotification, CustomEventParticipantNotification, - EventReminderNotification, + CustomEventReminderNotification, NewEventNotification, NewProfileNotification, ParticipationCustomizationNotification, @@ -52,7 +52,7 @@ def test_user_notification_sending(volunteer): def test_event_notification_sending(event, volunteer): NewEventNotification.send(event) - EventReminderNotification.send(event) + CustomEventReminderNotification.send(event) assert Notification.objects.count() == 2 * len( get_users_with_perms(event, only_with_perms_in=["view_event"]) )