Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion community_base/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.5.5"
__version__ = "0.5.6"
39 changes: 39 additions & 0 deletions community_base/events/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions community_base/events/mail_context.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
24 changes: 23 additions & 1 deletion community_base/events/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions community_base/events/templates/events/event_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
{% block meta_description %}{{ event.description|striptags|truncatewords:30 }}{% endblock %}
{% block content %}
<main class="cb-page cb-event-detail">
<script type="application/ld+json">{{ event_schema|safe }}</script>
<p><a href="{% url 'events_list' %}">All events</a></p>
<header class="cb-page-header">
<p>{{ event.get_kind_display }}</p>
<p>{{ event.get_kind_display }}{% if event.status == "cancelled" %} · <span class="cb-event-status">Cancelled</span>{% endif %}</p>
<h1 class="cb-page-title">{{ event.title }}</h1>
<p>
<time datetime="{{ event.start_datetime|date:'c' }}">{{ event.start_datetime }}</time>
Expand All @@ -23,7 +24,13 @@ <h1 class="cb-page-title">{{ event.title }}</h1>

<section class="cb-section" id="registration">
<h2>Registration</h2>
{% if registration.is_active %}
{% if event.status == "cancelled" %}
<p>This event was cancelled.</p>
{% if registration.is_active %}
<p>You had been registered for this event.</p>
<p><a class="cb-button" href="{{ event.get_absolute_url }}calendar.ics">Remove from calendar</a></p>
{% endif %}
{% elif registration.is_active %}
<p>You are registered.</p>
<form method="post" action="{{ event.get_absolute_url }}unregister/">{% csrf_token %}<button class="cb-button" type="submit">Cancel registration</button></form>
<p><a class="cb-button" href="{{ event.get_absolute_url }}calendar.ics">Add to calendar</a></p>
Expand All @@ -46,7 +53,7 @@ <h2>Registration</h2>
{% endif %}
</section>

{% if event.is_past and registration %}
{% if event.is_past and event.status != "cancelled" and registration %}
<section class="cb-section" id="feedback">
<h2>Feedback</h2>
<form class="cb-form" method="post" action="{{ event.get_absolute_url }}feedback/">
Expand Down
37 changes: 34 additions & 3 deletions community_base/events/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -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()
Expand Down Expand Up @@ -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 </script> breakout from user data.
return json.dumps(data).replace("</", "<\\/")


@require_GET
def event_list(request):
events = (
Expand Down Expand Up @@ -96,6 +125,7 @@ def event_detail(request, slug, public_id=None):
"can_register": can_register_for_event(request.user, event),
"anonymous_form": AnonymousEventRegistrationForm(),
"feedback": feedback,
"event_schema": _event_schema(event),
"feedback_form": EventFeedbackForm(
initial={
"rating": getattr(feedback, "rating", None),
Expand Down Expand Up @@ -286,8 +316,9 @@ def event_feedback(request, slug, public_id=None):
def event_calendar(request, slug, public_id=None):
event = _lookup_event(slug=slug, public_id=public_id)
attendee_email = request.user.email if request.user.is_authenticated else None
method = "CANCEL" if event.status == "cancelled" else "REQUEST"
response = HttpResponse(
generate_ics(event, audience="attendee", attendee_email=attendee_email),
generate_ics(event, method=method, audience="attendee", attendee_email=attendee_email),
content_type="text/calendar; charset=utf-8",
)
response["Content-Disposition"] = f'attachment; filename="{event.slug}.ics"'
Expand Down
3 changes: 2 additions & 1 deletion community_base/kernel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ is also why the fix could not be "add a Django system check that requires `SITE_
force every site to set it, including ones with no present use for it, the opposite of the rule.
A site that sends the mail purposes needing `SITE_URL` (`accounts.verify_email`,
`accounts.password_reset`, `accounts.email_change_confirm`, `events.verify_registration`,
`events.registration_confirmed`, `events.guest_invitation`; also required already, unrelated to
`events.registration_confirmed`, `events.guest_invitation`, `events.event_cancelled`;
also required already, unrelated to
this issue, by `events/integrations/calendar.py`, `jobs/relay.py`, `jobs/relay_scheduling.py`)
must configure it or those specific sends fail loudly, logged and retried by the job runner like
any other handler error, rather than delivering a mail nobody can act on.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "community-base"
version = "0.5.5"
version = "0.5.6"
description = "Shared Django apps for DataTalks.Club community sites"
requires-python = ">=3.13"
dependencies = [
Expand Down
50 changes: 49 additions & 1 deletion tests/events/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = []
Expand Down
38 changes: 35 additions & 3 deletions tests/events/test_mail_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading
Loading