Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions adhocracy-plus/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
11 changes: 8 additions & 3 deletions adhocracy-plus/config/settings/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 8 additions & 3 deletions adhocracy-plus/config/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
}
1 change: 0 additions & 1 deletion apps/account/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ class Meta:
"homepage",
"facebook_handle",
"twitter_handle",
"get_newsletters",
"language",
]

Expand Down
8 changes: 8 additions & 0 deletions apps/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions apps/newsletters/emails.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from django.urls import reverse

from apps.users.emails import EmailAplus as Email
from apps.users.models import User

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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}"))
Original file line number Diff line number Diff line change
@@ -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 %}
<p>
{% 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 %}
</p>
<p>
{% 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 %}
</p>
<p>
<a href="{{ settings_url }}">{% translate "Your notification settings" %}</a>
</p>
{% 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 %}
1 change: 0 additions & 1 deletion apps/notifications/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion apps/notifications/forms.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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)
Loading
Loading