{% 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.
+
+{% 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(),
}
)