diff --git a/CHANGELOG.md b/CHANGELOG.md index 696861b21..80b96a9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ This project (not yet) adheres to [Semantic Versioning](https://semver.org/spec/ ### Added +- Management command ``send_newsletter_settings_notice`` plus an English/German + notice email to inform users whose newsletter opt-in may have been reset by + the profile-edit bug. Recipients are the refined candidate pool; every sent + user is appended with a timestamp to ``/var/log/django/newsletter_settings_notice.log``, + so re-runs and staged rollouts (``--limit``) never send twice. Supports + ``--dry-run``, ``--log-file`` and ``--to`` (single test copy to a chosen + account) - Organisation page: follow button to get notified about new projects - Notifications: organisation followers receive email and in-app notification when a project is published - Project detail and module pages: alert for guest users on registered-users-only projects to register or log in with a personal account @@ -40,6 +47,23 @@ This project (not yet) adheres to [Semantic Versioning](https://semver.org/spec/ ### Fixed +- Profile editing no longer silently clears the newsletter opt-in + (``User.get_newsletters``). The newsletter toggle on the notification + settings page now controls this actual opt-in. +- Periodic notifications: the project started/completed and event reminder + tasks now use configurable coverage windows (``NOTIFICATION_PROJECT_STARTED_HOURS``, + ``NOTIFICATION_PROJECT_COMPLETED_HOURS``, ``NOTIFICATION_EVENT_STARTING_HOURS``, + default 72h) and deduplicate, so each project/event is notified exactly once. + Previously the tasks ran every 3 days with a 24h window, so most project + start/completion emails and short-notice event reminders were never sent; + the schedule now runs daily, mirroring adhocracy4's ``create_system_actions`` + idempotency. +- Guest account conversion: delete the Guest row immediately when a guest + converts to a regular account (previously only on email confirmation). + A leftover Guest row made the platform treat converted users as guests and + excluded them from all notification emails. Added management command + ``delete_converted_guest_rows`` to clean up stale Guest rows of users who + already converted. - tests/organisations: fix ``test_initiator_can_update`` assertion to use ``switch_language`` context manager (django-parler behaviour change caused the default-language description to return the primary language value) @@ -118,7 +142,7 @@ This project (not yet) adheres to [Semantic Versioning](https://semver.org/spec/ - Improvements to pagination - Footer: New Footer Module -- Hierarchical platform breadcrumbs +- Hierarchical platform breadcrumbs - Design: Update Colour Pallette - Design: New Landing Page @@ -151,7 +175,7 @@ This project (not yet) adheres to [Semantic Versioning](https://semver.org/spec/ ## v2602.2 -### Added +### Added - apps/projects: export_utils for exporting project data diff --git a/adhocracy-plus/config/settings/base.py b/adhocracy-plus/config/settings/base.py index ff8dc503b..f2d234329 100644 --- a/adhocracy-plus/config/settings/base.py +++ b/adhocracy-plus/config/settings/base.py @@ -530,6 +530,23 @@ ("a4_candy_budgeting", "proposal"), ) +# Coverage windows (in hours) for the periodic system notifications. The beat +# schedule runs the tasks daily and notifications are only created once per +# project/event, so the effective timing is determined by the daily cadence: +# +# - PROJECT_STARTED_HOURS / PROJECT_COMPLETED_HOURS are LOOKBACK margins. +# Projects are notified at the first daily run after their start/end date, +# i.e. within ~24h of the event; the margin only catches backdated phases +# or missed runs. +# - EVENT_STARTING_HOURS is a LOOKAHEAD margin. Events are reminded at the +# first daily run after they enter the window, i.e. roughly 48-72h before +# the event (less for events announced shortly before). +# +# Mirrors adhocracy4's create_system_actions configuration. +NOTIFICATION_PROJECT_STARTED_HOURS = 72 +NOTIFICATION_PROJECT_COMPLETED_HOURS = 72 +NOTIFICATION_EVENT_STARTING_HOURS = 72 + A4_CATEGORIZABLE = ( ("a4_candy_ideas", "idea"), ("a4_candy_mapideas", "mapidea"), diff --git a/adhocracy-plus/config/settings/docker.py b/adhocracy-plus/config/settings/docker.py index f80a5aac3..14d48a193 100644 --- a/adhocracy-plus/config/settings/docker.py +++ b/adhocracy-plus/config/settings/docker.py @@ -27,15 +27,20 @@ }, "send-recently-started-project-notifications": { "task": "send_recently_started_project_notifications", - "schedule": timedelta(days=3), + # Coverage window is NOTIFICATION_PROJECT_STARTED_HOURS (default 72h); + # the tasks deduplicate, so frequent runs are safe and each project is + # notified exactly once. + "schedule": timedelta(days=1), }, "send-recently-completed-project-notifications": { "task": "send_recently_completed_project_notifications", - "schedule": timedelta(days=3), + # Coverage window is NOTIFICATION_PROJECT_COMPLETED_HOURS (default 72h). + "schedule": timedelta(days=1), }, "send_upcoming-event-notifications": { "task": "send_upcoming_event_notifications", - "schedule": timedelta(days=3), + # Coverage window is NOTIFICATION_EVENT_STARTING_HOURS (default 72h). + "schedule": timedelta(days=1), }, "refresh_project_summaries": { "task": "refresh_project_summaries", diff --git a/adhocracy-plus/config/settings/production.py b/adhocracy-plus/config/settings/production.py index 6103473d6..0df54dd20 100644 --- a/adhocracy-plus/config/settings/production.py +++ b/adhocracy-plus/config/settings/production.py @@ -42,14 +42,19 @@ }, "send-recently-started-project-notifications": { "task": "send_recently_started_project_notifications", - "schedule": timedelta(days=3), + # Coverage window is NOTIFICATION_PROJECT_STARTED_HOURS (default 72h); + # the tasks deduplicate, so frequent runs are safe and each project is + # notified exactly once. + "schedule": timedelta(days=1), }, "send-recently-completed-project-notifications": { "task": "send_recently_completed_project_notifications", - "schedule": timedelta(days=3), + # Coverage window is NOTIFICATION_PROJECT_COMPLETED_HOURS (default 72h). + "schedule": timedelta(days=1), }, "send_upcoming-event-notifications": { "task": "send_upcoming_event_notifications", - "schedule": timedelta(days=3), + # Coverage window is NOTIFICATION_EVENT_STARTING_HOURS (default 72h). + "schedule": timedelta(days=1), }, } diff --git a/apps/account/forms.py b/apps/account/forms.py index 0db0e5ac1..d6aa47f4c 100644 --- a/apps/account/forms.py +++ b/apps/account/forms.py @@ -17,7 +17,6 @@ class Meta: "homepage", "facebook_handle", "twitter_handle", - "get_newsletters", "language", ] diff --git a/apps/account/views.py b/apps/account/views.py index 2cca938e6..c04a47368 100644 --- a/apps/account/views.py +++ b/apps/account/views.py @@ -12,6 +12,8 @@ from guest_user.functions import is_guest_user from guest_user.mixins import GuestUserRequiredMixin from guest_user.mixins import RegularUserRequiredMixin +from guest_user.models import Guest +from guest_user.signals import converted from guest_user.views import ConvertFormView from apps.users.forms import GuestConvertForm @@ -70,6 +72,12 @@ def get_form_kwargs(self): def form_valid(self, form): user = form.save(self.request) + # Delete the guest marker immediately, as guest_user's own convert flow + # does. Waiting for the email_confirmed signal instead would leave the + # user flagged as guest (and thus excluded from all notification + # emails) until they confirm their email address. + Guest.objects.filter(user=user).delete() + converted.send(Guest.objects, user=user) response = complete_signup( self.request, user, diff --git a/apps/newsletters/emails.py b/apps/newsletters/emails.py index b998e19e1..ee12230d2 100644 --- a/apps/newsletters/emails.py +++ b/apps/newsletters/emails.py @@ -1,3 +1,5 @@ +from django.urls import reverse + from apps.users.emails import EmailAplus as Email from apps.users.models import User @@ -40,3 +42,23 @@ def get_context(self): class NewsletterEmailAll(NewsletterEmail): def get_receivers(self): return User.objects.filter(is_active=True).distinct() + + +class NewsletterSettingsNoticeEmail(Email): + """One-time service email informing users that their newsletter opt-in + may have been reset by a bug. Sent regardless of the current opt-in.""" + + template_name = "a4_candy_newsletters/emails/newsletter_settings_notice" + + def __init__(self, user): + self._user = user + + def get_receivers(self): + return [self._user] + + def get_context(self): + context = super().get_context() + context["settings_url"] = self.get_host() + reverse( + "account_notification_settings" + ) + return context diff --git a/apps/newsletters/management/commands/send_newsletter_settings_notice.py b/apps/newsletters/management/commands/send_newsletter_settings_notice.py new file mode 100644 index 000000000..63c2e87b4 --- /dev/null +++ b/apps/newsletters/management/commands/send_newsletter_settings_notice.py @@ -0,0 +1,117 @@ +import os +from datetime import datetime + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.newsletters.emails import NewsletterSettingsNoticeEmail + +User = get_user_model() + +# v2601.1 (2026-01-22) shipped the change that reset the newsletter opt-in +# whenever a user saved their profile. Users who were logged in after that +# date could plausibly have been affected, but only those currently opted out +# are candidates. +BUG_DATE = timezone.make_aware(datetime(2026, 1, 22)) + +DEFAULT_LOG_FILE = "/var/log/django/newsletter_settings_notice.log" + + +class Command(BaseCommand): + help = ( + "Send a one-time notice email to users whose newsletter opt-in may " + "have been reset by a bug. Recipients are the refined candidate pool: " + "currently opted out, active, and logged in since the bug shipped. " + "Guest accounts are excluded. Every recipient is appended to a log " + "file, so re-runs are idempotent and never send twice." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Only print the number of recipients; do not send emails.", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Send to at most this many users (useful for staged rollouts). " + "Sent users are logged and skipped on later runs.", + ) + parser.add_argument( + "--to", + dest="to_email", + default=None, + help="Send a single test copy to this email address instead of " + "the candidate pool (nothing is logged).", + ) + parser.add_argument( + "--log-file", + dest="log_file", + default=DEFAULT_LOG_FILE, + help=f"File to append sent recipients to (default: {DEFAULT_LOG_FILE}).", + ) + + def handle(self, *args, **options): + if options["to_email"]: + self._send_test_copy(options["to_email"]) + return + + dry_run = options["dry_run"] + limit = options["limit"] + log_file = options["log_file"] + + already_sent = self._load_sent_emails(log_file) + + candidates = ( + User.objects.filter( + get_newsletters=False, + is_active=True, + last_login__gte=BUG_DATE, + ) + .exclude(email__startswith="guest+") + .exclude(email__in=already_sent) + .order_by("pk") + ) + count = candidates.count() + if limit: + candidates = candidates[:limit] + + verb = "would send" if dry_run else "sending" + self.stdout.write(f"{verb} notice email to {count} user(s)") + + if dry_run: + return + + if count: + os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True) + + sent = 0 + with open(log_file, "a") as log: + for user in candidates.iterator(): + NewsletterSettingsNoticeEmail(user).dispatch(user) + log.write(f"{timezone.now():%Y-%m-%d %H:%M:%S} {user.email}\n") + sent += 1 + + self.stdout.write(self.style.SUCCESS(f"done ({sent} sent)")) + + def _load_sent_emails(self, path): + """Return the set of email addresses already logged as sent.""" + sent = set() + if os.path.exists(path): + with open(path) as f: + for line in f: + line = line.strip() + if line: + sent.add(line.split()[-1]) + return sent + + def _send_test_copy(self, email): + user = User.objects.filter(email__iexact=email).first() + if not user: + self.stderr.write(f"No user with email {email}") + return + NewsletterSettingsNoticeEmail(user).dispatch(user) + self.stdout.write(self.style.SUCCESS(f"test copy sent to {email}")) diff --git a/apps/newsletters/templates/a4_candy_newsletters/emails/newsletter_settings_notice.en.email b/apps/newsletters/templates/a4_candy_newsletters/emails/newsletter_settings_notice.en.email new file mode 100644 index 000000000..66cdaa478 --- /dev/null +++ b/apps/newsletters/templates/a4_candy_newsletters/emails/newsletter_settings_notice.en.email @@ -0,0 +1,22 @@ +{% extends 'email_base.'|add:part_type %} +{% load i18n %} + +{% block headline %}{% translate "Update on your newsletter settings" %}{% endblock %} + +{% block subject %}{% translate "Update on your newsletter settings" %}{% endblock %} + +{% block content %} +
+{% blocktranslate %}Organisations on adhocracy plus send newsletters to their followers. Due to an issue, your user setting that controls whether you receive newsletters from organisations may have been reset.{% endblocktranslate %} +
++{% blocktranslate %}You are currently opted out. If you wish to receive newsletters from the organisations you subscribe to, visit your user settings and enable this setting.{% endblocktranslate %} +
++{% translate "Your notification settings" %} +
+{% endblock %} + +{% block reason %} +{% blocktranslate with receiver_mail=receiver.email site_name=site.name %}This email was sent to {{ receiver_mail }} because you are registered on {{ site_name }}. You receive this email because the newsletter setting on your account may have been reset.{% endblocktranslate %} +{% endblock %} diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py index 051d3195f..eb7a1d890 100644 --- a/apps/notifications/admin.py +++ b/apps/notifications/admin.py @@ -32,7 +32,6 @@ class NotificationAdmin(admin.ModelAdmin): class NotificationSettingsAdmin(admin.ModelAdmin): list_display = ("id", "user", "email_moderation", "notify_moderation") list_filter = ( - "email_newsletter", "email_initiator_publish_results", "email_project_updates", "notify_project_updates", diff --git a/apps/notifications/forms.py b/apps/notifications/forms.py index a3a89da20..1a8038dfd 100644 --- a/apps/notifications/forms.py +++ b/apps/notifications/forms.py @@ -1,15 +1,23 @@ from django import forms +from django.utils.translation import gettext_lazy as _ from .models import NotificationSettings class NotificationSettingsForm(forms.ModelForm): + # Newsletter opt-in lives on the User model (User.get_newsletters) and is + # read by the newsletter sender. It must NOT default to True: this field + # only reflects and changes the user's explicit choice. + get_newsletters = forms.BooleanField( + label=_("Email Newsletter"), + required=False, + ) + class Meta: model = NotificationSettings fields = [ # Project related "email_initiator_publish_results", - "email_newsletter", "email_project_updates", "notify_project_updates", "email_project_events", @@ -34,6 +42,13 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields["get_newsletters"].initial = self.instance.user.get_newsletters for field_name, field in self.fields.items(): if isinstance(field, forms.BooleanField): field.widget.attrs.update({"class": "form-check-input"}) + + def save(self, commit=True): + user = self.instance.user + user.get_newsletters = self.cleaned_data["get_newsletters"] + user.save() + return super().save(commit) diff --git a/apps/notifications/migrations/0005_notification_project.py b/apps/notifications/migrations/0005_notification_project.py new file mode 100644 index 000000000..0e52c7480 --- /dev/null +++ b/apps/notifications/migrations/0005_notification_project.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.15 on 2026-08-04 08:47 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("a4_candy_notifications", "0004_alter_notification_notification_type"), + ("a4projects", "0055_alter_project_allow_guest_users_default"), + ] + + operations = [ + migrations.AddField( + model_name="notification", + name="project", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="a4projects.project", + verbose_name="Project", + ), + ), + ] diff --git a/apps/notifications/migrations/0006_remove_notificationsettings_email_newsletter.py b/apps/notifications/migrations/0006_remove_notificationsettings_email_newsletter.py new file mode 100644 index 000000000..855203205 --- /dev/null +++ b/apps/notifications/migrations/0006_remove_notificationsettings_email_newsletter.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.15 on 2026-08-04 16:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("a4_candy_notifications", "0005_notification_project"), + ] + + operations = [ + migrations.RemoveField( + model_name="notificationsettings", + name="email_newsletter", + ), + ] diff --git a/apps/notifications/models.py b/apps/notifications/models.py index f096e2197..2bb0b87c1 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -119,7 +119,6 @@ class NotificationCategory: NotificationCategory.INVITATIONS: ("email_invitations", "notify_invitations"), NotificationCategory.MODERATION: ("email_moderation", "notify_moderation"), NotificationCategory.WARNINGS: ("email_warnings", "notify_warnings"), - "newsletter": ("email_newsletter", None), # No in-app for newsletter "system": (None, None), # Always deliver system notifications } @@ -141,11 +140,6 @@ def get_for_user(cls, user): related_name="notification_settings", ) - # Project related - Email newsletter - email_newsletter = models.BooleanField( - default=True, verbose_name=_("Email newsletter") - ) - # Initiator-only e-mails email_initiator_publish_results = models.BooleanField( default=True, @@ -243,7 +237,6 @@ def get_email_fields(self): "email_project_updates", "email_project_events", "email_user_engagement", - "email_newsletter", ] def get_notification_fields(self): @@ -290,6 +283,14 @@ class Notification(models.Model): choices=NotificationType.choices, verbose_name=_("Notification Type"), ) + project = models.ForeignKey( + "a4projects.Project", + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="+", + verbose_name=_("Project"), + ) read = models.BooleanField(default=False) read_at = models.DateTimeField(null=True, blank=True) created = models.DateTimeField(auto_now_add=True) diff --git a/apps/notifications/services.py b/apps/notifications/services.py index c7c85a5ce..375b15c49 100644 --- a/apps/notifications/services.py +++ b/apps/notifications/services.py @@ -6,6 +6,7 @@ from django.utils.safestring import mark_safe from guest_user.functions import is_guest_user +from adhocracy4.projects.models import Project from apps.users.emails import EmailAplus as Email from .models import NOTIFICATION_TYPE_MAPPING @@ -142,6 +143,7 @@ def create_notifications(obj, strategy) -> None: # Remove email_context before creating notifications notification_data.pop("email_context", None) notification_data.pop("translated_message_template", None) + notification_data["project"] = NotificationService._get_project(obj) # Create in-app notifications notifications = [ @@ -152,6 +154,26 @@ def create_notifications(obj, strategy) -> None: if notifications: Notification.objects.bulk_create(notifications) + @staticmethod + def _get_project(obj): + """Best-effort lookup of the project a notification belongs to.""" + if isinstance(obj, Project): + # Some notifications are created from a post_delete signal (e.g. + # project deletion), when the project row no longer exists. + if Project.objects.filter(pk=obj.pk).exists(): + return obj + return None + project = getattr(obj, "project", None) + if project is None: + content_object = getattr(obj, "content_object", None) + if content_object is not None: + project = getattr(content_object, "project", None) + if project is None: + comment = getattr(obj, "comment", None) + if comment is not None: + project = getattr(comment, "project", None) + return project + @staticmethod def _get_filtered_recipients(all_recipients, notification_type): """ diff --git a/apps/notifications/strategies/event_strategies.py b/apps/notifications/strategies/event_strategies.py index c8f81509f..7e16e0791 100644 --- a/apps/notifications/strategies/event_strategies.py +++ b/apps/notifications/strategies/event_strategies.py @@ -145,6 +145,8 @@ def create_notification_data(self, offline_event): "event_url": offline_event.get_absolute_url(), "event_date": format_event_date(offline_event.date), }, + # Used as dedup key by the periodic task: each event is notified once. + "target_url": offline_event.get_absolute_url(), "email_context": email_context, } diff --git a/apps/notifications/tasks.py b/apps/notifications/tasks.py index 598b1eacb..a6c1ecc9b 100644 --- a/apps/notifications/tasks.py +++ b/apps/notifications/tasks.py @@ -1,25 +1,61 @@ from datetime import timedelta from celery import shared_task +from django.conf import settings from django.db.models import Q from django.utils import timezone from adhocracy4.phases.models import Phase from apps.offlineevents.models import OfflineEvent +from .models import Notification +from .models import NotificationType from .services import NotificationService from .strategies import OfflineEventReminder from .strategies import ProjectEnded from .strategies import ProjectStarted +def _hours(setting_name, default): + """Read a notification window setting in hours (default 72).""" + return getattr(settings, setting_name, default) + + +def _project_already_notified(project, notification_type) -> bool: + """True if a notification of this type already exists for the project. + + Mirrors the idempotency of adhocracy4's ``create_system_actions`` + management command: the first run within the coverage window creates the + notification (and sends the email), later runs skip it so every project + is only notified once. + """ + return Notification.objects.filter( + project=project, notification_type=notification_type + ).exists() + + +def _event_already_notified(event) -> bool: + """True if an upcoming-event notification already exists for the event.""" + return Notification.objects.filter( + notification_type=NotificationType.EVENT_SOON, + target_url=event.get_absolute_url(), + ).exists() + + @shared_task(name="send_recently_started_project_notifications") def send_recently_started_project_notifications(): """ - Send notifications to project followers for project started + Notify followers that a project's first phase has started. + + Effective timing: the task runs daily and looks back + NOTIFICATION_PROJECT_STARTED_HOURS (default 72h), so a project is notified + at the first run after its start date - i.e. within ~24h of the start. The + lookback is a safety margin so backdated starts or missed runs are still + caught. Deduplication guarantees each project is notified exactly once. """ now = timezone.now() - last_check = now - timedelta(hours=24) + window = timedelta(hours=_hours("NOTIFICATION_PROJECT_STARTED_HOURS", 72)) + last_check = now - window started_phases = Phase.objects.filter( Q(start_date__gte=last_check, start_date__lte=now) @@ -38,6 +74,8 @@ def send_recently_started_project_notifications(): strategy = ProjectStarted() for project in started_projects: + if _project_already_notified(project, NotificationType.PROJECT_STARTED): + continue NotificationService.create_notifications(project, strategy) return len(started_projects) @@ -54,10 +92,17 @@ def is_last_phase_in_project(phase): @shared_task(name="send_recently_completed_project_notifications") def send_recently_completed_project_notifications(): """ - Send notifications to project followers for project completed + Notify followers that a project has been completed. + + Effective timing: the task runs daily and looks back + NOTIFICATION_PROJECT_COMPLETED_HOURS (default 72h), so a project is + notified at the first run after its last phase ends - i.e. within ~24h of + the end. The lookback is a safety margin so missed runs are still caught. + Deduplication guarantees each project is notified exactly once. """ now = timezone.now() - last_check = now - timedelta(hours=24) + window = timedelta(hours=_hours("NOTIFICATION_PROJECT_COMPLETED_HOURS", 72)) + last_check = now - window completed_phases = Phase.objects.filter( Q(end_date__gte=last_check, end_date__lte=now) @@ -68,6 +113,8 @@ def send_recently_completed_project_notifications(): ] strategy = ProjectEnded() for project in ended_projects: + if _project_already_notified(project, NotificationType.PROJECT_COMPLETED): + continue NotificationService.create_notifications(project, strategy) return @@ -76,11 +123,17 @@ def send_recently_completed_project_notifications(): @shared_task(name="send_upcoming_event_notifications") def send_upcoming_event_notifications(): """ - Send notifications to project followers for events starting within 24 hours + Remind followers of events starting soon. + + Effective timing: the task runs daily and looks ahead + NOTIFICATION_EVENT_STARTING_HOURS (default 72h), so an event is notified at + the first run after it enters the window - i.e. roughly 48-72h before the + event (sooner for events announced after they entered the window). + Deduplication guarantees each event is notified exactly once. """ now = timezone.now() - future = now + timedelta(hours=72) + future = now + timedelta(hours=_hours("NOTIFICATION_EVENT_STARTING_HOURS", 72)) upcoming_events = OfflineEvent.objects.filter( Q(date__gte=now, date__lte=future) @@ -92,6 +145,9 @@ def send_upcoming_event_notifications(): if not event.project: continue + if _event_already_notified(event): + continue + NotificationService.create_notifications(event, strategy) return diff --git a/apps/notifications/templates/a4_candy_notifications/settings.html b/apps/notifications/templates/a4_candy_notifications/settings.html index 4e3a9c831..7e76ffdef 100644 --- a/apps/notifications/templates/a4_candy_notifications/settings.html +++ b/apps/notifications/templates/a4_candy_notifications/settings.html @@ -72,8 +72,8 @@