From bb25f4b447cba3ed763c0f018c454dfa0fc04a61 Mon Sep 17 00:00:00 2001 From: Eric <34304046+EricTRL@users.noreply.github.com> Date: Fri, 29 Sep 2023 14:26:45 +0200 Subject: [PATCH] Re-send registration + ModelAdminFormViewMixin Added a view in the admin panel that allows re-sending registration emails for members that do not already have an associated user. Allow reusing ModelAdminFormViewMixin by generalizing titles and other text. --- .../templates/core/admin_form.html | 33 +--- core/tests/tests_views.py | 2 + core/views.py | 1 + membership_file/admin.py | 20 ++- membership_file/forms.py | 155 ++++++++++-------- .../resend_registration_email.html | 13 ++ membership_file/tests/test_forms.py | 2 +- membership_file/tests/test_views.py | 8 +- membership_file/views.py | 90 ++++++++-- utils/views.py | 31 +++- 10 files changed, 239 insertions(+), 116 deletions(-) rename membership_file/templates/membership_file/register_member.html => core/templates/core/admin_form.html (59%) create mode 100644 membership_file/templates/membership_file/resend_registration_email.html diff --git a/membership_file/templates/membership_file/register_member.html b/core/templates/core/admin_form.html similarity index 59% rename from membership_file/templates/membership_file/register_member.html rename to core/templates/core/admin_form.html index 2bc1c637..36b34269 100644 --- a/membership_file/templates/membership_file/register_member.html +++ b/core/templates/core/admin_form.html @@ -3,7 +3,6 @@ {% block extrahead %}{{ block.super }} - {{ adminform.media }} {% endblock %} @@ -19,7 +18,8 @@ {% translate 'Home' %}{{ opts.app_config.verbose_name }}{{ opts.verbose_name_plural|capfirst }} -› {% blocktranslate with name=opts.verbose_name %}Register new {{ name }}{% endblocktranslate %} +{% if original %}› {{ original|truncatewords:"18" }}{% endif %} +› {{ breadcrumbs_title|default:title }} {% endblock %} @@ -37,35 +37,10 @@ {% for fieldset in adminform %} {% include "admin/includes/fieldset.html" %} {% endfor %} - -{%comment%} - {% for field in form %} - -
- {% if not line.fields|length_is:'1' and not field.is_readonly %}{{ field.errors }}{% endif %} - {% if field.is_checkbox %} - check - {{ field.field }}{{ field.label_tag }} - {% else %} - {{ field.label_tag }} - {% if field.is_readonly %} -
{{ field.contents }}
- {% else %} - {{ field.field }} - {% endif %} - {% endif %} - {% if field.field.help_text %} -
{{ field.field.help_text|safe }}
- {% endif %} -
- {% endfor %} - -{%endcomment%} {% endblock %}
- +
{% block admin_change_form_document_ready %} @@ -80,4 +55,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/tests/tests_views.py b/core/tests/tests_views.py index f05c45cd..167040bd 100644 --- a/core/tests/tests_views.py +++ b/core/tests/tests_views.py @@ -1,11 +1,13 @@ from django.test import TestCase, RequestFactory from core.views import LinkedLoginView + class LinkedLoginTestCase(TestCase): """Tests for LinkedLoginView""" class DummyLinkedLoginView(LinkedLoginView): """Linked login with some overrides""" + image_source = "image.png" image_alt = "Image" link_title = "Link account" diff --git a/core/views.py b/core/views.py index da2a174e..ea1fbd6e 100644 --- a/core/views.py +++ b/core/views.py @@ -68,6 +68,7 @@ class LinkedLoginView(LoginView): the Squire account when logging in. This does not actually link any data itself; subclasses should implement that sort of behaviour. """ + template_name = "core/user_accounts/login_linked.html" image_source = None diff --git a/membership_file/admin.py b/membership_file/admin.py index a9b94168..aa5c23c6 100644 --- a/membership_file/admin.py +++ b/membership_file/admin.py @@ -9,7 +9,7 @@ from membership_file.forms import AdminMemberForm from membership_file.export import MemberResource, MembersFinancialResource from membership_file.models import Member, MemberLog, MemberLogField, Room, MemberYear, Membership -from membership_file.views import RegisterNewMemberAdminView +from membership_file.views import RegisterNewMemberAdminView, ResendRegistrationMailAdminView from utils.forms import RequestUserToFormModelAdminMixin @@ -71,8 +71,24 @@ def register_new_member(modeladmin, request, queryset): view = modeladmin.admin_site.admin_view(RegisterNewMemberAdminView.as_view(model_admin=modeladmin)) return view(request) - # Note: get_urls is extended by + @object_action(label="Re-send registration email", description="Re-sends the registration email to this member.") + def resend_verification(self, request, object): + view = self.admin_site.admin_view(ResendRegistrationMailAdminView.as_view(model_admin=self)) + return view(request, pk=object.pk) + + # Note: get_urls is extended changelist_actions = ("register_new_member",) + change_actions = ("resend_verification",) + + def get_change_actions(self, request, object_id, form_url): + # Action is only available if the user can add members normally + actions = super().get_change_actions(request, object_id, form_url) + if ( + not request.user.has_perm("membership_file.add_member") + or Member.objects.get(id=object_id).user is not None + ): + actions = [action for action in actions if action != "resend_verification"] + return actions def get_changelist_actions(self, request): # Action is only available if the user can add members normally diff --git a/membership_file/forms.py b/membership_file/forms.py index e7db728b..53e0ac61 100644 --- a/membership_file/forms.py +++ b/membership_file/forms.py @@ -112,7 +112,91 @@ def save(self): ) -class RegisterMemberForm(UpdatingUserFormMixin, FieldsetAdminFormMixin, forms.ModelForm): +class RegistrationFormBase(forms.ModelForm): + """Base class defining email functionality for sending registration emails to (new) members""" + + def __init__(self, request: HttpRequest, token_generator: LinkAccountTokenGenerator, *args, **kwargs): + self.domain = get_current_site(request).domain + self.use_https = request.is_secure() + self.token_generator = token_generator + super().__init__(*args, **kwargs) + + def send_registration_email(self): + """Generates and sends a registration email""" + # There is probably a better way to handle this than through a global preference, + # but it should do for now. This needs to be refactored anyway after #317 is merged. + global_preferences = global_preferences_registry.manager() + + context = { + "member": self.instance, + "sender": { + "name": str(self.user), + "description": str(global_preferences["membership__registration_description"]), + "extra_description": str(global_preferences["membership__registration_extra_description"]), + }, + "domain": self.domain, + "uid": urlsafe_base64_encode(force_bytes(self.instance.pk)), + "token": self.token_generator.make_token(user=self.instance), + "protocol": "https" if self.use_https else "http", + } + # Reply-To address + reply_to = str(global_preferences["membership__registration_reply_to_address"]) or None + if reply_to is not None: + reply_to = reply_to.split(",") + self.send_mail( + "membership_file/registration/registration_subject.txt", + "membership_file/registration/registration_email.txt", + context, + None, + self.instance.email, + reply_to=reply_to, + ) + + def send_mail( + self, + subject_template_name, + email_template_name, + context, + from_email, + to_email, + html_email_template_name=None, + reply_to=None, + ): + """ + Send a django.core.mail.EmailMultiAlternatives to `to_email`. + """ + + subject = loader.render_to_string(subject_template_name, context) + # Email subject *must not* contain newlines + subject = "".join(subject.splitlines()) + body = loader.render_to_string(email_template_name, context) + + email_message = EmailMultiAlternatives(subject, body, from_email, [to_email], reply_to=reply_to) + if html_email_template_name is not None: + html_email = loader.render_to_string(html_email_template_name, context) + email_message.attach_alternative(html_email, "text/html") + + email_message.send() + + +class ResendRegistrationForm(UpdatingUserFormMixin, FieldsetAdminFormMixin, RegistrationFormBase): + """ + Form that allows sending a registration email to a member. + There's no fields here; we basically only want a submit button. + """ + + class Meta: + model = Member + fields = () + + def save(self, commit=True) -> Any: + # We don't call super().save(commit) because the object didn't actually change + # There's no need to activate signals or update auto_now fields + self.send_registration_email() + return self.instance + + +class RegisterMemberForm(UpdatingUserFormMixin, FieldsetAdminFormMixin, RegistrationFormBase): """ Registers a member in the membership file, and optionally sends them an email to link or register a Squire account. Is able to automatically link an active year or room access. Also contains some useful presets, like those for @@ -159,7 +243,7 @@ class Meta: { "fields": [ "email", - "send_registration_email", + "do_send_registration_email", "phone_number", ("street", "house_number", "house_number_addition"), ("postal_code", "city"), @@ -190,16 +274,14 @@ class Meta: ), } - send_registration_email = forms.BooleanField( + do_send_registration_email = forms.BooleanField( + label="Send registration email?", initial=True, required=False, help_text="Whether to email a registration link to the new member, allowing them to link their account to this membership data.", ) - def __init__(self, request: HttpRequest, token_generator: LinkAccountTokenGenerator, *args, **kwargs): - self.domain = get_current_site(request).domain - self.use_https = request.is_secure() - self.token_generator = token_generator + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Make more fields required @@ -294,63 +376,8 @@ def _save_m2m(self): self.instance.accessible_rooms.add(*self.cleaned_data["room_access"]) # Only send out an email once the member is actually saved - if self.cleaned_data["send_registration_email"]: - # There is probably a better way to handle this than through a global preference, - # but it should do for now. This needs to be refactored anyway after #317 is merged. - global_preferences = global_preferences_registry.manager() - - context = { - "member": self.instance, - "sender": { - "name": str(self.user), - "description": str(global_preferences["membership__registration_description"]), - "extra_description": str(global_preferences["membership__registration_extra_description"]), - }, - "domain": self.domain, - "uid": urlsafe_base64_encode(force_bytes(self.instance.pk)), - "token": self.token_generator.make_token(user=self.instance), - "protocol": "https" if self.use_https else "http", - } - # Reply-To address - reply_to = str(global_preferences["membership__registration_reply_to_address"]) or None - if reply_to is not None: - reply_to = reply_to.split(",") - self.send_mail( - "membership_file/registration/registration_subject.txt", - "membership_file/registration/registration_email.txt", - context, - None, - self.instance.email, - reply_to=reply_to, - ) - - def send_mail( - self, - subject_template_name, - email_template_name, - context, - from_email, - to_email, - html_email_template_name=None, - reply_to=None, - ): - """ - Send a django.core.mail.EmailMultiAlternatives to `to_email`. - """ - # registration/registration_subject.txt - # registration/registration_email.html - - subject = loader.render_to_string(subject_template_name, context) - # Email subject *must not* contain newlines - subject = "".join(subject.splitlines()) - body = loader.render_to_string(email_template_name, context) - - email_message = EmailMultiAlternatives(subject, body, from_email, [to_email], reply_to=reply_to) - if html_email_template_name is not None: - html_email = loader.render_to_string(html_email_template_name, context) - email_message.attach_alternative(html_email, "text/html") - - email_message.send() + if self.cleaned_data["do_send_registration_email"]: + self.send_registration_email() class ConfirmLinkMembershipRegisterForm(RegisterForm): diff --git a/membership_file/templates/membership_file/resend_registration_email.html b/membership_file/templates/membership_file/resend_registration_email.html new file mode 100644 index 00000000..1e8eb8a2 --- /dev/null +++ b/membership_file/templates/membership_file/resend_registration_email.html @@ -0,0 +1,13 @@ +{% extends "core/admin_form.html" %} + +{% block content_subtitle %} + {{ block.super }} +

+ This action will re-send a registration email to {{ original.email|urlize }}. + This email allows them to link {{ original }}'s membership data to a new account or an already existing account. +

+

+ Members registered through the + admin member registration page will have already received this email. +

+{% endblock %} \ No newline at end of file diff --git a/membership_file/tests/test_forms.py b/membership_file/tests/test_forms.py index e08eb4dd..03a176d3 100644 --- a/membership_file/tests/test_forms.py +++ b/membership_file/tests/test_forms.py @@ -146,7 +146,7 @@ def test_save(self, _): "country": "The Netherlands", "date_of_birth": "1970-01-01", "notes": "", - "send_registration_email": False, + "do_send_registration_email": False, } self.assertFormValid(data) diff --git a/membership_file/tests/test_views.py b/membership_file/tests/test_views.py index d7b52477..dba2e051 100644 --- a/membership_file/tests/test_views.py +++ b/membership_file/tests/test_views.py @@ -129,14 +129,14 @@ def test_successful_get(self): def test_messages(self): """Tests messages and urls they contain""" # With registration mail - data = {**self.data, "send_registration_email": True} + data = {**self.data, "do_send_registration_email": True} res = self.assertValidPostResponse(data=data, redirect_url=self.base_url) member = Member.objects.filter(email=self.data["email"]).first() self.assertIsNotNone(member, "New member should've been created.") member.delete() # Without registration mail - data = {**self.data, "send_registration_email": False} + data = {**self.data, "do_send_registration_email": False} res = self.assertValidPostResponse(data=data, redirect_url=self.base_url) member = Member.objects.filter(email=self.data["email"]).first() self.assertIsNotNone(member, "New member should've been created.") @@ -266,7 +266,9 @@ def test_login_already_member(self): self._regenerate_token() data = {"username": "newuser", "password": "linkedlogintest"} user = User.objects.create_user(**data) - member = Member.objects.create(first_name="Bar", last_name="", legal_name="Bar", email="bar@example.com", user=user) + member = Member.objects.create( + first_name="Bar", last_name="", legal_name="Bar", email="bar@example.com", user=user + ) res: TemplateResponse = self.assertValidPostResponse(data, self.login_url) self.assertTemplateNotUsed(res, LinkMembershipLoginView.fail_template_name) self.assertIsInstance(res, TemplateResponse) diff --git a/membership_file/views.py b/membership_file/views.py index 6bf1184e..dc92f637 100644 --- a/membership_file/views.py +++ b/membership_file/views.py @@ -1,16 +1,21 @@ from typing import Any, Dict +from django import http from django.contrib import messages +from django.contrib.admin import ModelAdmin from django.contrib.auth import get_user_model, login as auth_login, logout as auth_logout from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import PermissionDenied +from django.db import models from django.forms.models import BaseModelForm -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, HttpResponseBadRequest from django.views import View -from django.views.generic.edit import CreateView -from django.views.generic import TemplateView, FormView +from django.views.generic import TemplateView, FormView, DetailView +from django.views.generic.detail import SingleObjectMixin +from django.views.generic.edit import CreateView, UpdateView, ModelFormMixin, FormView from django.template.response import TemplateResponse from django.urls import reverse, reverse_lazy from django.utils.html import format_html +from django.utils.translation import override as translation_override from dynamic_preferences.registries import global_preferences_registry from core.views import LinkedLoginView, RegisterUserView from utils.tokens import SessionTokenMixin, UrlTokenMixin @@ -26,8 +31,9 @@ ConfirmLinkMembershipRegisterForm, ContinueMembershipForm, RegisterMemberForm, + ResendRegistrationForm, ) -from membership_file.models import Membership +from membership_file.models import MemberYear, Membership, Room from membership_file.util import LinkAccountTokenGenerator, MembershipRequiredMixin global_preferences = global_preferences_registry.manager() @@ -98,17 +104,10 @@ class ExtendMembershipSuccessView(MemberMixin, UpdateMemberYearMixin, TemplateVi LINK_TOKEN_GENERATOR = LinkAccountTokenGenerator() -class RegisterNewMemberAdminView(PermissionRequiredMixin, ModelAdminFormViewMixin, CreateView): - """ - A form in the admin panel that registers a new user, and optionally - sends them a registration email. The receiver can use this registration - email in order to link the created membership data to a new or pre-existing - account. - """ +class MemberRegistrationFormMixin(PermissionRequiredMixin): + """Mixin used to render member registration forms""" permission_required = "membership_file.add_member" - form_class = RegisterMemberForm - template_name = "membership_file/register_member.html" token_generator = LINK_TOKEN_GENERATOR def get_form_kwargs(self) -> Dict[str, Any]: @@ -121,9 +120,36 @@ def get_form_kwargs(self) -> Dict[str, Any]: ) return kwargs + +class RegisterNewMemberAdminView(MemberRegistrationFormMixin, ModelAdminFormViewMixin, CreateView): + """ + A form in the admin panel that registers a new user, and optionally + sends them a registration email. The receiver can use this registration + email in order to link the created membership data to a new or pre-existing + account. + """ + + form_class = RegisterMemberForm + title = "Register new member" + save_button_title = "Register Member" + def form_valid(self, form: BaseModelForm) -> HttpResponse: - self.email_sent = form.cleaned_data["send_registration_email"] - return super().form_valid(form) + self.email_sent = form.cleaned_data["do_send_registration_email"] + res = super().form_valid(form) + + # Construct admin log entry + message = self.model_admin.construct_change_message(self.request, form, None, True) + with translation_override(None): + for q in [ + Room.objects.filter(id__in=form.cleaned_data.get("room_access", [])), + MemberYear.objects.filter(id__in=form.cleaned_data.get("active_years", [])), + ]: + for added_object in q: + message.append( + {"added": {"name": str(added_object._meta.verbose_name), "object": str(added_object)}} + ) + self.model_admin.log_addition(self.request, self.object, message) + return res def get_success_url(self) -> str: # Send user back to the member registration form, and show a message with a link to the newly created member object @@ -141,6 +167,40 @@ def get_success_url(self) -> str: return reverse(f"admin:membership_file_member_actions", args=("register_new_member",)) +class ResendRegistrationMailAdminView(MemberRegistrationFormMixin, ModelAdminFormViewMixin, UpdateView): + """TODO + Object is derived from in the URLConf + """ + + form_class = ResendRegistrationForm + model = form_class._meta.model + template_name = "membership_file/resend_registration_email.html" + title = "Re-send membership email" + save_button_title = "Resend email" + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + res = super().dispatch(request, *args, **kwargs) + if self.object.user is not None: + return HttpResponseBadRequest("Member already has an associated user.") + return res + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + return super().post(request, *args, **kwargs) + + def get_success_url(self) -> str: + member_link = reverse(f"admin:membership_file_member_change", args=(self.object.id,)) + messages.success( + self.request, + format_html('Re-sent registration email to member “{0}”', self.object, member_link), + ) + return member_link + + class LinkMembershipViewTokenMixin: """ A mixin to be used in combination with `UrlTokenMixin` or `SessionTokenMixin`. diff --git a/utils/views.py b/utils/views.py index 0bb2a02c..4423dcc1 100644 --- a/utils/views.py +++ b/utils/views.py @@ -137,19 +137,42 @@ class ModelAdminFormViewMixin: # Class variable needed as we need to be able to pass this through as_view(..) model_admin: ModelAdmin = None + title = "Form title" + subtitle = None + breadcrumbs_title = None + save_button_title = None + template_name = "core/admin_form.html" def __init__(self, *args, model_admin: ModelAdmin = None, **kwargs) -> None: assert model_admin is not None self.model_admin = model_admin super().__init__(*args, **kwargs) + def get_title(self): + """Gets the title displayed at the top of the page""" + return self.title + + def get_subtitle(self): + """Gets the title displayed at the top of the page""" + return self.subtitle or self.object + + def get_breadcrumbs_title(self): + """Gets the title used in the breadcrumbs. When None, uses `title`""" + return self.breadcrumbs_title + + def get_save_button_title(self): + """Gets the title used for the save button. Defaults to 'Save'""" + return self.save_button_title + def get_form(self, form_class: Optional[Type[BaseModelForm]] = None) -> BaseModelForm: # This method should return a form instance if form_class is None: form_class = self.get_form_class() # Use this form_class's excludes instead of those from the ModelAdmin's form_class - exclude = form_class._meta.exclude or () + exclude = None + if hasattr(form_class, "_meta"): + exclude = form_class._meta.exclude or () # This constructs a form class # NB: More defaults can be passed into the **kwargs of ModelAdmin.get_form @@ -179,7 +202,11 @@ def get_context_data(self, **kwargs) -> Dict[str, Any]: "adminform": adminForm, "is_nav_sidebar_enabled": True, "opts": self.model_admin.model._meta, - "title": "Register new member", + "original": self.object, + "title": self.get_title(), + "subtitle": self.get_subtitle(), + "breadcrumbs_title": self.get_breadcrumbs_title(), + "save_button_title": self.get_save_button_title(), } )