From 431e27ae6f735ff6beab91eb2dc52778c833583a Mon Sep 17 00:00:00 2001 From: Eric <34304046+EricTRL@users.noreply.github.com> Date: Sat, 23 Sep 2023 19:40:55 +0200 Subject: [PATCH] Invalid render + configuration errors + Util tests Added TokenMixinBase.token_invalid, which is called whenever token verification fails. This method returns a TemplateResponse with a 40x HTTP status code. Added several configuration errors to TokenMixinBase and related classes. Not setting these values can lead to insecurities (or errors). Added tests for OtherRadioSelect (widget), ModelAdminFormViewMixin, FieldsetAdminFormMixin, and the various TokenMixin views. --- membership_file/forms.py | 7 +- membership_file/processor.py | 2 +- membership_file/tests/test_views.py | 2 +- membership_file/views.py | 70 +----- .../utils/testing/token_fail_template.html | 1 + utils/tests/test_forms.py | 75 ++++++ utils/tests/test_tokens.py | 235 ++++++++++++++++++ utils/tests/test_views.py | 49 +++- utils/tests/test_widgets.py | 78 ++++++ utils/tokens.py | 19 +- utils/views.py | 66 +++++ utils/widgets.py | 4 +- 12 files changed, 531 insertions(+), 77 deletions(-) create mode 100644 utils/templates/utils/testing/token_fail_template.html create mode 100644 utils/tests/test_tokens.py create mode 100644 utils/tests/test_widgets.py 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