Skip to content

Commit 1bb6fe2

Browse files
committed
feat: add ModalSubscribeView for multi-list subscription from modal
feat: add mailing list selection modal to subscribe card feat: wire modal context through mixin and page templates feat: add mailing list modal checkbox styles feat: open list-selection modal from subscribed/pending manage button fix: use :style binding to override dialog-modal CSS display rule fix: use :target dialog pattern instead of Alpine x-show for modal fix: align modal UI to Figma - title, checkboxes, select all, button order fix: checkbox colors fix: select-all selector, email validation, and CSS token issues in modal fix: checkbox states and dark-mode checkmark in mailing list modal fix: override legacy checkbox specificity and cancel tailwind orange tint fix: use svg icon + sibling pattern for modal checkbox, drop mask-image approach fix: move inline import to module level and fix unsubscribe error handling fix: gate modal open on valid email format fix: remove dead loading/spinner CSS and replace margin !important with shorthand chore: delete orphaned subscribe success card template fix: use MailmanClient().unsubscribe in ModalSubscribeView fix: fixes after rebase chore: avoid redundant code
1 parent 140a143 commit 1bb6fe2

13 files changed

Lines changed: 504 additions & 125 deletions

File tree

core/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747

4848
from . import context_processors
4949
from .mixins import V3Mixin, iter_v3_views
50+
from mailing_list.constants import MAILING_LIST_LABELS as _ML_LABELS
5051
from mailing_list.mixins import (
5152
MailingListCardMixin,
5253
get_subscription_state_count_and_email,
@@ -2249,6 +2250,8 @@ def _intro_card(library_name, authors):
22492250
context["demo_mailman_lists"] = constants.MAILMAN_LISTS
22502251
context["demo_subscribe_url"] = reverse("mailing-list-subscribe")
22512252
context["demo_quick_subscribe_url"] = reverse("mailing-list-quick-subscribe")
2253+
context["demo_modal_subscribe_url"] = reverse("mailing-list-modal-subscribe")
2254+
context["demo_mailing_lists"] = _ML_LABELS.values()
22522255

22532256
mailing_list_state = None
22542257

mailing_list/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class MailingLists(Enum):
3737

3838
MAILING_LIST_LABELS = {
3939
MailingLists.BOOST.value: {
40+
"id": f"{MailingLists.BOOST.value}.{MAILMAN_DOMAIN}",
4041
"name": "Boost Developers",
4142
"address": build_mailing_list_address(MailingLists.BOOST.value),
4243
"description": (
@@ -48,6 +49,7 @@ class MailingLists(Enum):
4849
),
4950
},
5051
MailingLists.BOOST_ANNOUNCE.value: {
52+
"id": f"{MailingLists.BOOST_ANNOUNCE.value}.{MAILMAN_DOMAIN}",
5153
"name": "Boost Announcements",
5254
"address": build_mailing_list_address(MailingLists.BOOST_ANNOUNCE.value),
5355
"description": (
@@ -57,6 +59,7 @@ class MailingLists(Enum):
5759
),
5860
},
5961
MailingLists.BOOST_USERS.value: {
62+
"id": f"{MailingLists.BOOST_USERS.value}.{MAILMAN_DOMAIN}",
6063
"name": "Boost Users",
6164
"address": build_mailing_list_address(MailingLists.BOOST_USERS.value),
6265
"description": (
@@ -68,7 +71,7 @@ class MailingLists(Enum):
6871
},
6972
}
7073

71-
MAILMAN_LISTS = [f"{_l}.{MAILMAN_DOMAIN}" for _l in MAILING_LIST_LABELS.keys()]
74+
MAILMAN_LISTS = [_l["id"] for _l in MAILING_LIST_LABELS.values()]
7275

7376
# we only want boost devel for now, leaving the others in case that changes.
7477
ML_STATS_URLS = [

mailing_list/mixins.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ def get_context_data(self, **kwargs):
6363
context["mailing_list_card_subscribe_url"] = reverse(
6464
"mailing-list-quick-subscribe"
6565
)
66+
context["mailing_list_card_modal_subscribe_url"] = reverse(
67+
"mailing-list-modal-subscribe"
68+
)
6669
context["mailing_list_card_list_id"] = _DEFAULT_LIST_ID
70+
context["mailing_list_card_lists"] = constants.MAILING_LIST_LABELS.values()
6771

6872
if request.user.is_authenticated:
6973
managed_lists = set(constants.MAILMAN_LISTS)
@@ -73,6 +77,11 @@ def get_context_data(self, **kwargs):
7377
context["mailing_list_card_subscription_count"] = state.count
7478
context["mailing_list_card_user_email"] = state.email
7579
context["mailing_list_card_manage_url"] = reverse("profile-account")
80+
context["mailing_list_card_subscribed_ids"] = set(
81+
UserMailingListSubscription.objects.filter(
82+
user=request.user, list_id__in=managed_lists
83+
).values_list("list_id", flat=True)
84+
)
7685

7786
# URL-param overrides for the no-JS PRG flow.
7887
# Error state always wins (DB record was rolled back on failure).

mailing_list/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.urls import path
22

33
from mailing_list.views import ConfirmSubscriptionView
4+
from mailing_list.views import ModalSubscribeView
45
from mailing_list.views import QuickSubscribeView
56
from mailing_list.views import SubscribeView
67

@@ -11,6 +12,11 @@
1112
QuickSubscribeView.as_view(),
1213
name="mailing-list-quick-subscribe",
1314
),
15+
path(
16+
"modal-subscribe/",
17+
ModalSubscribeView.as_view(),
18+
name="mailing-list-modal-subscribe",
19+
),
1420
path(
1521
"confirm/<str:token>/",
1622
ConfirmSubscriptionView.as_view(),

mailing_list/views.py

Lines changed: 174 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,25 @@ def post(self, request):
216216
"""
217217

218218

219+
def _build_card_context(request) -> dict:
220+
"""Build the base context dict for rendering _mailing_list_card.html."""
221+
managed_lists = set(constants.MAILMAN_LISTS)
222+
ctx = {
223+
"subscribe_url": reverse("mailing-list-quick-subscribe"),
224+
"modal_subscribe_url": reverse("mailing-list-modal-subscribe"),
225+
"login_url": reverse("account_login"),
226+
"mailing_lists": constants.MAILING_LIST_LABELS.values(),
227+
"subscribed_ids": set(),
228+
}
229+
if request.user.is_authenticated:
230+
ctx["subscribed_ids"] = set(
231+
UserMailingListSubscription.objects.filter(
232+
user=request.user, list_id__in=managed_lists
233+
).values_list("list_id", flat=True)
234+
)
235+
return ctx
236+
237+
219238
class QuickSubscribeView(View):
220239
"""Subscribe to a single list. Works for both authenticated and anonymous users.
221240
@@ -224,17 +243,10 @@ class QuickSubscribeView(View):
224243
"""
225244

226245
def _card(self, request, **ctx):
227-
subscribe_url = reverse("mailing-list-quick-subscribe")
228-
login_url = reverse("account_login")
229246
return render(
230247
request,
231248
"v3/includes/_mailing_list_card.html",
232-
{
233-
"subscribe_url": subscribe_url,
234-
"login_url": login_url,
235-
"list_id": constants.MAILMAN_LISTS[0],
236-
**ctx,
237-
},
249+
{**_build_card_context(request), **ctx},
238250
)
239251

240252
def post(self, request):
@@ -312,14 +324,12 @@ def _handle_authenticated(self, request, email, list_id, managed_lists):
312324
user=request.user, list_id__in=managed_lists
313325
).count()
314326
if _is_htmx(request):
315-
return render(
327+
return self._card(
316328
request,
317-
"v3/mailing_list/_subscribe_success_card.html",
318-
{
319-
"email": existing.email,
320-
"subscription_count": subscription_count,
321-
"manage_url": manage_url,
322-
},
329+
state="active",
330+
user_email=existing.email,
331+
subscription_count=subscription_count,
332+
manage_url=manage_url,
323333
)
324334
return _prg_redirect(request)
325335

@@ -498,3 +508,152 @@ def _label(list_id):
498508
"home_url": "/",
499509
},
500510
)
511+
512+
513+
class ModalSubscribeView(View):
514+
"""Subscribe to one or more lists via the list-selection modal.
515+
516+
Accepts email + one or more list_id POST values. Works for both authenticated
517+
and anonymous users. Only reachable via HTMX (the modal is Alpine-only).
518+
519+
Authenticated flow: subscribes to newly checked lists and unsubscribes from
520+
any currently tracked lists that were unchecked.
521+
Anonymous flow: subscribe-only - sends a single confirmation email for all
522+
checked lists. Unsubscribe not supported for anonymous users.
523+
"""
524+
525+
def _card(self, request, **ctx):
526+
return render(
527+
request,
528+
"v3/includes/_mailing_list_card.html",
529+
{**_build_card_context(request), **ctx},
530+
)
531+
532+
def post(self, request):
533+
email = request.POST.get("email", "").strip()
534+
managed_lists = set(constants.MAILMAN_LISTS)
535+
list_ids = [
536+
lid for lid in request.POST.getlist("list_id") if lid in managed_lists
537+
]
538+
539+
if not email:
540+
return self._card(
541+
request, state="error", error_message="Email is required."
542+
)
543+
544+
if _is_rate_limited(request):
545+
return self._card(
546+
request,
547+
state="error",
548+
error_message="Too many attempts. Please try again later.",
549+
user_email=email,
550+
)
551+
552+
if request.user.is_authenticated:
553+
return self._handle_authenticated(request, email, list_ids, managed_lists)
554+
return self._handle_anonymous(request, email, list_ids)
555+
556+
def _handle_authenticated(self, request, email, list_ids, managed_lists):
557+
manage_url = reverse("profile-account")
558+
559+
current_subs = {
560+
sub.list_id: sub
561+
for sub in UserMailingListSubscription.objects.filter(
562+
user=request.user, list_id__in=managed_lists
563+
)
564+
}
565+
to_subscribe = [lid for lid in list_ids if lid not in current_subs]
566+
to_unsubscribe = [lid for lid in current_subs if lid not in list_ids]
567+
568+
for lid in to_unsubscribe:
569+
sub = current_subs[lid]
570+
if sub.status == SubscriptionStatus.PENDING:
571+
sub.delete()
572+
else:
573+
try:
574+
MailmanClient().unsubscribe(sub.email, lid)
575+
UserMailingListSubscription.objects.filter(
576+
user=request.user, list_id=lid
577+
).delete()
578+
except MailmanAPIError as exc:
579+
logger.error(
580+
"Mailman unsubscribe error for %s/%s: %s", sub.email, lid, exc
581+
)
582+
583+
if not to_subscribe:
584+
subscription_count = UserMailingListSubscription.objects.filter(
585+
user=request.user, list_id__in=managed_lists
586+
).count()
587+
if subscription_count == 0:
588+
return self._card(request, user_email=email)
589+
return self._card(
590+
request,
591+
state="active",
592+
user_email=email,
593+
subscription_count=subscription_count,
594+
manage_url=manage_url,
595+
)
596+
597+
succeeded = []
598+
for lid in to_subscribe:
599+
try:
600+
with transaction.atomic():
601+
UserMailingListSubscription.objects.update_or_create(
602+
user=request.user,
603+
list_id=lid,
604+
defaults={"email": email, "status": SubscriptionStatus.PENDING},
605+
)
606+
succeeded.append(lid)
607+
except IntegrityError:
608+
pass
609+
610+
if not succeeded:
611+
return self._card(
612+
request,
613+
state="error",
614+
error_message="Could not subscribe. Please try again.",
615+
user_email=email,
616+
)
617+
618+
try:
619+
_send_confirmation_email(request, email, request.user.pk, succeeded)
620+
except Exception as exc:
621+
logger.error("Failed to send confirmation email to %s: %s", email, exc)
622+
UserMailingListSubscription.objects.filter(
623+
user=request.user, list_id__in=succeeded
624+
).delete()
625+
return self._card(
626+
request,
627+
state="error",
628+
error_message="Could not send confirmation email. Please try again.",
629+
user_email=email,
630+
)
631+
632+
return self._card(
633+
request,
634+
state="pending",
635+
user_email=email,
636+
manage_url=manage_url,
637+
)
638+
639+
def _handle_anonymous(self, request, email, list_ids):
640+
if not list_ids:
641+
return self._card(
642+
request,
643+
state="error",
644+
error_message="Please select at least one mailing list.",
645+
user_email=email,
646+
)
647+
648+
try:
649+
_send_confirmation_email(request, email, None, list_ids)
650+
except Exception as exc:
651+
logger.error("Failed to send confirmation email to %s: %s", email, exc)
652+
return self._card(
653+
request,
654+
state="error",
655+
error_message="Could not send confirmation email. Please try again.",
656+
user_email=email,
657+
)
658+
659+
return self._card(request, state="pending", user_email=email)

0 commit comments

Comments
 (0)