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/health/healthchecks.py b/ephios/core/services/health/healthchecks.py index 828437996..688034103 100644 --- a/ephios/core/services/health/healthchecks.py +++ b/ephios/core/services/health/healthchecks.py @@ -14,6 +14,8 @@ # health checks are meant to monitor the health of the application while it is running # in contrast there are django checks which are meant to check the configuration of the application +# pylint: disable=broad-exception-raised, broad-exception-caught + def run_healthchecks(): for _, healthchecks in register_healthchecks.send(None): @@ -128,26 +130,24 @@ class CronJobHealthCheck(AbstractHealthCheck): def check(self): last_call = LastRunPeriodicCall.get_last_call() - if LastRunPeriodicCall.is_stuck(): - if last_call: - return ( - HealthCheckStatus.WARNING, - mark_safe( - _("Cronjob stuck, last run {last_call}.").format( - last_call=naturaltime(last_call), - ) - ), - ) - else: - return ( - HealthCheckStatus.ERROR, - mark_safe(_("Cronjob stuck, no last run.")), - ) - else: + if not LastRunPeriodicCall.is_stuck(): return ( HealthCheckStatus.OK, mark_safe(_("Last run {last_call}.").format(last_call=naturaltime(last_call))), ) + if last_call: + return ( + HealthCheckStatus.WARNING, + mark_safe( + _("Cronjob stuck, last run {last_call}.").format( + last_call=naturaltime(last_call), + ) + ), + ) + return ( + HealthCheckStatus.ERROR, + mark_safe(_("Cronjob stuck, no last run.")), + ) class DiskSpaceHealthCheck(AbstractHealthCheck): 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/mail/cid.py b/ephios/core/services/mail/cid.py index 4fac2b57e..862779557 100644 --- a/ephios/core/services/mail/cid.py +++ b/ephios/core/services/mail/cid.py @@ -22,6 +22,8 @@ # source: # https://github.com/pretix/pretix/blob/a08272571b7b67a3f41e02cf05af8183e3f94a02/src/pretix/base/services/mail.py +# pylint: disable = logging-not-lazy, missing-timeout, bare-except + class CustomEmail(EmailMultiAlternatives): def _create_mime_attachment(self, content, mimetype): @@ -31,7 +33,7 @@ def _create_mime_attachment(self, content, mimetype): If the mimetype is message/rfc822, content may be an email.Message or EmailMessage object, as well as a str. """ - basetype, subtype = mimetype.split("/", 1) + basetype, __ = mimetype.split("/", 1) if basetype == "multipart" and isinstance(content, SafeMIMEMultipart): return content return super()._create_mime_attachment(content, mimetype) @@ -50,8 +52,7 @@ def replace_images_with_cid_paths(body_html): cid_id = "image_%s" % (len(cid_images) - 1) image["src"] = "cid:%s" % cid_id return str(email), cid_images - else: - return body_html, [] + return body_html, [] def attach_cid_images(msg, cid_images, verify_ssl=True): diff --git a/ephios/core/services/notifications/backends.py b/ephios/core/services/notifications/backends.py index f2c028806..529da1e65 100644 --- a/ephios/core/services/notifications/backends.py +++ b/ephios/core/services/notifications/backends.py @@ -38,6 +38,7 @@ def enabled_notification_backends(): def send_all_notifications(): CACHE_LOCK_KEY = "notification_sending_running" if cache.get(CACHE_LOCK_KEY): + logger.warning("Previous notification sending job not finished. Skipping...") return cache.set(CACHE_LOCK_KEY, str(uuid.uuid4()), timeout=1800) # will be cleared if no errors occur backends = set(installed_notification_backends()) @@ -168,7 +169,7 @@ def send(cls, notification): payload = { "head": str(notification.subject), "body": notification.body, - "icon": as_brand_static_path("appicon-prod.svg"), + "icon": as_brand_static_path("appicon-svg-prod.svg"), } if actions := notification.get_actions(): payload["url"] = actions[0][1] diff --git a/ephios/core/services/notifications/types.py b/ephios/core/services/notifications/types.py index f72875335..d3446f0a9 100644 --- a/ephios/core/services/notifications/types.py +++ b/ephios/core/services/notifications/types.py @@ -1,13 +1,12 @@ -from typing import List +import logging +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,9 +16,11 @@ 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 from ephios.core.templatetags.settings_extras import make_absolute +logger = logging.getLogger(__name__) + NOTIFICATION_READ_PARAM_NAME = "fromNotification" @@ -37,7 +38,8 @@ def notification_type_from_slug(slug): for notification in installed_notification_types(): if notification.slug == slug: return notification - raise ValueError(_("Notification type '{slug}' was not found.").format(slug=slug)) + logger.warning(f"No notification type found for slug {slug}") + return FallbackNotification class AbstractNotificationHandler: @@ -107,6 +109,25 @@ def get_actions_with_referrer(cls, notification): return actions +class FallbackNotification(AbstractNotificationHandler): + slug = "fallback" + title = "Fallback" + + @classmethod + def get_subject(cls, notification): + return notification.data.get("subject", notification.slug) + + @classmethod + def get_body(cls, notification): + return notification.data.get( + "body", _("Unknown content for notification #{}").format(notification.pk) + ) + + @classmethod + def is_obsolete(cls, notification): + return True + + class ProfileUpdateNotification(AbstractNotificationHandler): slug = "ephios_profile_update" title = _("Your profile has been edited") @@ -186,51 +207,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 +490,74 @@ 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_generic_mass" + title = _("You received a message from another user") 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) - - @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"), - ) - @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})), + ) + ] -class CustomEventParticipantNotification(AbstractNotificationHandler): - slug = "ephios_custom_event_participant" - title = _("Message to all participants") +class CustomEventNotification(SubjectBodyDataMixin, AbstractNotificationHandler): + slug = "ephios_custom_event_notification" + title = _("A responsible shares information on an event") 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,9 +629,8 @@ def get_body(cls, notification): ResponsibleParticipationStateChangeNotification, ResponsibleConfirmedParticipationDeclinedNotification, ResponsibleConfirmedParticipationCustomizedNotification, - NewEventNotification, - EventReminderNotification, - CustomEventParticipantNotification, + GenericMassNotification, + CustomEventNotification, ConsequenceApprovedNotification, ConsequenceDeniedNotification, ] diff --git a/ephios/core/signup/participants.py b/ephios/core/signup/participants.py index b83109cc0..b8b09e300 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"user-{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/event_form.html b/ephios/core/templates/core/event_form.html index a7115f30d..03f92585d 100644 --- a/ephios/core/templates/core/event_form.html +++ b/ephios/core/templates/core/event_form.html @@ -42,7 +42,7 @@

    {{ form.location|as_crispy_field }}
    -
    +
    diff --git a/ephios/core/templates/core/fragments/plugin_form.html b/ephios/core/templates/core/fragments/plugin_form.html index cf8e9cd2a..63dc78033 100644 --- a/ephios/core/templates/core/fragments/plugin_form.html +++ b/ephios/core/templates/core/fragments/plugin_form.html @@ -1,6 +1,6 @@ {% load crispy_forms_filters %}
    -
    +
    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..4b029c00a --- /dev/null +++ b/ephios/core/templates/core/mass_notification_write.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} +{% load cache %} +{% load event_extras %} +{% load ephios_crispy %} +{% load crispy_forms_tags %} +{% load static %} +{% load i18n %} + +{% block javascript %} + +{% endblock %} + +{% block title %} + {% translate "Mass notification" %} +{% endblock %} + +{% block html_head %} + {% if form.event %} + + {% endif %} +{% 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/templates/core/userprofile_list.html b/ephios/core/templates/core/userprofile_list.html index 20a2e82c7..bae4b1887 100644 --- a/ephios/core/templates/core/userprofile_list.html +++ b/ephios/core/templates/core/userprofile_list.html @@ -17,22 +17,37 @@

    - {% if perms.core.add_userprofile and show_local_user_management %} -
    - {% translate "Add user" %} +
    +
    + {% crispy_field filter_form.query wrapper_class="col-12 col-lg" show_labels=False %} + {% crispy_field filter_form.group wrapper_class="col-12 col-lg" show_labels=False %} + {% crispy_field filter_form.qualification wrapper_class="col-12 col-lg" show_labels=False %}
    - {% endif %} - {% crispy_field filter_form.query wrapper_class="col-12 col-lg" show_labels=False %} - {% crispy_field filter_form.group wrapper_class="col-12 col-lg" show_labels=False %} - {% crispy_field filter_form.qualification wrapper_class="col-12 col-lg" show_labels=False %} - +
    {% translate "Reset" %}
    + +
      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/accounts.py b/ephios/core/views/accounts.py index 606e83bbf..92d618e51 100644 --- a/ephios/core/views/accounts.py +++ b/ephios/core/views/accounts.py @@ -1,3 +1,5 @@ +import urllib.parse + from django import forms from django.conf import settings from django.contrib import messages @@ -104,6 +106,12 @@ def get_context_data(self, **kwargs): ctx["show_local_user_management"] = show_login_form( self.request, IdentityProvider.objects.all() ) + if len(users := self.get_queryset()) <= 200: + # as we encode every participant identifier in the URL, we must make sure the URL doesn't get too long + # URLs can be 2000 characters long, so with <10 characters per recipient, 200 is a good bound. + ctx["mass_notification_tos"] = urllib.parse.urlencode( + [("to", user.as_participant().identifier) for user in users] + ) return ctx def get_queryset(self): 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..eafe6ca6a 100644 --- a/ephios/core/views/notifications.py +++ b/ephios/core/views/notifications.py @@ -1,9 +1,26 @@ +from functools import cached_property +from operator import attrgetter + +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.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 guardian.shortcuts import get_objects_for_user, get_users_with_perms -from ephios.core.models import Notification +from ephios.core.models import AbstractParticipation, Notification +from ephios.core.services.notifications.types import ( + CustomEventNotification, + GenericMassNotification, +) +from ephios.extra.mixins import CustomCheckPermissionMixin class OwnNotificationMixin(LoginRequiredMixin): @@ -33,3 +50,184 @@ 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( + label=_("Subject"), + ) + body = forms.CharField( + label=_("Message"), + widget=forms.Textarea(attrs={"rows": 8}), + ) + to_participants = forms.MultipleChoiceField( + label=_("Recipients"), + 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() + if self.event: + self.fields["body"].widget.attrs["placeholder"] = _( + "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" + ), + ) + str( + self.request.user + ) + self.fields["to_participants"].help_text = _( + "You can only select users that have permission to view the event." + ) + + def _configure_choices(self): + choices = {} + self.participants_by_identifier = {} + + # without event context, a user can send notifications to profiles they can see + user_qs = get_objects_for_user(user=self.request.user, perms=["core.view_userprofile"]) + if self.event: + # if we are planning for an event, we can reach users that can view the event + user_qs = 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_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) + + choices = list(sorted(choices.items(), key=sort_key)) + + if self.event: + optgroup_choices = { + _("confirmed"): {i: p for i, p in choices if i in self.event_confirmed}, + _("requested"): {i: p for i, p in choices if i in self.event_requested}, + _("without response"): {i: p for i, p in choices if i in self.event_nonfeedback}, + _("others"): { + i: p + for i, p in choices + if i not in self.event_confirmed | self.event_requested | self.event_nonfeedback + }, + } + else: + optgroup_choices = choices + + self.fields["to_participants"].choices = optgroup_choices + + def clean_to_participants(self): + return list(map(self.participants_by_identifier.get, self.cleaned_data["to_participants"])) + + +class MassNotificationWriteView(CustomCheckPermissionMixin, FormView): + 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["to_participants"] = self.request.GET.getlist("to", []) + 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: + CustomEventNotification.send(self.event, recipients, **subject_and_body) + messages.success( + self.request, + ngettext_lazy( + "Sent notification to {count} participant.", + "Sent 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( + "Sent notification to {count} user.", + "Sent 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:userprofile_list") 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/locale/de/LC_MESSAGES/django.po b/ephios/locale/de/LC_MESSAGES/django.po index a0fdab3cb..49fc046c2 100644 --- a/ephios/locale/de/LC_MESSAGES/django.po +++ b/ephios/locale/de/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-11 17:22+0100\n" -"PO-Revision-Date: 2025-09-04 22:35+0200\n" +"POT-Creation-Date: 2026-01-11 20:18+0100\n" +"PO-Revision-Date: 2026-01-11 20:19+0100\n" "Last-Translator: Felix Rindt \n" "Language-Team: German \n" @@ -17,7 +17,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Poedit 3.6\n" +"X-Generator: Poedit 3.8\n" #: ephios/api/access/views.py:85 ephios/api/models.py:47 ephios/core/pdf.py:60 #: ephios/core/pdf.py:150 ephios/core/pdf.py:184 @@ -71,7 +71,7 @@ msgstr "" "Startzeit kleiner-gleich (deprecated, stattdessen start_time_before nutzen)" #: ephios/api/filters.py:61 ephios/core/models/events.py:79 -#: ephios/core/models/events.py:91 ephios/core/views/event.py:62 +#: ephios/core/models/events.py:91 ephios/core/views/event.py:57 msgid "event type" msgstr "Veranstaltungstyp" @@ -95,12 +95,12 @@ msgstr "Teilnahme-Typ" msgid "Duration in seconds" msgstr "Dauer in Sekunden" -#: ephios/api/serializers.py:167 ephios/core/forms/events.py:185 +#: ephios/api/serializers.py:167 ephios/core/forms/events.py:181 #: ephios/core/templates/core/fragments/shift_box_small.html:24 msgid "Start time" msgstr "Beginn" -#: ephios/api/serializers.py:168 ephios/core/forms/events.py:186 +#: ephios/api/serializers.py:168 ephios/core/forms/events.py:182 #: ephios/core/templates/core/fragments/shift_box_small.html:25 msgid "End time" msgstr "Ende" @@ -142,6 +142,7 @@ msgstr "Speichern" #: ephios/core/templates/core/disposition/disposition.html:124 #: ephios/core/templates/core/event_form.html:68 #: ephios/core/templates/core/eventtype_form.html:21 +#: ephios/core/templates/core/mass_notification_write.html:135 #: ephios/core/templates/core/userprofile_form.html:152 #: ephios/plugins/questionnaires/templates/questionnaires/question_form.html:91 #: ephios/plugins/questionnaires/templates/questionnaires/savedanswer_form.html:28 @@ -285,7 +286,7 @@ msgstr "Möchten Sie die Anwendung wirklich löschen?" #: ephios/core/templates/core/userprofile_confirm_delete.html:21 #: ephios/core/templates/core/userprofile_form.html:89 #: ephios/core/templates/core/userprofile_form.html:120 -#: ephios/core/templates/core/userprofile_list.html:105 +#: ephios/core/templates/core/userprofile_list.html:120 #: ephios/core/templates/core/userprofile_workinghours.html:50 #: ephios/core/templates/core/workinghours_confirm_delete.html:17 #: ephios/plugins/files/templates/files/document_confirm_delete.html:19 @@ -394,7 +395,7 @@ msgstr "Zurück" #: ephios/core/templates/core/group_list.html:63 #: ephios/core/templates/core/identityprovider_list.html:27 #: ephios/core/templates/core/shift_form.html:98 -#: ephios/core/templates/core/userprofile_list.html:100 +#: ephios/core/templates/core/userprofile_list.html:115 #: ephios/core/templates/core/userprofile_workinghours.html:49 #: ephios/plugins/files/templates/files/document_list.html:40 #: ephios/plugins/pages/templates/pages/page_list.html:24 @@ -613,11 +614,11 @@ msgstr "" msgid "This account is inactive." msgstr "Dieser Account ist deaktiviert." -#: ephios/core/forms/events.py:42 ephios/core/forms/events.py:133 +#: ephios/core/forms/events.py:38 ephios/core/forms/events.py:129 msgid "Visible for" msgstr "Sichtbar für" -#: ephios/core/forms/events.py:44 +#: ephios/core/forms/events.py:40 msgid "" "Select groups which the event shall be visible for. Regardless, the event " "will be visible for responsible groups and users that already signed up." @@ -626,20 +627,20 @@ msgstr "" "davon ist die Veranstaltung für alle Verantwortliche und Teilnehmende " "sichtbar." -#: ephios/core/forms/events.py:53 +#: ephios/core/forms/events.py:49 msgid "Responsible persons" msgstr "Verantwortliche Personen" -#: ephios/core/forms/events.py:54 +#: ephios/core/forms/events.py:50 msgid "Individuals can also be made responsible for an event." msgstr "" "Auch individuelle Personen können für eine Veranstaltung verantwortlich sein." -#: ephios/core/forms/events.py:60 +#: ephios/core/forms/events.py:56 msgid "Responsible groups" msgstr "Verantwortliche Gruppen" -#: ephios/core/forms/events.py:115 +#: ephios/core/forms/events.py:111 #, python-brace-format msgid "" "Also, this event is visible to {groups}, but you don't have " @@ -648,7 +649,7 @@ msgstr "" "Außerdem ist die Veranstaltung für {groups} sichtbar, aber Sie haben " "keine Berechtigung, die Sichtbarkeit für diese Gruppen zu ändern." -#: ephios/core/forms/events.py:124 +#: ephios/core/forms/events.py:120 #, python-brace-format msgid "" "This event is always editable by {groups}, because they manage ephios." @@ -656,57 +657,33 @@ msgstr "" "Die Veranstaltung kann auch von {groups} bearbeitet werden, da diese " "ephios verwalten." -#: ephios/core/forms/events.py:134 +#: ephios/core/forms/events.py:130 msgid "Responsibles" msgstr "Verantwortliche" -#: ephios/core/forms/events.py:183 ephios/core/models/users.py:499 +#: ephios/core/forms/events.py:179 ephios/core/models/users.py:499 #: ephios/core/pdf.py:137 ephios/core/pdf.py:183 #: ephios/core/templates/core/fragments/shift_box_small.html:22 #: ephios/core/templates/core/userprofile_workinghours.html:25 -#: ephios/core/views/event.py:70 ephios/core/views/event.py:79 +#: ephios/core/views/event.py:65 ephios/core/views/event.py:74 #: ephios/core/views/log.py:35 msgid "Date" msgstr "Datum" -#: ephios/core/forms/events.py:184 ephios/core/pdf.py:144 +#: ephios/core/forms/events.py:180 ephios/core/pdf.py:144 #: ephios/core/pdf.py:194 #: ephios/core/templates/core/fragments/shift_box_small.html:23 msgid "Meeting time" msgstr "Treffpunkt" -#: ephios/core/forms/events.py:262 +#: ephios/core/forms/events.py:258 msgid "Meeting time must not be after start time!" msgstr "Treffpunkt darf nicht nach dem Beginn liegen!" -#: ephios/core/forms/events.py:293 +#: ephios/core/forms/events.py:289 msgid "You need to enter a valid color" msgstr "Sie müssen eine gültige Farbe eingeben" -#: ephios/core/forms/events.py:326 -msgid "Send notification about new event to everyone" -msgstr "Alle Benutzer über die neue Veranstaltung benachrichtigen" - -#: ephios/core/forms/events.py:327 -msgid "Send reminder to everyone that is not participating" -msgstr "Alle nicht teilnehmenden Benutzer an die Veranstaltung erinnern" - -#: ephios/core/forms/events.py:328 -msgid "Send a message to all participants" -msgstr "Nachricht an alle Teilnehmenden senden" - -#: ephios/core/forms/events.py:333 -msgid "Mail content" -msgstr "Nachricht" - -#: ephios/core/forms/events.py:343 -msgid "Send" -msgstr "Senden" - -#: ephios/core/forms/events.py:353 -msgid "You cannot send an empty mail." -msgstr "Sie können keine leere Nachricht versenden." - #: ephios/core/forms/users.py:90 ephios/core/forms/users.py:105 msgid "Can add events" msgstr "Darf Veranstaltungen hinzufügen" @@ -971,11 +948,12 @@ msgstr "Veranstaltung" msgid "events" msgstr "Veranstaltungen" -#: ephios/core/models/events.py:253 +#: ephios/core/models/events.py:253 ephios/core/views/notifications.py:148 msgid "requested" msgstr "angefragt" -#: ephios/core/models/events.py:254 ephios/core/views/event.py:89 +#: ephios/core/models/events.py:254 ephios/core/views/event.py:84 +#: ephios/core/views/notifications.py:147 msgid "confirmed" msgstr "bestätigt" @@ -1250,7 +1228,7 @@ msgstr "fehlgeschlagen" msgid "denied" msgstr "abgelehnt" -#: ephios/core/models/users.py:415 ephios/core/views/event.py:84 +#: ephios/core/models/users.py:415 ephios/core/views/event.py:79 msgid "State" msgstr "Status" @@ -1285,7 +1263,7 @@ msgstr "Anlass" #: ephios/core/models/users.py:503 ephios/core/models/users.py:504 #: ephios/core/signals.py:292 -#: ephios/core/templates/core/userprofile_list.html:96 +#: ephios/core/templates/core/userprofile_list.html:111 #: ephios/core/templates/core/workinghours_list.html:9 #: ephios/core/templates/core/workinghours_list.html:14 #: ephios/templates/base.html:144 @@ -1319,7 +1297,7 @@ msgstr "" msgid "{subject} for {user}" msgstr "{subject} für {user}" -#: ephios/core/models/users.py:560 ephios/plugins/guests/models.py:129 +#: ephios/core/models/users.py:560 ephios/plugins/guests/models.py:133 msgid "Guest" msgstr "Gast" @@ -1519,43 +1497,43 @@ msgstr "Zeit" msgid "Participants" msgstr "Teilnehmende" -#: ephios/core/services/health/healthchecks.py:71 +#: ephios/core/services/health/healthchecks.py:73 msgid "Database" msgstr "Datenbank" -#: ephios/core/services/health/healthchecks.py:72 +#: ephios/core/services/health/healthchecks.py:74 msgid "The database is the central storage for all data." msgstr "Die Datenbank ist zentraler Speicherort für alle Informationen." -#: ephios/core/services/health/healthchecks.py:86 +#: ephios/core/services/health/healthchecks.py:88 msgid "Using SQLite, which should not be used in production." msgstr "Nutzt SQLite (ungeeignet für Produktionsumgebung)." -#: ephios/core/services/health/healthchecks.py:89 +#: ephios/core/services/health/healthchecks.py:91 msgid "Database connection established." msgstr "Datenbankverbindung hergestellt." -#: ephios/core/services/health/healthchecks.py:94 +#: ephios/core/services/health/healthchecks.py:96 msgid "Cache" msgstr "Cache" -#: ephios/core/services/health/healthchecks.py:95 +#: ephios/core/services/health/healthchecks.py:97 msgid "The cache is used to store temporary data." msgstr "Im Cache werden Daten temporär gespeichert." -#: ephios/core/services/health/healthchecks.py:113 +#: ephios/core/services/health/healthchecks.py:115 msgid "Using LocMemCache, which should not be used in production." msgstr "Nutzt LocMemCache (ungeeignet für Produktionsumgebung)." -#: ephios/core/services/health/healthchecks.py:116 +#: ephios/core/services/health/healthchecks.py:118 msgid "Cache connection established." msgstr "Cache-Verbindung hergestellt." -#: ephios/core/services/health/healthchecks.py:121 +#: ephios/core/services/health/healthchecks.py:123 msgid "Cronjob" msgstr "Cronjob" -#: ephios/core/services/health/healthchecks.py:123 +#: ephios/core/services/health/healthchecks.py:125 msgid "" "A cron job must regularly call ephios to do recurring tasks like sending " "notifications." @@ -1565,18 +1543,18 @@ msgstr "" #: ephios/core/services/health/healthchecks.py:136 #, python-brace-format +msgid "Last run {last_call}." +msgstr "Letzte Ausführung {last_call}." + +#: ephios/core/services/health/healthchecks.py:142 +#, python-brace-format msgid "Cronjob stuck, last run {last_call}." msgstr "Cronjob hängt, letzte Ausführung {last_call}." -#: ephios/core/services/health/healthchecks.py:144 +#: ephios/core/services/health/healthchecks.py:149 msgid "Cronjob stuck, no last run." msgstr "Cronjob hängt, keine letzte Ausführung." -#: ephios/core/services/health/healthchecks.py:149 -#, python-brace-format -msgid "Last run {last_call}." -msgstr "Letzte Ausführung {last_call}." - #: ephios/core/services/health/healthchecks.py:155 msgid "Disk space" msgstr "Speicherplatz" @@ -1599,29 +1577,29 @@ msgstr "Weniger als 1 GB Speicherplatz verfügbar. Bitte schaffen Sie Platz." msgid "{disk_space} of disk space available." msgstr "{disk_space} verfügbar." -#: ephios/core/services/notifications/backends.py:138 +#: ephios/core/services/notifications/backends.py:139 msgid "via email" msgstr "via E-Mail" -#: ephios/core/services/notifications/backends.py:163 +#: ephios/core/services/notifications/backends.py:164 msgid "via push notification" msgstr "via Push-Benachrichtigung" -#: ephios/core/services/notifications/types.py:40 +#: ephios/core/services/notifications/types.py:122 #, python-brace-format -msgid "Notification type '{slug}' was not found." -msgstr "Benachrichtigungstyp '{slug}' wurde nicht gefunden." +msgid "Unknown content for notification #{}" +msgstr "Unbekannter Inhalt für Benachrichtigung #{}" -#: ephios/core/services/notifications/types.py:112 +#: ephios/core/services/notifications/types.py:131 msgid "Your profile has been edited" msgstr "Ihr Benutzerprofil wurde bearbeitet" -#: ephios/core/services/notifications/types.py:120 +#: ephios/core/services/notifications/types.py:139 #, python-brace-format msgid "{platform} account updated" msgstr "{platform}-Benutzerkonto aktualisiert" -#: ephios/core/services/notifications/types.py:126 +#: ephios/core/services/notifications/types.py:145 #, python-brace-format msgid "" "You're receiving this email because your {platform} account at {org_name} " @@ -1630,20 +1608,20 @@ msgstr "" "Sie erhalten diese E-Mail, weil Ihr {platform}-Benutzerkonto bei {org_name} " "aktualisiert wurde." -#: ephios/core/services/notifications/types.py:134 +#: ephios/core/services/notifications/types.py:153 msgid "View profile" msgstr "Benutzerprofil ansehen" -#: ephios/core/services/notifications/types.py:143 +#: ephios/core/services/notifications/types.py:162 msgid "A new profile has been created" msgstr "Ein neues Benutzerprofil wurde erstellt" -#: ephios/core/services/notifications/types.py:157 +#: ephios/core/services/notifications/types.py:176 #, python-brace-format msgid "Welcome to {}!" msgstr "Willkommen bei {}!" -#: ephios/core/services/notifications/types.py:163 +#: ephios/core/services/notifications/types.py:182 #, python-brace-format msgid "" "You're receiving this email because a new {platform} account has been " @@ -1656,206 +1634,172 @@ msgstr "" "Bitte rufen Sie folgende Seite auf und wählen Sie ein neues Passwort: {url}\n" "Ihr Benutzername ist Ihre E-Mail-Adresse: {email}" -#: ephios/core/services/notifications/types.py:175 +#: ephios/core/services/notifications/types.py:194 #: ephios/core/templates/core/mails/new_account_email.html:11 #: ephios/templates/registration/password_reset_confirm.html:7 #: ephios/templates/registration/password_reset_confirm.html:19 msgid "Set password" msgstr "Passwort setzen" -#: ephios/core/services/notifications/types.py:191 -msgid "A new event has been added" -msgstr "Eine neue Veranstaltung wurde hinzugefügt" - -#: ephios/core/services/notifications/types.py:206 -#, python-brace-format -msgid "New {type}: {title}" -msgstr "{type} hinzugefügt: {title}" - -#: ephios/core/services/notifications/types.py:212 -#, python-brace-format -msgid "" -"A new {type} ({title}, {location}) has been added.\n" -"Further information: {description}" -msgstr "" -"{type} {title} ({location}) wurde hinzugefügt.\n" -" Weitere Informationen: {description}" - -#: ephios/core/services/notifications/types.py:231 -#: ephios/core/services/notifications/types.py:249 -#: ephios/core/services/notifications/types.py:388 -#: ephios/core/services/notifications/types.py:553 -#: ephios/core/services/notifications/types.py:624 +#: ephios/core/services/notifications/types.py:223 +#: ephios/core/services/notifications/types.py:362 +#: ephios/core/services/notifications/types.py:571 #: ephios/core/templates/core/mails/new_event.html:36 #: ephios/core/templates/core/userprofile_workinghours.html:47 #: ephios/plugins/guests/notifications.py:37 msgid "View event" msgstr "Veranstaltung anzeigen" -#: ephios/core/services/notifications/types.py:277 +#: ephios/core/services/notifications/types.py:251 msgid "The state of your participation has changed" msgstr "Der Status Ihrer Teilnahme hat sich geändert" -#: ephios/core/services/notifications/types.py:284 +#: ephios/core/services/notifications/types.py:258 #, python-brace-format msgid "Participation {state} for {event}" msgstr "Teilnahme {state} für {event}" -#: ephios/core/services/notifications/types.py:300 +#: ephios/core/services/notifications/types.py:274 #, python-brace-format msgid "You requested a participation for {shift}." msgstr "Sie haben eine Teilnahme für {shift} angefragt." -#: ephios/core/services/notifications/types.py:302 +#: ephios/core/services/notifications/types.py:276 #, python-brace-format msgid "Your participation for {shift} is now confirmed." msgstr "Ihre Teilnahme an {shift} ist jetzt bestätigt." -#: ephios/core/services/notifications/types.py:305 +#: ephios/core/services/notifications/types.py:279 #, python-brace-format msgid "Your participation for {shift} has been rejected by a responsible user." msgstr "" "Ihre Teilnahme an {shift} wurde von einer verantwortlichen Person abgelehnt." -#: ephios/core/services/notifications/types.py:308 +#: ephios/core/services/notifications/types.py:282 msgid "Your time is" msgstr "Ihre Zeit ist" -#: ephios/core/services/notifications/types.py:314 +#: ephios/core/services/notifications/types.py:288 msgid "A confirmed participation of yours has been tweaked by a responsible" msgstr "" "Eine Ihrer bestätigten Teilnahmen wurde von einer verantwortlichen Person " "angepasst" -#: ephios/core/services/notifications/types.py:326 +#: ephios/core/services/notifications/types.py:300 #, python-brace-format msgid "Participation tweaked for {shift}" msgstr "Teilnahme angepasst für {shift}" -#: ephios/core/services/notifications/types.py:334 +#: ephios/core/services/notifications/types.py:308 #, python-brace-format msgid "Your participation for {shift} has been tweaked by a responsible user." msgstr "" "Ihre Teilnahme an {shift} wurde von einer verantwortlichen Person angepasst." -#: ephios/core/services/notifications/types.py:391 +#: ephios/core/services/notifications/types.py:365 #: ephios/core/templates/core/disposition/disposition.html:8 #: ephios/core/templates/core/fragments/shift_box_big.html:119 #: ephios/core/templates/core/home.html:107 msgid "Disposition" msgstr "Disposition" -#: ephios/core/services/notifications/types.py:400 +#: ephios/core/services/notifications/types.py:374 #, python-brace-format msgid "{participant} signed up for {shift}" msgstr "{participant} nimmt teil an {shift}" -#: ephios/core/services/notifications/types.py:402 +#: ephios/core/services/notifications/types.py:376 #, python-brace-format msgid "{participant} has requested to participate in {shift}" msgstr "Teilnahme angefragt von {participant} für {shift}" -#: ephios/core/services/notifications/types.py:405 +#: ephios/core/services/notifications/types.py:379 #, python-brace-format msgid "{participant} declined to participate in {shift}" msgstr "{participant} hat abgesagt für {shift}" -#: ephios/core/services/notifications/types.py:408 +#: ephios/core/services/notifications/types.py:382 #, python-brace-format msgid "{participant} was rejected for {shift}" msgstr "{participant} wurde abgelehnt für {shift}" -#: ephios/core/services/notifications/types.py:411 +#: ephios/core/services/notifications/types.py:385 #, python-brace-format msgid "{participant} is being dispatched for {shift}" msgstr "{participant} wird disponiert in {shift}" -#: ephios/core/services/notifications/types.py:424 +#: ephios/core/services/notifications/types.py:398 msgid "A participation for your event awaits disposition" msgstr "Eine Teilnahme an Ihrer Veranstaltung wartet auf Disposition" -#: ephios/core/services/notifications/types.py:438 +#: ephios/core/services/notifications/types.py:412 #, python-brace-format msgid "{participant} requested participating in {shift}." msgstr "{participant} hat eine Teilnahme an {shift} angefragt." -#: ephios/core/services/notifications/types.py:448 +#: ephios/core/services/notifications/types.py:422 msgid "A participation for your event has changed state" msgstr "Der Status einer Teilnahme an Ihrer Veranstaltung hat sich geändert" -#: ephios/core/services/notifications/types.py:462 +#: ephios/core/services/notifications/types.py:436 #, python-brace-format msgid "The participation of {participant} for {shift} is now {state}." msgstr "Die Teilnahme von {participant} an {shift} ist nun {state}." -#: ephios/core/services/notifications/types.py:475 +#: ephios/core/services/notifications/types.py:449 msgid "A participant declined after having been confirmed for your event" msgstr "" "Ein Teilnehmer hat abgesagt, nachdem er für Ihre Veranstaltung bestätigt " "wurde" -#: ephios/core/services/notifications/types.py:482 +#: ephios/core/services/notifications/types.py:456 #, python-brace-format msgid "{participant} declined their participation in {shift}." msgstr "{participant} hat abgesagt für {shift}." -#: ephios/core/services/notifications/types.py:491 +#: ephios/core/services/notifications/types.py:465 msgid "A confirmed participant altered their participation" msgstr "Ein bestätigter Teilnehmer hat seine Teilnahme angepasst" -#: ephios/core/services/notifications/types.py:498 +#: ephios/core/services/notifications/types.py:472 #, python-brace-format msgid "Participation altered for {event}" msgstr "Teilnahme angepasst für {event}" -#: ephios/core/services/notifications/types.py:510 +#: ephios/core/services/notifications/types.py:484 #, python-brace-format msgid "{participant} altered their participation in {shift}." msgstr "{participant} hat die Teilnahme an {shift} angepasst." -#: ephios/core/services/notifications/types.py:519 -msgid "An event has vacant spots" -msgstr "Für eine Veranstaltung werden noch Teilnehmende gesucht" - -#: ephios/core/services/notifications/types.py:539 -#, python-brace-format -msgid "Help needed for {title}" -msgstr "Unterstützung benötigt für {title}" - -#: ephios/core/services/notifications/types.py:544 -#, python-brace-format -msgid "Your support is needed for {title} ({start} - {end})." -msgstr "Ihre Unterstützung wird benötigt für {title} ({start} - {end})." - -#: ephios/core/services/notifications/types.py:558 -msgid "Message to all participants" -msgstr "Nachricht an alle Teilnehmenden" +#: ephios/core/services/notifications/types.py:504 +msgid "You received a message from another user" +msgstr "Sie haben eine Nachricht von einem anderen Nutzer erhalten" -#: ephios/core/services/notifications/types.py:606 -#, python-brace-format -msgid "Information regarding {title}" -msgstr "Informationen zu {title}" - -#: ephios/core/services/notifications/types.py:620 +#: ephios/core/services/notifications/types.py:528 +#: ephios/core/services/notifications/types.py:567 msgid "View message" msgstr "Nachricht anzeigen" -#: ephios/core/services/notifications/types.py:636 -#: ephios/core/services/notifications/types.py:646 +#: ephios/core/services/notifications/types.py:536 +msgid "A responsible shares information on an event" +msgstr "Ein Verantwortlicher teilt Informationen zu einer Veranstaltung" + +#: ephios/core/services/notifications/types.py:583 +#: ephios/core/services/notifications/types.py:593 msgid "Your request has been approved" msgstr "Ihre Anfrage wurde akzeptiert" -#: ephios/core/services/notifications/types.py:651 +#: ephios/core/services/notifications/types.py:598 #, python-brace-format msgid "\"{consequence}\" has been approved." msgstr "\"{consequence}\" wurde akzeptiert." -#: ephios/core/services/notifications/types.py:656 -#: ephios/core/services/notifications/types.py:666 +#: ephios/core/services/notifications/types.py:603 +#: ephios/core/services/notifications/types.py:613 msgid "Your request has been denied" msgstr "Ihre Anfrage wurde abgelehnt" -#: ephios/core/services/notifications/types.py:671 +#: ephios/core/services/notifications/types.py:618 #, python-brace-format msgid "\"{consequence}\" has been denied." msgstr "\"{consequence}\" wurde abgelehnt." @@ -2039,7 +1983,7 @@ msgstr "Absagen" msgid "Signup choice" msgstr "Anmeldeaktion" -#: ephios/core/signup/participants.py:124 +#: ephios/core/signup/participants.py:142 msgid "Placeholder" msgstr "Platzhalter" @@ -2154,6 +2098,7 @@ msgstr "Änderungsverlauf" #: ephios/core/templates/core/event_detail.html:114 #: ephios/core/templates/core/fragments/event_list_list_mode.html:121 #: ephios/core/templates/core/mails/new_event.html:19 +#: ephios/core/templates/core/mass_notification_write.html:57 #: ephios/core/templates/core/workinghours_list.html:18 #: ephios/plugins/federation/templates/federation/external_event_list.html:66 #: ephios/plugins/guests/templates/guests/guestuser_form.html:14 @@ -2208,12 +2153,12 @@ msgid "Filter" msgstr "Filter" #: ephios/core/templates/core/event_list.html:73 -#: ephios/core/templates/core/userprofile_list.html:32 +#: ephios/core/templates/core/userprofile_list.html:29 msgid "Apply" msgstr "Anwenden" #: ephios/core/templates/core/event_list.html:77 -#: ephios/core/templates/core/userprofile_list.html:34 +#: ephios/core/templates/core/userprofile_list.html:31 msgid "Reset" msgstr "Zurücksetzen" @@ -2575,6 +2520,50 @@ msgstr "Benachrichtigungseinstellungen" msgid "Click here to view and change your notification settings" msgstr "Hier klicken um Benachrichtigungseinstellungen anzusehen und zu ändern" +#: ephios/core/templates/core/mass_notification_write.html:14 +msgid "Mass notification" +msgstr "Massenbenachrichtigung" + +#: ephios/core/templates/core/mass_notification_write.html:31 +msgid "Mass Notification" +msgstr "Massenbenachrichtigung" + +#: ephios/core/templates/core/mass_notification_write.html:75 +#: ephios/core/views/notifications.py:64 +msgid "Recipients" +msgstr "Empfänger" + +#: ephios/core/templates/core/mass_notification_write.html:87 +#, python-format +msgid "Include %(counter)s confirmed participant" +msgid_plural "Include %(counter)s confirmed participants" +msgstr[0] "An %(counter)s bestätigten Teilnehmenden senden" +msgstr[1] "An %(counter)s bestätigte Teilnehmende senden" + +#: ephios/core/templates/core/mass_notification_write.html:100 +#, python-format +msgid "Include %(counter)s participant with a requested participation" +msgid_plural "Include %(counter)s participants with requested participation" +msgstr[0] "An %(counter)s Teilnehmenden mit angefragter Teilnahme senden" +msgstr[1] "An %(counter)s Teilnehmende mit angefragter Teilnahme senden" + +#: ephios/core/templates/core/mass_notification_write.html:113 +#, python-format +msgid "Include %(counter)s user that did not respond to the event yet" +msgid_plural "Include %(counter)s users that did not respond to the event yet" +msgstr[0] "" +"An %(counter)s Benutzer senden, der noch keine Rückmeldung gegeben hat" +msgstr[1] "" +"An %(counter)s Benutzer senden, die noch keine Rückmeldung gegeben haben" + +#: ephios/core/templates/core/mass_notification_write.html:125 +msgid "Recipient list" +msgstr "Liste der Empfänger" + +#: ephios/core/templates/core/mass_notification_write.html:134 +msgid "Send" +msgstr "Senden" + #: ephios/core/templates/core/notification_detail.html:11 msgid "View other notifications" msgstr "Andere Benachrichtigungen anzeigen" @@ -2862,36 +2851,43 @@ msgstr "" msgid "Revoke password and API Tokens" msgstr "Passwort und Zugriffstoken widerrufen" -#: ephios/core/templates/core/userprofile_list.html:23 +#: ephios/core/templates/core/userprofile_list.html:37 msgid "Add user" msgstr "Benutzer hinzufügen" -#: ephios/core/templates/core/userprofile_list.html:54 +#: ephios/core/templates/core/userprofile_list.html:43 +#, python-format +msgid "Send message to this %(counter)s account" +msgid_plural "Send message to these %(counter)s accounts" +msgstr[0] "Nachricht an diesen %(counter)s Nutzer senden" +msgstr[1] "Nachricht an diese %(counter)s Nutzer senden" + +#: ephios/core/templates/core/userprofile_list.html:69 msgid "minor" msgstr "minderjährig" -#: ephios/core/templates/core/userprofile_list.html:61 +#: ephios/core/templates/core/userprofile_list.html:76 #, python-format msgid "Last login: %(timeframe)s ago" msgstr "Letze Anmeldung vor %(timeframe)s" -#: ephios/core/templates/core/userprofile_list.html:63 +#: ephios/core/templates/core/userprofile_list.html:78 msgid "Last login: never" msgstr "Letze Anmeldung: nie" -#: ephios/core/templates/core/userprofile_list.html:65 +#: ephios/core/templates/core/userprofile_list.html:80 msgid "not seen recently" msgstr "länger nicht gesehen" -#: ephios/core/templates/core/userprofile_list.html:70 +#: ephios/core/templates/core/userprofile_list.html:85 msgid "inactive" msgstr "inaktiv" -#: ephios/core/templates/core/userprofile_list.html:72 +#: ephios/core/templates/core/userprofile_list.html:87 msgid "admin" msgstr "Admin" -#: ephios/core/templates/core/userprofile_list.html:83 +#: ephios/core/templates/core/userprofile_list.html:98 msgid "no groups" msgstr "keine Gruppen" @@ -2965,68 +2961,68 @@ msgstr "Hinzufügen" msgid "No entries" msgstr "Keine Einträge" -#: ephios/core/views/accounts.py:39 ephios/core/views/accounts.py:40 -#: ephios/core/views/event.py:57 ephios/core/views/event.py:58 +#: ephios/core/views/accounts.py:41 ephios/core/views/accounts.py:42 +#: ephios/core/views/event.py:52 ephios/core/views/event.py:53 msgid "Search for…" msgstr "Suche nach…" -#: ephios/core/views/accounts.py:44 +#: ephios/core/views/accounts.py:46 msgid "Group" msgstr "Gruppe" -#: ephios/core/views/accounts.py:49 +#: ephios/core/views/accounts.py:51 msgid "Group membership" msgstr "Gruppenmitgliedschaft" -#: ephios/core/views/accounts.py:55 ephios/core/views/accounts.py:61 +#: ephios/core/views/accounts.py:57 ephios/core/views/accounts.py:63 #: ephios/plugins/eventautoqualification/models.py:15 msgid "Qualification" msgstr "Qualifikation" -#: ephios/core/views/accounts.py:147 +#: ephios/core/views/accounts.py:155 #, python-brace-format msgid "User {name} ({email}) added successfully." msgstr "Benutzer {name} ({email}) erfolgreich hinzugefügt." -#: ephios/core/views/accounts.py:197 +#: ephios/core/views/accounts.py:205 #, python-brace-format msgid "User {name} ({email}) updated successfully." msgstr "Benutzer {name} ({email}) wurde geändert." -#: ephios/core/views/accounts.py:226 +#: ephios/core/views/accounts.py:234 #, python-brace-format msgid "The user {name} ({email}) was deleted." msgstr "Benutzer {name} ({email}) wurde gelöscht." -#: ephios/core/views/accounts.py:253 +#: ephios/core/views/accounts.py:261 #, python-brace-format msgid "The user's password has been reset. An email was sent to {email}." msgstr "" "Das Account-Passwort wurde zurückgesetzt. Eine Nachricht wurde an {email} " "geschickt." -#: ephios/core/views/accounts.py:260 +#: ephios/core/views/accounts.py:268 #, python-brace-format msgid "No valid email address ({email}). The password has not been reset." msgstr "" "Keine valide E-Mail-Adresse ({email}). Das Passwort wurde nicht " "zurückgesetzt." -#: ephios/core/views/accounts.py:284 +#: ephios/core/views/accounts.py:292 msgid "The user's password and API tokens have been revoked." msgstr "Das Passwort und die Zugrifftoken des Benutzers wurden widerrufen." -#: ephios/core/views/accounts.py:330 +#: ephios/core/views/accounts.py:338 #, python-brace-format msgid "Group \"{group}\" created successfully." msgstr "Gruppe \"{group}\" erfolgreich erstellt." -#: ephios/core/views/accounts.py:349 +#: ephios/core/views/accounts.py:357 #, python-brace-format msgid "Group \"{group}\" updated successfully." msgstr "Gruppe \"{group}\" erfolgreich aktualisiert." -#: ephios/core/views/accounts.py:366 +#: ephios/core/views/accounts.py:374 #, python-brace-format msgid "The group \"{group}\" was deleted." msgstr "Gruppe \"{group}\" wurde gelöscht." @@ -3071,45 +3067,41 @@ msgstr "Es wurden keine Veranstaltungen zum Löschen ausgewählt." msgid "The selected events have been deleted." msgstr "Die ausgewählten Veranstaltungen wurden gelöscht." -#: ephios/core/views/event.py:73 +#: ephios/core/views/event.py:68 msgctxt "event date filter" msgid "until" msgstr "bis zum" -#: ephios/core/views/event.py:74 +#: ephios/core/views/event.py:69 msgctxt "event date filter" msgid "from" msgstr "ab dem" -#: ephios/core/views/event.py:87 +#: ephios/core/views/event.py:82 msgid "all" msgstr "alle" -#: ephios/core/views/event.py:88 +#: ephios/core/views/event.py:83 msgid "no response" msgstr "ohne Rückmeldung" -#: ephios/core/views/event.py:90 +#: ephios/core/views/event.py:85 msgid "requested or confirmed" msgstr "angefragt oder bestätigt" -#: ephios/core/views/event.py:91 +#: ephios/core/views/event.py:86 msgid "disposition to do" msgstr "Disposition offen" -#: ephios/core/views/event.py:589 ephios/core/views/shift.py:122 +#: ephios/core/views/event.py:584 ephios/core/views/shift.py:122 #, python-brace-format msgid "The event {title} has been saved." msgstr "Die Veranstaltung {title} wurde gespeichert." -#: ephios/core/views/event.py:687 +#: ephios/core/views/event.py:682 msgid "Event copied successfully." msgstr "Veranstaltung erfolgreich kopiert." -#: ephios/core/views/event.py:716 -msgid "Notifications sent succesfully." -msgstr "Benachrichtigungen erfolgreich gesendet." - #: ephios/core/views/eventtype.py:41 #, python-brace-format msgid "The event type {type} has been saved." @@ -3141,6 +3133,65 @@ msgstr "Handelnder Benutzer" msgid "Contents" msgstr "Inhalte" +#: ephios/core/views/notifications.py:57 +msgid "Subject" +msgstr "Betreff" + +#: ephios/core/views/notifications.py:60 +msgid "Message" +msgstr "Nachricht" + +#: ephios/core/views/notifications.py:67 +msgid "Add recipients" +msgstr "Empfänger hinzufügen" + +#: ephios/core/views/notifications.py:81 +#, python-brace-format +msgid "" +"Hello,\n" +"\n" +"regarding {event_title} starting at {event_start} we want to communicate...\n" +"\n" +"Kind regards\n" +msgstr "" +"Hallo,\n" +"\n" +"bezüglich {event_title} am {event_start} möchten wir mitteilen...\n" +"\n" +"Viele Grüße\n" + +#: ephios/core/views/notifications.py:91 +msgid "You can only select users that have permission to view the event." +msgstr "" +"Sie können nur Nutzer auswählen, die Zugriff auf die Veranstaltung haben." + +#: ephios/core/views/notifications.py:149 +msgid "without response" +msgstr "ohne Rückmeldung" + +#: ephios/core/views/notifications.py:150 +msgid "others" +msgstr "andere" + +#: ephios/core/views/notifications.py:193 +#, python-brace-format +msgid "Information on {event_title}" +msgstr "Informationen zu {event_title}" + +#: ephios/core/views/notifications.py:210 +#, python-brace-format +msgid "Sent notification to {count} participant." +msgid_plural "Sent notification to {count} participants." +msgstr[0] "Benachrichtigung an {count} Teilnehmenden gesendet." +msgstr[1] "Benachrichtigung an {count} Teilnehmende gesendet." + +#: ephios/core/views/notifications.py:223 +#, python-brace-format +msgid "Sent notification to {count} user." +msgid_plural "Sent notification to {count} users." +msgstr[0] "Benachrichtigung an {count} Nutzer gesendet." +msgstr[1] "Benachrichtigung an {count} Nutzern gesendet." + #: ephios/core/views/pwa.py:68 msgid "Show the event list" msgstr "Zeig die Veranstaltungsliste" @@ -3327,7 +3378,7 @@ msgstr "" "Keine Daten ausgewählt. Fügen Sie Daten und Regeln mit den Knöpfen auf der " "linken Seite hinzu." -#: ephios/extra/utils.py:36 +#: ephios/extra/utils.py:29 msgid "None" msgstr "Nichts" @@ -4095,24 +4146,24 @@ msgstr "Föderierter Benutzer" msgid "federated users" msgstr "Föderierte Benutzer" -#: ephios/plugins/federation/models.py:197 +#: ephios/plugins/federation/models.py:201 msgid "Federated user" msgstr "Föderierter Benutzer" -#: ephios/plugins/federation/models.py:203 -#: ephios/plugins/federation/models.py:209 +#: ephios/plugins/federation/models.py:207 +#: ephios/plugins/federation/models.py:213 msgid "federated participant" msgstr "Föderierter Teilnehmender" -#: ephios/plugins/federation/models.py:204 +#: ephios/plugins/federation/models.py:208 msgid "federated participants" msgstr "Föderierte Teilnehmende" -#: ephios/plugins/federation/models.py:220 +#: ephios/plugins/federation/models.py:224 msgid "federated participation" msgstr "Föderierte Teilnahme" -#: ephios/plugins/federation/models.py:221 +#: ephios/plugins/federation/models.py:225 msgid "federated participations" msgstr "Föderierte Teilnahmen" @@ -5329,16 +5380,18 @@ msgid "Logout" msgstr "Abmelden" #: ephios/templates/base.html:198 ephios/templates/base.html:212 +#, python-format msgid "Install %(platform_name)s app" msgstr "%(platform_name)s-App installieren" #: ephios/templates/base.html:204 ephios/templates/base.html:218 +#, python-format msgid "" "You can install %(platform_name)s on your device and use it like any other " "app!" msgstr "" -"Sie können %(platform_name)s auf ihrem Gerät installieren und wie jede andere App " -"benutzen!" +"Sie können %(platform_name)s auf ihrem Gerät installieren und wie jede " +"andere App benutzen!" #: ephios/templates/base.html:206 msgid "Simply click the button below to continue." @@ -5374,12 +5427,13 @@ msgid "Activate notifications" msgstr "Benachrichtigungen aktivieren" #: ephios/templates/base.html:274 +#, python-format msgid "" "%(platform_name)s can send you push notifications so that you stay up to " "date. Do you want to activate them?\"" msgstr "" -"%(platform_name)s kann Ihnen Push-Benachrichtigungen senden, damit Sie auf dem " -"aktuellen Stand bleiben. Möchten Sie Benachrichtigungen aktivieren?" +"%(platform_name)s kann Ihnen Push-Benachrichtigungen senden, damit Sie auf " +"dem aktuellen Stand bleiben. Möchten Sie Benachrichtigungen aktivieren?" #: ephios/templates/offline.html:10 msgid "No connection" @@ -5439,6 +5493,78 @@ msgstr "Passwort erfolgreich zurückgesetzt" msgid "Please check your mailbox for further instructions." msgstr "Bitte prüfen Sie Ihren E-Mail-Posteingang für weitere Anweisungen." +#~ msgid "Mass mailing" +#~ msgstr "Massenbenachrichtigung" + +#~ msgid "" +#~ "Use this form to craft and send a mass notification to your selected " +#~ "recipients. You can specify the message subject and body and adjust the " +#~ "recipients to your needs." +#~ msgstr "" +#~ "Mit diesem Formular können Sie eine Nachricht an die ausgewählten " +#~ "Empfänger versenden. " + +#~ msgid "" +#~ "Use this form to craft and send a mass notification to your selected " +#~ "recipients. You can specify the message subject and body. Recipients can " +#~ "be chosen based on their attendance status. The list of recipients can be " +#~ "tweaked to your needs." +#~ msgstr "" +#~ "Mit diesem Formular können Sie eine Nachricht an mehrere Nutzer " +#~ "versenden. Neben Betreff und Inhalt können Empfänger basierend auf ihrem " +#~ "Teilnahmestatus in der Veranstaltung gewählt werden." + +#~ msgid "Show recipient list" +#~ msgstr "Empfängerliste anzeigen" + +#~ msgid "Send notification about new event to everyone" +#~ msgstr "Alle Benutzer über die neue Veranstaltung benachrichtigen" + +#~ msgid "Send reminder to everyone that is not participating" +#~ msgstr "Alle nicht teilnehmenden Benutzer an die Veranstaltung erinnern" + +#~ msgid "Mail content" +#~ msgstr "Nachricht" + +#~ msgid "You cannot send an empty mail." +#~ msgstr "Sie können keine leere Nachricht versenden." + +#, python-brace-format +#~ msgid "Notification type '{slug}' was not found." +#~ msgstr "Benachrichtigungstyp '{slug}' wurde nicht gefunden." + +#~ msgid "A new event has been added" +#~ msgstr "Eine neue Veranstaltung wurde hinzugefügt" + +#, python-brace-format +#~ msgid "New {type}: {title}" +#~ msgstr "{type} hinzugefügt: {title}" + +#, python-brace-format +#~ msgid "" +#~ "A new {type} ({title}, {location}) has been added.\n" +#~ "Further information: {description}" +#~ msgstr "" +#~ "{type} {title} ({location}) wurde hinzugefügt.\n" +#~ " Weitere Informationen: {description}" + +#~ msgid "An event has vacant spots" +#~ msgstr "Für eine Veranstaltung werden noch Teilnehmende gesucht" + +#, python-brace-format +#~ msgid "Help needed for {title}" +#~ msgstr "Unterstützung benötigt für {title}" + +#, python-brace-format +#~ msgid "Your support is needed for {title} ({start} - {end})." +#~ msgstr "Ihre Unterstützung wird benötigt für {title} ({start} - {end})." + +#~ msgid "Message to all participants" +#~ msgstr "Nachricht an alle Teilnehmenden" + +#~ msgid "Notifications sent succesfully." +#~ msgstr "Benachrichtigungen erfolgreich gesendet." + #~ msgid "You already registered as a guest for this event." #~ msgstr "Sie sind bereits als Gast für diese Veranstaltung registriert." @@ -5608,9 +5734,6 @@ msgstr "Bitte prüfen Sie Ihren E-Mail-Posteingang für weitere Anweisungen." #~ msgstr "" #~ "Relevante Qualifikationskategorien (für Benutzliste und Disposition)" -#~ msgid "Information" -#~ msgstr "Information" - #~ msgid "View working hours" #~ msgstr "Arbeitsstunden anzeigen" @@ -5625,9 +5748,6 @@ msgstr "Bitte prüfen Sie Ihren E-Mail-Posteingang für weitere Anweisungen." #~ msgid "Notification types" #~ msgstr "Benachrichtigungen" -#~ msgid "Push notifications" -#~ msgstr "Push-Benachrichtigungen" - #~ msgid "" #~ "You can manage the occasions you want to receive push notificaions for " #~ "above. You can manage if you want to receive push notifications on this " @@ -5749,9 +5869,6 @@ msgstr "Bitte prüfen Sie Ihren E-Mail-Posteingang für weitere Anweisungen." #~ msgid "here" #~ msgstr "hier" -#~ msgid "Receive notifications for a confirmed participation" -#~ msgstr "Benachrichtigungen für bestätigte Teilnahmen erhalten" - #~ msgid "Receive notifications for a rejected participation" #~ msgstr "Benachrichtigungen für abgelehnte Teilnahmen erhalten" 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/event_notifications.js b/ephios/static/ephios/js/event_notifications.js deleted file mode 100644 index 34d6d26ee..000000000 --- a/ephios/static/ephios/js/event_notifications.js +++ /dev/null @@ -1,11 +0,0 @@ -$(document).ready(function () { - function handleMessageContentField(event) { - const duration = event? 400 :0; - if ($("input:radio[name ='action']:checked").val() === "participants") - $("#div_id_mail_content").slideDown(duration); - else - $("#div_id_mail_content").slideUp(duration); - } - $("input:radio[name ='action']").change(handleMessageContentField); - handleMessageContentField(); -}); diff --git a/ephios/static/ephios/js/main.js b/ephios/static/ephios/js/main.js index 475ad5d9b..90089869e 100644 --- a/ephios/static/ephios/js/main.js +++ b/ephios/static/ephios/js/main.js @@ -102,8 +102,13 @@ $(document).ready(function () { $('.cb-element').change(function () { if ($('.cb-element:checked').length === $('.cb-element').length) { $('#checkall').prop('checked', true); - } else { + $('#checkall').prop('indeterminate', false); + } else if ($('.cb-element:checked').length === 0) { $('#checkall').prop('checked', false); + $('#checkall').prop('indeterminate', false); + } else { + $('#checkall').prop('checked', true); + $('#checkall').prop('indeterminate', true); } }); 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..4cb4f9cf2 --- /dev/null +++ b/ephios/static/ephios/js/mass_notification_write.js @@ -0,0 +1,45 @@ +$(document).ready(function () { + const jSelect = $("#id_to_participants"); + + jSelect.select2({ + closeOnSelect: false + } + ) + + document.getElementById("id_to_participants").addEventListener("invalid", (event) => { + new bootstrap.Collapse('#collapseToParticipants').show(); + }); + + Array.from(document.getElementsByClassName("check-add-recipients")).forEach(check => { + check.addEventListener("click", function (e) { + const namesToSelect = check.dataset.participants.trim().split(" "); + if (check.checked) { + jSelect.val(jSelect.val().concat(namesToSelect)).trigger('change'); + } else { + jSelect.val(jSelect.val().filter(item => { + // keep items not in namesToSelect + return namesToSelect.indexOf(item) < 0; + })).trigger('change'); + } + }); + }); + jSelect.on("change", function (e) { + Array.from(document.getElementsByClassName("check-add-recipients")).forEach(check => { + const namesToSelect = check.dataset.participants.trim().split(" "); + const namesSelected = jSelect.val().filter(item => { + return namesToSelect.indexOf(item) >= 0; + }); + if (namesSelected.length === 0) { + check.indeterminate = false; + check.checked = false; + } else if (namesSelected.length === namesToSelect.length) { + check.indeterminate = false; + check.checked = true; + } else { + check.indeterminate = true; + check.checked = true; + } + }); + }); + +}); diff --git a/ephios/static/ephios/scss/ephios_custom.scss b/ephios/static/ephios/scss/ephios_custom.scss index a25fc9cd4..bf188ed6a 100644 --- a/ephios/static/ephios/scss/ephios_custom.scss +++ b/ephios/static/ephios/scss/ephios_custom.scss @@ -355,3 +355,16 @@ $shift-box-target: lighten($primary, 20%); .comment-visibility-icon { min-width: 1.5rem; } + +// collapsable card +.card-header[data-bs-toggle="collapse"] a { + text-decoration: none; + } +.card-header[data-bs-toggle="collapse"] { + transition: border-bottom-width 0.2s linear; +} +.card-header[data-bs-toggle="collapse"].collapsed { + border-bottom-width: 0; + background-color: transparent; +} + diff --git a/ephios/static/select2-bootstrap-5-theme/select2-bootstrap-5-theme.scss b/ephios/static/select2-bootstrap-5-theme/select2-bootstrap-5-theme.scss index 1768f6551..04e318c5b 100644 --- a/ephios/static/select2-bootstrap-5-theme/select2-bootstrap-5-theme.scss +++ b/ephios/static/select2-bootstrap-5-theme/select2-bootstrap-5-theme.scss @@ -6,4 +6,6 @@ @import "../bootstrap/scss/variables"; @import "../bootstrap/scss/mixins"; +$s2bs5-group-color: darken($secondary, 50%); + @import "include-all"; diff --git a/tests/core/test_event_notifications.py b/tests/core/test_event_notifications.py deleted file mode 100644 index d34f4a4f4..000000000 --- a/tests/core/test_event_notifications.py +++ /dev/null @@ -1,80 +0,0 @@ -from django.urls import reverse -from guardian.shortcuts import get_users_with_perms - -from ephios.core.forms.events import EventNotificationForm -from ephios.core.models import AbstractParticipation, LocalParticipation, Notification, UserProfile - - -class TestEventNotifications: - def test_mail_new_event(self, django_app, event, volunteer, planner, groups): - form = django_app.get( - reverse("core:event_notifications", kwargs=dict(pk=event.id)), user=planner - ).form - form["action"] = EventNotificationForm.NEW_EVENT - response = form.submit() - assert response.status_code == 302 - assert ( - Notification.objects.count() - == get_users_with_perms(event, only_with_perms_in=["view_event"]).count() - ) - - def test_mail_reminder(self, django_app, event, volunteer, planner, groups): - form = django_app.get( - reverse("core:event_notifications", kwargs=dict(pk=event.id)), user=planner - ).form - form["action"] = EventNotificationForm.REMINDER - response = form.submit() - assert response.status_code == 302 - 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"])) - assert Notification.objects.count() == users_not_participating.count() - - def test_mail_participants(self, django_app, event, volunteer, planner, groups): - LocalParticipation.objects.create( - shift=event.shifts.first(), - user=volunteer, - state=AbstractParticipation.States.CONFIRMED, - ) - form = django_app.get( - reverse("core:event_notifications", kwargs=dict(pk=event.id)), user=planner - ).form - form["action"] = EventNotificationForm.PARTICIPANTS - form["mail_content"] = "hey there" - response = form.submit() - assert response.status_code == 302 - assert Notification.objects.count() == 1 + len( - get_users_with_perms(event, only_with_perms_in=["change_event"]) - ) - - def test_mail_participants_content_required( - self, django_app, event, volunteer, planner, groups - ): - form = django_app.get( - reverse("core:event_notifications", kwargs=dict(pk=event.id)), user=planner - ).form - form["action"] = EventNotificationForm.PARTICIPANTS - response = form.submit() - assert response.status_code == 200 - assert Notification.objects.count() == 0 - - def test_participants_mail_is_not_duplicated_for_participating_responsible( - self, django_app, event, planner, groups - ): - LocalParticipation.objects.create( - shift=event.shifts.first(), - user=planner, - state=AbstractParticipation.States.CONFIRMED, - ) - form = django_app.get( - reverse("core:event_notifications", kwargs=dict(pk=event.id)), user=planner - ).form - form["action"] = EventNotificationForm.PARTICIPANTS - form["mail_content"] = "hey Franz was here" - form.submit() - number_of_responsible_users = len( - get_users_with_perms(event, only_with_perms_in=["change_event"]) - ) - assert Notification.objects.count() == number_of_responsible_users diff --git a/tests/core/test_notification_mass.py b/tests/core/test_notification_mass.py new file mode 100644 index 000000000..7c8aaaa52 --- /dev/null +++ b/tests/core/test_notification_mass.py @@ -0,0 +1,45 @@ +from django.urls import reverse + +from ephios.core.models import AbstractParticipation, LocalParticipation, Notification +from ephios.core.models.events import PlaceholderParticipation + + +def test_mass_notification_not_without_perm(django_app, event, planner, volunteer): + django_app.get( + f"{reverse('core:notification_mass')}?event_id={event.pk}", user=volunteer, status=403 + ) + django_app.get(reverse("core:notification_mass"), user=planner, status=403) + + +def test_event_mass_notification(django_app, event, volunteer, planner): + LocalParticipation.objects.create( + shift=event.shifts.first(), + user=volunteer, + state=AbstractParticipation.States.CONFIRMED, + ) + PlaceholderParticipation.objects.create( + shift=event.shifts.first(), + display_name="No-Email Participant", + state=AbstractParticipation.States.CONFIRMED, + ) + LocalParticipation.objects.create( + shift=event.shifts.first(), + user=planner, + state=AbstractParticipation.States.REQUESTED, + ) + response = django_app.get(event.get_absolute_url(), user=planner).click("Send notifications") + form = response.form + form["body"] = "Please remember to bring warm tea" + form["to_participants"] = [volunteer.as_participant().identifier] + form.submit().follow() + assert Notification.objects.count() == 1 + + +def test_mass_notification(django_app, groups, volunteer, manager): + form = django_app.get(reverse("core:notification_mass"), user=manager).form + form["subject"] = "A message from the manager" + form["body"] = "Thank you for your support!" + form["to_participants"] = [volunteer.as_participant().identifier] + response = form.submit() + response.follow() + assert Notification.objects.count() == 1 diff --git a/tests/core/test_notifications.py b/tests/core/test_notifications.py index 2e79ab26c..30057634a 100644 --- a/tests/core/test_notifications.py +++ b/tests/core/test_notifications.py @@ -10,9 +10,6 @@ NOTIFICATION_READ_PARAM_NAME, ConsequenceApprovedNotification, ConsequenceDeniedNotification, - CustomEventParticipantNotification, - EventReminderNotification, - NewEventNotification, NewProfileNotification, ParticipationCustomizationNotification, ParticipationStateChangeNotification, @@ -34,11 +31,13 @@ def test_notification_form_render(django_app, volunteer): def test_notification_form_submit(django_app, volunteer): form = django_app.get(reverse("core:settings_notifications"), user=volunteer).form - form["ephios_new_event"] = ["ephios_backend_email"] + form["ephios_participation_awaits_disposition"] = ["ephios_backend_email"] form.submit() assert ( "ephios_backend_email" - in volunteer.preferences["notifications__notifications"]["ephios_new_event"] + in volunteer.preferences["notifications__notifications"][ + "ephios_participation_awaits_disposition" + ] ) @@ -50,16 +49,6 @@ def test_user_notification_sending(volunteer): assert not Notification.objects.filter(processing_completed=False).exists() -def test_event_notification_sending(event, volunteer): - NewEventNotification.send(event) - EventReminderNotification.send(event) - assert Notification.objects.count() == 2 * len( - get_users_with_perms(event, only_with_perms_in=["view_event"]) - ) - call_command("send_notifications") - assert not Notification.objects.filter(processing_completed=False).exists() - - def test_participation_notification_sending(event, qualified_volunteer): participation = LocalParticipation.objects.create( shift=event.shifts.first(), @@ -180,17 +169,6 @@ def test_consequence_notifications(volunteer, workinghours_consequence): assert not Notification.objects.filter(processing_completed=False).exists() -def test_responsibles_receive_custom_notification(django_app, qualified_volunteer, planner, event): - participation = LocalParticipation.objects.create( - shift=event.shifts.first(), - user=qualified_volunteer, - state=AbstractParticipation.States.CONFIRMED, - ) - CustomEventParticipantNotification.send(event, "test notification") - assert planner.has_perm("change_event", event) - assert Notification.objects.get(user=planner) - - def test_middleware_marks_notification_as_read(django_app, qualified_volunteer, planner, event): participation = LocalParticipation.objects.create( shift=event.shifts.first(),