@@ -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+
219238class 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