diff --git a/membership_file/forms.py b/membership_file/forms.py index b19b323c..c674f9d9 100644 --- a/membership_file/forms.py +++ b/membership_file/forms.py @@ -203,10 +203,9 @@ def __init__(self, request: HttpRequest, token_generator: LinkAccountTokenGenera super().__init__(*args, **kwargs) # Make more fields required - # TODO - # req_fields = ('street', 'house_number', 'postal_code', 'city', 'country', 'date_of_birth') - # for field in req_fields: - # self.fields[field].required = True + req_fields = ('street', 'house_number', 'postal_code', 'city', 'country', 'date_of_birth') + for field in req_fields: + self.fields[field].required = True # Add field to automatically create memberships in one or more active years choices = [(year.id, year.name) for year in MemberYear.objects.filter(is_active=True)] diff --git a/membership_file/processor.py b/membership_file/processor.py index b8b32cd4..fced8de5 100644 --- a/membership_file/processor.py +++ b/membership_file/processor.py @@ -1,4 +1,4 @@ def member_context(request): return { - "member": request.member, + "member": getattr(request, "member", None), } diff --git a/membership_file/tests/test_views.py b/membership_file/tests/test_views.py index dac74d56..9f27a01b 100644 --- a/membership_file/tests/test_views.py +++ b/membership_file/tests/test_views.py @@ -38,7 +38,7 @@ def test_successful_get(self): def test_succesful_post(self): response = self.client.post(self.get_base_url(), data={}, follow=True) self.assertRedirects(response, reverse("membership:continue_success")) - msg = "Succesfully extended Knights membership into {year}".format(year=MemberYear.objects.get(id=3)) + msg = "Successfully extended Knights membership into {year}".format(year=MemberYear.objects.get(id=3)) self.assertHasMessage(response, level=messages.SUCCESS, text=msg) @suppress_warnings diff --git a/membership_file/views.py b/membership_file/views.py index ba48ebb1..305a37d9 100644 --- a/membership_file/views.py +++ b/membership_file/views.py @@ -15,6 +15,7 @@ from dynamic_preferences.registries import global_preferences_registry from core.views import LinkedLoginView, LoginView, RegisterUserView from utils.tokens import SessionTokenMixin, UrlTokenMixin +from utils.views import ModelAdminFormViewMixin UserModel = get_user_model() @@ -86,7 +87,7 @@ def get_form_kwargs(self): def form_valid(self, form): form.save() - msg = f"Succesfully extended Knights membership into {self.year}" + msg = f"Successfully extended Knights membership into {self.year}" messages.success(self.request, msg) return super(ExtendMembershipView, self).form_valid(form) @@ -95,69 +96,6 @@ class ExtendMembershipSuccessView(MemberMixin, UpdateMemberYearMixin, TemplateVi template_name = "membership_file/extend_membership_successpage.html" -class ModelAdminFormViewMixin: - """ - A Mixin that allows a ModelForm (e.g in a CreateView) to be rendered - inside a ModelAdmin in the admin panel using features normally available there. - - This includes default widgets and styling (e.g. for datetime) and formsets. - - The `form_class` must also inherit `membership_file.forms.FieldsetAdminFormMixin` - in order for this to work. - Furthermore, a `model_admin` should be passed in order to instantiate this view. - """ - - # Class variable needed as we need to be able to pass this through as_view(..) - model_admin: ModelAdmin = None - - 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_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 () - - # This constructs a form class - # NB: More defaults can be passed into the **kwargs of ModelAdmin.get_form - form_class = self.model_admin.get_form( - self.request, - None, - change=False, - # Fields are defined in the form - fields=None, - # Override standard ModelAdmin form and ignore its exclude list - form=form_class, - exclude=exclude, - ) - - # Use the newly constructed form class to create a form - return super().get_form(form_class) - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - context = super().get_context_data(**kwargs) - form = context.pop("form") - adminForm = helpers.AdminForm( - form, list(form.get_fieldsets(self.request, self.object)), {}, model_admin=self.model_admin - ) - - context.update( - { - "adminform": adminForm, - "is_nav_sidebar_enabled": True, - "opts": Member._meta, - "title": "Register new member", - } - ) - - return context - - LINK_TOKEN_GENERATOR = LinkAccountTokenGenerator() @@ -229,7 +167,7 @@ def get_url_object(self, uidb64: str): def dispatch(self, *args, **kwargs): # Fail if the requesting user already has a member if hasattr(self.request.user, "member"): - return render(self.request, self.fail_template_name) + return self.token_invalid() return super().dispatch(*args, **kwargs) @@ -271,7 +209,7 @@ class LinkMembershipRegisterView(LinkMembershipViewTokenMixin, SessionTokenMixin def dispatch(self, *args, **kwargs): # Fail if a user is logged in if self.request.user.is_authenticated: - return render(self.request, self.fail_template_name) + return self.token_invalid(status=403) return super().dispatch(*args, **kwargs) def get_login_url(self): diff --git a/utils/templates/utils/testing/token_fail_template.html b/utils/templates/utils/testing/token_fail_template.html new file mode 100644 index 00000000..b55972b1 --- /dev/null +++ b/utils/templates/utils/testing/token_fail_template.html @@ -0,0 +1 @@ +TOKEN FAILURE! \ No newline at end of file diff --git a/utils/tests/test_forms.py b/utils/tests/test_forms.py index b77c6535..2c528c89 100644 --- a/utils/tests/test_forms.py +++ b/utils/tests/test_forms.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from utils.forms import ( + FieldsetAdminFormMixin, RequestUserToFormModelAdminMixin, UpdatingUserFormMixin, get_basic_filter_by_field_form, @@ -200,3 +201,77 @@ def test_formset_prefix(self): self.assertEqual(form_group.formset_class.call_args.kwargs["prefix"], "formset") form_group = self._construct_form_group(prefix="Group") self.assertEqual(form_group.formset_class.call_args.kwargs["prefix"], "Group-formset") + + +class FieldsetAdminUserForm(FieldsetAdminFormMixin, forms.ModelForm): + """ModelForm that includes fieldsets.""" + + class Meta: + model = User + fields = ("username", "first_name", "last_name", "email", "last_login") + + fieldsets = [ + (None, {"fields": [("username",), "email", "last_login"]}), + ("Name", {"fields": [("first_name", "last_name")]}), + ] + + +class FieldsetAdminFormMixinTestCase(TestCase): + """Tests for forms utilising FieldsetAdminFormMixin""" + + class FieldAdminForm(FieldsetAdminFormMixin, forms.ModelForm): + """ModelForm without fieldsets.""" + + class Meta: + model = User + fields = ("username", "first_name", "last_name", "email") + + class Media: + css = {"all": ("extra-css-file.css",)} + js = ("extra-js-file.js",) + + class NoMetaForm(FieldAdminForm, forms.ModelForm): + """ModelForm without a Meta class""" + + def test_media(self): + """Tests if form media is added and merged correctly""" + media = FieldsetAdminFormMixinTestCase.FieldAdminForm().media.render() + # Additional media is there + self.assertIn("extra-js-file.js", media) + self.assertIn("extra-css-file.css", media) + # Parent media is there + self.assertIn("admin/js/admin/RelatedObjectLookups.js", media) + + def test_fieldsets(self): + """Tests whether fieldsets are properly created""" + # Meta has fieldsets + form = FieldsetAdminUserForm() + fieldsets = form.get_fieldsets(None) + self.assertEqual(len(fieldsets), 2) + self.assertEqual(fieldsets[1][0], "Name") + + # Meta has no fieldsets (all fields are added to a single fieldset) + form = FieldsetAdminFormMixinTestCase.FieldAdminForm() + fieldsets = form.get_fieldsets(None) + self.assertEqual(len(fieldsets), 1) + name, attrs = fieldsets[0] + self.assertIsNone(name) + self.assertIn("fields", attrs) + self.assertDictEqual(attrs["fields"], form.fields) + + def test_meta(self): + """Tests whether the _meta.fieldsets attribute is set""" + # No Meta class + form = FieldsetAdminFormMixinTestCase.NoMetaForm() + self.assertTrue(hasattr(form._meta, "fieldsets")) + self.assertIsNone(form._meta.fieldsets) + + # Meta class (no fieldset attribute) + form = FieldsetAdminFormMixinTestCase.FieldAdminForm() + self.assertTrue(hasattr(form._meta, "fieldsets")) + self.assertIsNone(form._meta.fieldsets) + + # Meta class (fieldset attribute) + form = FieldsetAdminUserForm() + self.assertTrue(hasattr(form._meta, "fieldsets")) + self.assertIsNotNone(form._meta.fieldsets) diff --git a/utils/tests/test_tokens.py b/utils/tests/test_tokens.py new file mode 100644 index 00000000..e78f8e24 --- /dev/null +++ b/utils/tests/test_tokens.py @@ -0,0 +1,235 @@ +from typing import Optional, Tuple +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.contrib.sessions.middleware import SessionMiddleware +from django.http import HttpRequest, HttpResponse +from django.test import RequestFactory, TestCase +from django.template.response import TemplateResponse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from django.views.generic import TemplateView + +from utils.tokens import SessionTokenMixin, UrlTokenMixin + +UserModel = get_user_model() + + +class SessionTokenViewMixinTestCase(TestCase): + """Tests access requirements for views that fetch a token from the session data, partially like Django's `PasswordResetConfirmView`""" + + class DummySessionView(SessionTokenMixin, TemplateView): + """Simple view that mimics Django's password reset token generation""" + + template_name = "utils/testing/test_mixin_template.html" + fail_template_name = "utils/testing/token_fail_template.html" + session_token_name = "_my_session_token" + token_generator = PasswordResetTokenGenerator() + + view_class = DummySessionView + + def setUp(self) -> None: + self.view = self.view_class.as_view() + self.request_factory = RequestFactory() + self.middleware = SessionMiddleware() + + self.user = UserModel.objects.create(username="user") + + return super().setUp() + + def build_response( + self, token_user: UserModel, request_user: Optional[UserModel] = None, token: Optional[str] = None + ) -> Tuple[HttpRequest, TemplateResponse]: + """ + Builds a request made by `request_user`, with url kwargs for a base64 encoded `token_user` and a `token`. + If `token` is omitted, a valid token is generated based on the `token_user` + """ + token = token or self.view_class.token_generator.make_token(token_user) + uidb64 = urlsafe_base64_encode(force_bytes(token_user.pk)) + req: HttpRequest = self.request_factory.get(f"/my_path/{token}/foo/{uidb64}/bar/") + req.user = request_user + + # Enable session middleware + self.middleware.process_request(req) + req.session.save() + + return req, uidb64, token + + def test_token_session_success(self): + """Tests token handling with valid session tokens""" + req, uidb64, token = self.build_response(self.user) + req.session[self.view_class.session_token_name] = token + res = self.view(req, uidb64=uidb64) + self.assertEqual(res.status_code, 200) + # TemplateView converts template_name into a list + self.assertListEqual( + res.template_name, + [self.view_class.template_name], + "Success template should be used when token is valid.", + ) + + def _test_token_fail(self, request: HttpRequest, response: TemplateResponse, msg: str = "Token"): + """Common checks for failed tokens""" + self.assertEqual(response.status_code, 400, f"{msg} is invalid; should stay on page") + self.assertEqual( + response.template_name, + self.view_class.fail_template_name, + "Fail template should be used when token is invalid.", + ) + self.assertNotIn( + self.view_class.session_token_name, + request.session, + "Session token should not be set when token is invalid", + ) + + def test_token_url_fail(self): + """Tests token handling with invalid tokens in the URL""" + # Token already replaced in URL, but not present in session data + req, uidb64, token = self.build_response(self.user) + res = self.view(req, uidb64=uidb64) + self._test_token_fail(req, res) + + def test_token_session_fail(self): + """Tests token handling with invalid tokens in the session""" + # Invalid token present in session data + req, uidb64, token = self.build_response(self.user) + req.session[self.view_class.session_token_name] = "INVALID" + res = self.view(req, uidb64=uidb64) + self.assertEqual(res.status_code, 400, f"Token in session is invalid; should stay on page") + + self.assertEqual( + res.template_name, + self.view_class.fail_template_name, + "Fail template should be used when token is invalid.", + ) + + def test_user_fail(self): + """Tests token handling if the user in the url is invalid""" + # Base64 text is invalid + req, _, token = self.build_response(self.user) + res = self.view(req, uidb64="1") + self._test_token_fail(req, res, msg="base64 encoded user") + + # User does not exist + req, _, token = self.build_response(self.user) + res = self.view(req, uidb64=urlsafe_base64_encode(force_bytes(42))) + self._test_token_fail(req, res, msg="Non-existing user") + + +class UrlTokenViewMixinTestCase(TestCase): + """Tests access requirements for views that fetch and set a token through a URL, like Django's `PasswordResetConfirmView`""" + + class DummyUrlView(UrlTokenMixin, TemplateView): + """Simple view that mimics Django's password reset token generation""" + + template_name = "utils/testing/test_mixin_template.html" + fail_template_name = "utils/testing/token_fail_template.html" + session_token_name = "_my_session_token" + token_generator = PasswordResetTokenGenerator() + url_token_name = "token-replacement-in-url" + + view_class = DummyUrlView + + def setUp(self) -> None: + self.view = self.view_class.as_view() + self.request_factory = RequestFactory() + self.middleware = SessionMiddleware() + + self.user = UserModel.objects.create(username="user") + + return super().setUp() + + def build_response( + self, token_user: UserModel, request_user: Optional[UserModel] = None, token: Optional[str] = None + ) -> Tuple[HttpRequest, TemplateResponse]: + """ + Builds a request made by `request_user`, with url kwargs for a base64 encoded `token_user` and a `token`. + If `token` is omitted, a valid token is generated based on the `token_user` + """ + token = token or self.view_class.token_generator.make_token(token_user) + uidb64 = urlsafe_base64_encode(force_bytes(token_user.pk)) + req: HttpRequest = self.request_factory.get(f"/my_path/{token}/foo/{uidb64}/bar/") + req.user = request_user + + # Enable session middleware + self.middleware.process_request(req) + req.session.save() + + return req, uidb64, token + + def test_token_url_success(self): + """Tests redirection and token handling with valid tokens""" + req, uidb64, token = self.build_response(self.user) + res = self.view(req, uidb64=uidb64, token=token) + self.assertEqual( + res.status_code, 302, "Token valid; should redirect to same page without the token in the URL" + ) + self.assertEqual( + req.path.replace(token, self.view_class.url_token_name), res.url, "Token should be replaced in the URL" + ) + self.assertIn(self.view_class.session_token_name, req.session, "Session token should be set") + self.assertEqual(req.session[self.view_class.session_token_name], token) + + def test_token_session_success(self): + """Tests token handling with valid session tokens""" + req, uidb64, token = self.build_response(self.user) + req.session[self.view_class.session_token_name] = token + res = self.view(req, uidb64=uidb64, token=self.view_class.url_token_name) + self.assertEqual(res.status_code, 200) + # TemplateView converts template_name into a list + self.assertListEqual( + res.template_name, + [self.view_class.template_name], + "Success template should be used when token is valid.", + ) + + def _test_token_fail(self, request: HttpRequest, response: TemplateResponse, msg: str = "Token"): + """Common checks for failed tokens""" + self.assertEqual(response.status_code, 400, f"{msg} is invalid; should stay on page") + self.assertEqual( + response.template_name, + self.view_class.fail_template_name, + "Fail template should be used when token is invalid.", + ) + self.assertNotIn( + self.view_class.session_token_name, + request.session, + "Session token should not be set when token is invalid", + ) + + def test_token_url_fail(self): + """Tests token handling with invalid tokens in the URL""" + # Token itself is invalid + req, uidb64, token = self.build_response(self.user, token="INVALID") + res = self.view(req, uidb64=uidb64, token=token) + self._test_token_fail(req, res) + + # Token already replaced in URL, but not present in session data + req, uidb64, token = self.build_response(self.user, token=self.view_class.url_token_name) + res = self.view(req, uidb64=uidb64, token=token) + self._test_token_fail(req, res) + + def test_token_session_fail(self): + """Tests token handling with invalid tokens in the session""" + # Invalid token present in session data + req, uidb64, token = self.build_response(self.user, token=self.view_class.url_token_name) + req.session[self.view_class.session_token_name] = "INVALID" + res = self.view(req, uidb64=uidb64, token=token) + self.assertEqual(res.status_code, 400, f"Token in session is invalid; should stay on page") + + self.assertEqual( + res.template_name, + self.view_class.fail_template_name, + "Fail template should be used when token is invalid.", + ) + + def test_user_fail(self): + """Tests token handling if the user in the url is invalid""" + # Base64 text is invalid + req, _, token = self.build_response(self.user) + res = self.view(req, uidb64="1", token=token) + self._test_token_fail(req, res, msg="base64 encoded user") + + # User does not exist + req, _, token = self.build_response(self.user) + res = self.view(req, uidb64=urlsafe_base64_encode(force_bytes(42)), token=token) + self._test_token_fail(req, res, msg="Non-existing user") diff --git a/utils/tests/test_views.py b/utils/tests/test_views.py index 04d18b04..c9755268 100644 --- a/utils/tests/test_views.py +++ b/utils/tests/test_views.py @@ -1,4 +1,7 @@ from django.core.exceptions import ValidationError, PermissionDenied +from django.contrib.admin import helpers, ModelAdmin +from django.contrib.admin.widgets import AdminTextInputWidget, AdminSplitDateTime +from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.contrib.messages import constants @@ -9,11 +12,18 @@ from django.views import View from django.views.generic import ListView, FormView from django.forms import Form, BooleanField +from utils.tests.test_forms import FieldsetAdminUserForm User = get_user_model() -from utils.views import SearchFormMixin, RedirectMixin, PostOnlyFormViewMixin, SuperUserRequiredMixin +from utils.views import ( + ModelAdminFormViewMixin, + SearchFormMixin, + RedirectMixin, + PostOnlyFormViewMixin, + SuperUserRequiredMixin, +) class TestForm(Form): @@ -197,3 +207,40 @@ def test_staff_denied(self): self.request.user = self.staff with self.assertRaises(PermissionDenied): self.view(self.request) + + +class ModelAdminFormViewMixinTestCase(TestCase): + """Tests for ModelAdminFormViewMixin""" + + class DummyModelFormView(ModelAdminFormViewMixin, FormView): + """FormView in combination with FieldsetAdminUserForm""" + + form_class = FieldsetAdminUserForm + template_name = "utils/testing/test_mixin_template.html" + + view_class = DummyModelFormView + + def setUp(self) -> None: + self.model_admin = ModelAdmin(model=User, admin_site=AdminSite()) + self.view = self.view_class(model_admin=self.model_admin) + request_factory = RequestFactory() + req = request_factory.get(f"/my_path/") + self.view.setup(req) + self.view.object = None + + return super().setUp() + + def test_admin_widgets(self): + """Tests whether admin widgets properly replace standard widgets. E.g. AdminSplitDateTime""" + context = self.view.get_context_data() + self.assertIsInstance(context.get("adminform", None), helpers.AdminForm) + adminform: helpers.AdminForm = context["adminform"] + form = adminform.form + # Form fields should be converted to admin classes + self.assertIsInstance(form.fields['username'].widget, AdminTextInputWidget) + self.assertIsInstance(form.fields["last_login"].widget, AdminSplitDateTime) + + # More context needed for template + self.assertTrue(context.get("is_nav_sidebar_enabled", False)) + self.assertIsNotNone(context.get("opts", None)) + self.assertIn("title", context) diff --git a/utils/tests/test_widgets.py b/utils/tests/test_widgets.py new file mode 100644 index 00000000..3e508a09 --- /dev/null +++ b/utils/tests/test_widgets.py @@ -0,0 +1,78 @@ +from django.test import TestCase + +from utils.widgets import OtherRadioSelect + + +class OtherWidgetTestCase(TestCase): + """Tests for the OtherRadioSelect widget""" + + def setUp(self) -> None: + self.widget = OtherRadioSelect( + choices=[("a_value", "a label"), ("b_value", "b label"), ("c_value", "c label")] + ) + return super().setUp() + + def test_radiolist_class(self): + """Tests if the radiolist class is added correctly""" + # No override class passed + self.assertEqual(self.widget.attrs.get("class", None), "radiolist") + # Override class passed + widget = OtherRadioSelect( + choices=[("a_value", "a label"), ("b_value", "b label"), ("c_value", "c label")], attrs={"class": "foo"} + ) + self.assertEqual(widget.attrs.get("class", None), "foo") + + def test_get_context_other(self): + """Tests if the free-text widget's context is also passed""" + # Value is one of the preset options + context = self.widget.get_context("test_field", "b_value", {"class": "foo"}) + self.assertIsNotNone(context.get("other_widget", None)) + other_widget = context["other_widget"] + self.assertEqual(other_widget["name"], OtherRadioSelect.other_field_name % "test_field") + # Value is one of the preset options; widget should be empty and disabled + self.assertIsNone(other_widget["value"]) + self.assertTrue(other_widget["attrs"]["disabled"]) + # Class is overridden + self.assertEqual(other_widget["attrs"]["class"], "foo") + + # Value is not a preset option + context = self.widget.get_context("test_field", "free text value", None) + other_widget = context["other_widget"] + self.assertEqual(other_widget["value"], "free text value") + self.assertFalse(other_widget["attrs"]["disabled"]) + # Class is inherited from base radioWidget + self.assertEqual(other_widget["attrs"]["class"], "radiolist") + + def test_get_value(self): + """Tests if the value entered in the other option can be retrieved""" + # Mimic some POST data (preset option) + val = self.widget.value_from_datadict({"test_field": "b_value"}, {}, "test_field") + self.assertEqual(val, "b_value") + + # Other option + val = self.widget.value_from_datadict({"test_field": "not a preset value!"}, {}, "test_field") + self.assertEqual(val, "not a preset value!") + self.assertNotEqual(val, OtherRadioSelect.other_option_name) + + def test_optgroups(self): + """Tests if an 'other' option is properly created""" + optgroups = self.widget.optgroups("test_field", ["b_value"], None) + # An additional optgroup should be created + self.assertEqual(len(optgroups), 4) + optgroup_name, other_option, index = optgroups[-1] + self.assertIsNone(optgroup_name) + self.assertEqual(index, 3) + other_option = other_option[0] + self.assertEqual(other_option["name"], "test_field") + self.assertEqual(other_option["value"], OtherRadioSelect.other_option_name) + self.assertEqual(other_option["label"], "Other:") + # Not selected, because a preset was selected + self.assertFalse(other_option["selected"]) + self.assertEqual(other_option["index"], "3") + self.assertEqual(other_option["template_name"], "utils/snippets/otherradio_option.html") + + optgroups = self.widget.optgroups("test_field", ["free text option"], None) + _, other_option, _ = optgroups[-1] + other_option = other_option[0] + # Selected, because no preset was selected + self.assertTrue(other_option["selected"]) diff --git a/utils/tokens.py b/utils/tokens.py index be4f4ebb..9852d841 100644 --- a/utils/tokens.py +++ b/utils/tokens.py @@ -1,10 +1,11 @@ from typing import Type from django.contrib.auth import get_user_model from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.core.exceptions import ImproperlyConfigured from django.db.models import Model from django.forms import ValidationError from django.http import HttpResponseRedirect -from django.shortcuts import render +from django.template.response import TemplateResponse from django.utils.decorators import method_decorator from django.utils.http import urlsafe_base64_decode from django.views.decorators.cache import never_cache @@ -33,6 +34,13 @@ class TokenMixinBase: object_id_kwarg_name = "uidb64" object_class: Type[Model] = UserModel + def __init__(self) -> None: + super().__init__() + if not self.session_token_name: # pragma: no cover + raise ImproperlyConfigured(f"{self.__class__.__name__} should override session_token_name (cannot be None or empty).") + if self.token_generator is None: # pragma: no cover + raise ImproperlyConfigured(f"{self.__class__.__name__} should set a token_generator (cannot be None).") + def get_url_object(self, uidb64: str): """ Decodes the base64-encoded id for an object of type `object_class` from the URL. If @@ -54,9 +62,11 @@ def delete_token(self): def dispatch(self, validlink, *args, **kwargs): # Render fail template if the URL is invalid if not validlink: - return render(self.request, self.fail_template_name) + return self.token_invalid() return super().dispatch(*args, **kwargs) + def token_invalid(self, status=400) -> TemplateResponse: + return TemplateResponse(self.request, self.fail_template_name, status=status) class UrlTokenMixin(TokenMixinBase): """ @@ -82,6 +92,11 @@ class UrlTokenMixin(TokenMixinBase): url_token_name: str = None token_kwarg_name = "token" + def __init__(self) -> None: + super().__init__() + if not self.url_token_name: # pragma: no cover + raise ImproperlyConfigured(f"{self.__class__.__name__} should override url_token_name (cannot be None or empty).") + @method_decorator(sensitive_post_parameters()) @method_decorator(never_cache) def dispatch(self, *args, **kwargs): diff --git a/utils/views.py b/utils/views.py index a0d0644c..0f8756cc 100644 --- a/utils/views.py +++ b/utils/views.py @@ -1,4 +1,7 @@ +from typing import Any, Dict, Optional +from django.contrib.admin import helpers, ModelAdmin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.forms import BaseModelForm from django.http.response import HttpResponseRedirect from django.contrib.messages import success, warning @@ -118,3 +121,66 @@ class SuperUserRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): def test_func(self): return self.request.user.is_superuser + + +class ModelAdminFormViewMixin: + """ + A Mixin that allows a ModelForm (e.g in a CreateView) to be rendered + inside a ModelAdmin in the admin panel using features normally available there. + + This includes default widgets and styling (e.g. for datetime) and formsets. + + The `form_class` must also inherit `utils.forms.FieldsetAdminFormMixin` + in order for this to work. + Furthermore, a `model_admin` should be passed in order to instantiate this view. + """ + + # Class variable needed as we need to be able to pass this through as_view(..) + model_admin: ModelAdmin = None + + 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_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 () + + # This constructs a form class + # NB: More defaults can be passed into the **kwargs of ModelAdmin.get_form + form_class = self.model_admin.get_form( + self.request, + None, + change=False, + # Fields are defined in the form + fields=None, + # Override standard ModelAdmin form and ignore its exclude list + form=form_class, + exclude=exclude, + ) + + # Use the newly constructed form class to create a form + return super().get_form(form_class) + + def get_context_data(self, **kwargs) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + form = context.pop("form") + adminForm = helpers.AdminForm( + form, list(form.get_fieldsets(self.request, self.object)), {}, model_admin=self.model_admin + ) + + context.update( + { + "adminform": adminForm, + "is_nav_sidebar_enabled": True, + "opts": self.model_admin.model._meta, + "title": "Register new member", + } + ) + + return context diff --git a/utils/widgets.py b/utils/widgets.py index 44afb7ec..8357aec4 100644 --- a/utils/widgets.py +++ b/utils/widgets.py @@ -25,7 +25,7 @@ class Media: js = ("js/other_option_widget.js",) def __init__(self, attrs=None, choices=None) -> None: - if attrs is None or attrs.get("class", None): + if attrs is None or attrs.get("class", None) is None: attrs = attrs or {} attrs["class"] = "radiolist" super().__init__(attrs, choices) @@ -39,7 +39,7 @@ def get_context(self, name, value, attrs): context["other_widget"] = self.other_widget_class().get_context( self.other_field_name % name, "" if self.any_selected else value, - {**self.attrs, **{"disabled": self.any_selected}}, + {**self.attrs, **(attrs or {}), **{"disabled": self.any_selected}}, )["widget"] return context