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 @@