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 %}
+
+
+{% 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"])
)