diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e4213..4687658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.5.6 + +- Events: cancelling an event now notifies its guests. `cancel_event` is idempotent, + skips the event's pending reminders, and after commit dispatches an + `events.notify_cancellation` job that sends one `events.event_cancelled` mail per + active registration, deduped by registration and `ics_sequence`; inactive + registrations are never mailed. The mail context resolves the absolute event URL and + a `calendar_cancel_url`: for a cancelled event `calendar.ics` serves a + `METHOD:CANCEL` invite, so a guest who opens the link removes the calendar entry. + Cancelled events keep their detail page with a Cancelled badge and + `schema.org` `EventCancelled` status instead of returning 404, while staying out of + public listings; registration remains closed. The reminder send handler skips + reminders whose event is no longer upcoming. + ## 0.5.5 - C5.2i: add an optional `homework_steps` app with per-question resumable drafts, diff --git a/community_base/__init__.py b/community_base/__init__.py index 86716a7..a779a44 100644 --- a/community_base/__init__.py +++ b/community_base/__init__.py @@ -1 +1 @@ -__version__ = "0.5.5" +__version__ = "0.5.6" diff --git a/community_base/events/jobs.py b/community_base/events/jobs.py index 6d8fe2d..b1fd0f7 100644 --- a/community_base/events/jobs.py +++ b/community_base/events/jobs.py @@ -130,6 +130,12 @@ def send_reminder_handler(context: JobContext, payload: JobPayload): or registration.status != EventRegistration.Status.CONFIRMED ): raise PermanentJobError("event_reminder_stale") + if registration.event.status != "upcoming": + reminder.status = EventReminder.Status.SKIPPED + reminder.reason = "event_inactive" + reminder.completed_at = timezone.now() + reminder.save(update_fields=("status", "reason", "completed_at", "updated_at")) + return delivery = send( "events.reminder", registration.normalized_email, @@ -148,6 +154,39 @@ def send_reminder_handler(context: JobContext, payload: JobPayload): complete_reminder(reminder.pk, delivery) +@register_handler("events.notify_cancellation") +def notify_cancellation_handler(context: JobContext, payload: JobPayload): + del context + event_id = _positive_id(payload.get("event_id"), "invalid_event_cancellation_payload") + with transaction.atomic(): + event = Event.objects.filter(pk=event_id, status="cancelled").first() + if event is None: + raise PermanentJobError("event_not_cancelled") + registrations = event.registrations.filter( + status__in=( + EventRegistration.Status.PENDING_VERIFICATION, + EventRegistration.Status.CONFIRMED, + ) + ).select_related("user") + for registration in registrations: + send( + "events.event_cancelled", + registration.normalized_email, + { + "registration_id": str(registration.pk), + "registration_version": registration.version, + "event_id": event.pk, + "event_title": event.title, + "event_start": event.start_datetime.isoformat(), + "event_timezone": event.timezone, + }, + f"events.cancellation:{registration.pk}:{event.ics_sequence}", + category="events", + user=registration.user, + related=registration, + ) + + @register_handler("events.expire_registration_verifications") def expire_registration_verifications_handler(context: JobContext, payload: JobPayload): del context diff --git a/community_base/events/mail_context.py b/community_base/events/mail_context.py index 669e57a..46aca29 100644 --- a/community_base/events/mail_context.py +++ b/community_base/events/mail_context.py @@ -1,4 +1,5 @@ from community_base.events.models import EventRegistration +from community_base.events.routing import event_url from community_base.events.tokens import generate_registration_token from community_base.kernel.conf import require @@ -31,4 +32,11 @@ def resolve_delivery_context(*, delivery, context): expiry_hours=24 * 365, ) resolved["manage_url"] = f"{site_url}/events/registration/manage/?token={token}" + elif delivery.purpose == "events.event_cancelled": + site_url = require("SITE_URL").rstrip("/") + base_url = f"{site_url}{event_url(registration.event)}" + resolved["event_url"] = base_url + # For a cancelled event this endpoint serves the METHOD:CANCEL ICS, + # so the guest's calendar entry is removed with one click. + resolved["calendar_cancel_url"] = f"{base_url}calendar.ics" return resolved diff --git a/community_base/events/services.py b/community_base/events/services.py index 0cdbffb..04789c1 100644 --- a/community_base/events/services.py +++ b/community_base/events/services.py @@ -6,9 +6,15 @@ from django.utils import timezone from django.utils.text import slugify -from community_base.events.models import Event, EventPublicIdSequence, EventSeries +from community_base.events.models import ( + Event, + EventPublicIdSequence, + EventReminder, + EventSeries, +) from community_base.events.registration import enroll_series_registrants_in_event from community_base.events.signals import event_cancelled, event_published, event_rescheduled +from community_base.jobs.dispatch import dispatch_after_commit from community_base.kernel.access import can_access from community_base.kernel.conf import get from community_base.kernel.hooks import resolve @@ -89,9 +95,25 @@ def cancel_event(event, *, reason=""): event = Event.objects.select_for_update().get(pk=event.pk) if event.status in {"completed", "archived"}: raise ValidationError("Completed or archived events cannot be cancelled.") + if event.status == "cancelled": + return event event.status = "cancelled" event.ics_sequence += 1 event.save(update_fields=("status", "ics_sequence", "updated_at")) + now = timezone.now() + EventReminder.objects.filter( + registration__event_id=event.pk, status=EventReminder.Status.PENDING + ).update( + status=EventReminder.Status.SKIPPED, + reason="event_cancelled", + completed_at=now, + updated_at=now, + ) + dispatch_after_commit( + "events.notify_cancellation", + f"events.cancellation:{event.pk}:{event.ics_sequence}", + {"event_id": event.pk}, + ) _after_commit(event_cancelled, event, reason=str(reason)[:500]) return event diff --git a/community_base/events/templates/events/event_detail.html b/community_base/events/templates/events/event_detail.html index dcb1091..d262535 100644 --- a/community_base/events/templates/events/event_detail.html +++ b/community_base/events/templates/events/event_detail.html @@ -3,9 +3,10 @@ {% block meta_description %}{{ event.description|striptags|truncatewords:30 }}{% endblock %} {% block content %}
+

All events

-

{{ event.get_kind_display }}

+

{{ event.get_kind_display }}{% if event.status == "cancelled" %} ยท Cancelled{% endif %}

{{ event.title }}

@@ -23,7 +24,13 @@

{{ event.title }}

Registration

- {% if registration.is_active %} + {% if event.status == "cancelled" %} +

This event was cancelled.

+ {% if registration.is_active %} +

You had been registered for this event.

+

Remove from calendar

+ {% endif %} + {% elif registration.is_active %}

You are registered.

{% csrf_token %}

Add to calendar

@@ -46,7 +53,7 @@

Registration

{% endif %}
- {% if event.is_past and registration %} + {% if event.is_past and event.status != "cancelled" and registration %}

Feedback

diff --git a/community_base/events/views.py b/community_base/events/views.py index c90dedf..e26a649 100644 --- a/community_base/events/views.py +++ b/community_base/events/views.py @@ -1,5 +1,7 @@ +import json + from django.contrib import messages -from django.core.exceptions import PermissionDenied, ValidationError +from django.core.exceptions import ImproperlyConfigured, PermissionDenied, ValidationError from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.cache import patch_vary_headers @@ -21,6 +23,10 @@ from community_base.kernel.conf import get PUBLIC_STATUSES = ("upcoming", "completed") +# A cancelled event keeps its own detail page so guests with the link see the +# cancellation instead of a 404; it disappears from listings, which stay on +# PUBLIC_STATUSES. +DETAIL_STATUSES = (*PUBLIC_STATUSES, "cancelled") def _private(response): @@ -32,7 +38,7 @@ def _private(response): def _lookup_event(*, slug, public_id=None): - events = Event.objects.filter(status__in=PUBLIC_STATUSES).select_related("event_series") + events = Event.objects.filter(status__in=DETAIL_STATUSES).select_related("event_series") if public_id is not None: return get_object_or_404(events, public_id=public_id) event = events.filter(slug=slug).order_by("-start_datetime", "-pk").first() @@ -61,6 +67,29 @@ def _registration_for_user(event, user): return EventRegistration.objects.filter(event=event, user=user).first() +def _event_schema(event): + data = { + "@context": "https://schema.org", + "@type": "Event", + "name": event.title, + "eventStatus": ( + "https://schema.org/EventCancelled" + if event.status == "cancelled" + else "https://schema.org/EventScheduled" + ), + "startDate": event.start_datetime.isoformat(), + "endDate": event.effective_end_datetime.isoformat(), + } + site_url = get("SITE_URL") + if site_url: + try: + data["url"] = f"{str(site_url).rstrip('/')}{event_url(event)}" + except ImproperlyConfigured: + pass + # Keep the JSON valid while preventing a breakout from user data. + return json.dumps(data).replace("=3.13" dependencies = [ diff --git a/tests/events/test_jobs.py b/tests/events/test_jobs.py index 3a0cb2f..980e344 100644 --- a/tests/events/test_jobs.py +++ b/tests/events/test_jobs.py @@ -13,7 +13,7 @@ ZoomTemporaryError, ) from community_base.events.models import Event, EventIntegrationAttempt, EventReminder -from community_base.events.registration import register_for_event +from community_base.events.registration import register_for_event, unregister_from_event from community_base.jobs.models import JobIntent from community_base.jobs.registry import JobContext, registered_handler_names, registered_schedules from community_base.jobs.runner import PermanentJobError, RetryableJobError, run_intent @@ -63,6 +63,7 @@ def test_event_handlers_and_schedules_are_registered(): assert { "events.plan_reminders", "events.send_reminder", + "events.notify_cancellation", "events.expire_registration_verifications", "events.sync_zoom", "events.process_recording", @@ -113,6 +114,53 @@ def test_suppressed_reminder_is_terminal_without_a_delivery_job(settings): assert reminder.delivery.job is None +def test_cancellation_notice_reaches_active_registrations_and_is_idempotent(): + item = event() + active = [] + for email in ("member@example.com", "second@example.com"): + user = get_user_model().objects.create_user(email=email) + active.append(register_for_event(item, user)[0]) + departed = get_user_model().objects.create_user(email="departed@example.com") + register_for_event(item, departed) + unregister_from_event(item, departed) + Event.objects.filter(pk=item.pk).update(status="cancelled") + + jobs.notify_cancellation_handler(None, {"event_id": item.pk}) + jobs.notify_cancellation_handler(None, {"event_id": item.pk}) + + deliveries = EmailDelivery.objects.filter(purpose="events.event_cancelled") + assert deliveries.count() == 2 + assert {delivery.recipient_email for delivery in deliveries} == { + "member@example.com", + "second@example.com", + } + assert {delivery.idempotency_key for delivery in deliveries} == { + f"events.cancellation:{registration.pk}:{item.ics_sequence}" for registration in active + } + + +def test_cancellation_notice_requires_a_cancelled_event(): + item = event() + + with pytest.raises(PermanentJobError, match="event_not_cancelled"): + jobs.notify_cancellation_handler(None, {"event_id": item.pk}) + + +def test_reminder_for_cancelled_event_is_skipped_without_a_delivery(): + registration() + jobs.plan_reminders_handler(None, {}) + reminder = EventReminder.objects.get(interval="24h") + assert reminder.status == EventReminder.Status.CLAIMED + + Event.objects.filter(pk=reminder.registration.event_id).update(status="cancelled") + jobs.send_reminder_handler(None, {"reminder_id": str(reminder.pk)}) + + reminder.refresh_from_db() + assert reminder.status == EventReminder.Status.SKIPPED + assert reminder.reason == "event_inactive" + assert not EmailDelivery.objects.filter(purpose="events.reminder").exists() + + def test_zoom_create_job_persists_result_once(monkeypatch): item = event(required_level=0) calls = [] diff --git a/tests/events/test_mail_context.py b/tests/events/test_mail_context.py index 236d91c..6d62caf 100644 --- a/tests/events/test_mail_context.py +++ b/tests/events/test_mail_context.py @@ -2,9 +2,9 @@ Companion to `tests/accounts/test_mail_context.py`: the same rule applied to the events half of `mail_context.resolve_delivery_context`. `events.verify_registration`, -`events.registration_confirmed` and `events.guest_invitation` all compose an absolute -link and must raise loudly when `SITE_URL` is unset; `events.reminder` does not -compose a link and must be unaffected. +`events.registration_confirmed`, `events.guest_invitation` and `events.event_cancelled` +all compose an absolute link and must raise loudly when `SITE_URL` is unset; +`events.reminder` does not compose a link and must be unaffected. """ from datetime import timedelta @@ -57,3 +57,35 @@ def test_a_purpose_that_builds_no_link_is_unaffected_by_an_unset_site_url(): resolved = resolve_delivery_context(delivery=delivery, context=context) assert resolved == {} + + +def test_event_cancelled_links_compose_absolute_urls(): + requested = request_anonymous_registration(event(), "person@example.com") + registration = requested.registration + delivery = SimpleNamespace(purpose="events.event_cancelled") + context = { + "registration_id": str(registration.pk), + "registration_version": registration.version, + } + + resolved = resolve_delivery_context(delivery=delivery, context=context) + + assert resolved["event_url"] == "http://testserver/events/open-community-event/" + assert ( + resolved["calendar_cancel_url"] + == "http://testserver/events/open-community-event/calendar.ics" + ) + + +def test_event_cancelled_link_raises_when_site_url_is_unset(): + requested = request_anonymous_registration(event(), "person@example.com") + registration = requested.registration + delivery = SimpleNamespace(purpose="events.event_cancelled") + context = { + "registration_id": str(registration.pk), + "registration_version": registration.version, + } + + with override_settings(COMMUNITY_BASE={"SITE_URL": ""}): + with pytest.raises(ImproperlyConfigured): + resolve_delivery_context(delivery=delivery, context=context) diff --git a/tests/events/test_public_views.py b/tests/events/test_public_views.py index bebee70..1f67949 100644 --- a/tests/events/test_public_views.py +++ b/tests/events/test_public_views.py @@ -248,3 +248,46 @@ def test_calendar_download_uses_public_route_without_authentication(client): assert response.status_code == 200 assert response["Content-Type"] == "text/calendar; charset=utf-8" assert b"BEGIN:VCALENDAR" in response.content + + +def test_cancelled_event_detail_visible_with_schema_and_off_listings(client): + item = event(status="cancelled") + + detail = client.get(item.get_absolute_url()) + listing = client.get("/events/") + calendar = client.get(item.get_absolute_url() + "calendar.ics") + + assert detail.status_code == 200 + content = detail.content.decode() + assert 'Cancelled' in content + assert "This event was cancelled." in content + assert '"eventStatus": "https://schema.org/EventCancelled"' in content + assert list(listing.context["upcoming_events"]) == [] + assert list(listing.context["past_events"]) == [] + assert calendar.status_code == 200 + assert b"METHOD:CANCEL" in calendar.content + assert b"STATUS:CANCELLED" in calendar.content + + +def test_cancelled_event_shows_calendar_removal_to_registered_member(client, django_user_model): + item = event(status="cancelled") + user = django_user_model.objects.create_user(email="member@example.com") + registration(item, user) + client.force_login(user) + + detail = client.get(item.get_absolute_url()) + + content = detail.content.decode() + assert "You had been registered for this event." in content + assert "Remove from calendar" in content + + +def test_cancelled_event_rejects_registration(client, django_user_model): + item = event(status="cancelled") + user = django_user_model.objects.create_user(email="member@example.com") + client.force_login(user) + + response = client.post(item.get_absolute_url() + "register/") + + assert response.status_code == 302 + assert not EventRegistration.objects.filter(event=item).exists() diff --git a/tests/events/test_services.py b/tests/events/test_services.py index 4f31823..22883a4 100644 --- a/tests/events/test_services.py +++ b/tests/events/test_services.py @@ -6,7 +6,15 @@ from django.test import override_settings from django.utils import timezone -from community_base.events.models import Event, EventPublicIdSequence, EventSeries, Host +from community_base.events.models import ( + Event, + EventPublicIdSequence, + EventReminder, + EventSeries, + Host, +) +from community_base.events.registration import register_for_event +from community_base.events.reminders import plan_event_reminders from community_base.events.services import ( allocate_public_id, can_register_for_event, @@ -18,6 +26,7 @@ reserve_public_id, ) from community_base.events.signals import event_cancelled, event_published, event_rescheduled +from community_base.jobs.models import JobIntent pytestmark = pytest.mark.django_db(transaction=True) @@ -134,3 +143,28 @@ def receiver(sender, signal, **values): assert received[2][1]["reason"] == "No host" assert item.status == "cancelled" assert item.ics_sequence == 2 + + +def test_cancel_event_dispatches_notice_and_skips_reminders( + django_capture_on_commit_callbacks, +): + item = event(status="upcoming") + user = get_user_model().objects.create_user(email="guest@example.com") + register_for_event(item, user) + plan_event_reminders(item) + + with django_capture_on_commit_callbacks(execute=True): + cancelled = cancel_event(item, reason="No host") + + intent = JobIntent.objects.get(handler="events.notify_cancellation") + assert intent.payload == {"event_id": cancelled.pk} + reminder = EventReminder.objects.get(interval="24h") + assert reminder.status == EventReminder.Status.SKIPPED + assert reminder.reason == "event_cancelled" + + with django_capture_on_commit_callbacks(execute=True): + repeat = cancel_event(cancelled, reason="Repeated") + + assert Event.objects.get(pk=cancelled.pk).ics_sequence == cancelled.ics_sequence + assert JobIntent.objects.filter(handler="events.notify_cancellation").count() == 1 + assert repeat.status == "cancelled" diff --git a/uv.lock b/uv.lock index a7859ba..cbcf055 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.13" [options] -exclude-newer = "2026-09-14T19:26:55.38012903Z" +exclude-newer = "2026-09-15T17:12:03.542649324Z" exclude-newer-span = "P7D" [[package]] @@ -302,7 +302,7 @@ wheels = [ [[package]] name = "community-base" -version = "0.5.5" +version = "0.5.6" source = { editable = "." } dependencies = [ { name = "apispec" },