M src/wiki/apps.py => src/wiki/apps.py +1 -0
@@ 15,4 15,5 @@ class WikiConfig(AppConfig):
register(checks.check_for_required_installed_apps, checks.Tags.required_installed_apps)
register(checks.check_for_obsolete_installed_apps, checks.Tags.obsolete_installed_apps)
register(checks.check_for_context_processors, checks.Tags.context_processors)
+ register(checks.check_for_fields_in_custom_user_model, checks.Tags.fields_in_custom_user_model)
load_wiki_plugins()
M src/wiki/checks.py => src/wiki/checks.py +29 -0
@@ 7,6 7,7 @@ class Tags:
required_installed_apps = "required_installed_apps"
obsolete_installed_apps = "obsolete_installed_apps"
context_processors = "context_processors"
+ fields_in_custom_user_model = "fields_in_custom_user_model"
REQUIRED_INSTALLED_APPS = (
@@ 30,6 31,12 @@ REQUIRED_CONTEXT_PROCESSORS = (
('sekizai.context_processors.sekizai', 'E009'),
)
+FIELDS_IN_CUSTOM_USER_MODEL = (
+ # check function, field fetcher, required field type, error code
+ ('check_user_field', 'USERNAME_FIELD', 'CharField', 'E010'),
+ ('check_email_field', 'get_email_field_name()', 'EmailField', 'E011'),
+)
+
def check_for_required_installed_apps(app_configs, **kwargs):
errors = []
@@ 69,3 76,25 @@ def check_for_context_processors(app_configs, **kwargs):
)
)
return errors
+
+
+def check_for_fields_in_custom_user_model(app_configs, **kwargs):
+ errors = []
+ from wiki.conf import settings
+ if not settings.ACCOUNT_HANDLING:
+ return errors
+ import wiki.forms_account_handling
+ from django.contrib.auth import get_user_model
+ User = get_user_model()
+ for check_function_name, field_fetcher, required_field_type, error_code in FIELDS_IN_CUSTOM_USER_MODEL:
+ function = getattr(wiki.forms_account_handling, check_function_name)
+ if not function(User):
+ errors.append(
+ Error(
+ '%s.%s.%s refers to a field that is not of type %s' % (User.__module__, User.__name__, field_fetcher, required_field_type),
+ hint='If you have your own login/logout views, turn off settings.WIKI_ACCOUNT_HANDLING',
+ obj=User,
+ id='wiki.%s' % error_code,
+ )
+ )
+ return errors
M src/wiki/forms.py => src/wiki/forms.py +4 -56
@@ 1,11 1,8 @@
-import random
-import string
from datetime import timedelta
from django import forms
from django.apps import apps
from django.contrib.auth import get_user_model
-from django.contrib.auth.forms import UserCreationForm
from django.core import validators
from django.core.validators import RegexValidator
from django.forms.widgets import HiddenInput
@@ 21,6 18,10 @@ from wiki.core.diff import simple_merge
from wiki.core.plugins.base import PluginSettingsFormMixin
from wiki.editors import getEditor
+from .forms_account_handling import ( # noqa: ignore=F401 importing additional forms here as a convenience for other modules expecting them here
+ UserCreationForm, UserUpdateForm,
+)
+
validate_slug_numbers = RegexValidator(
r'^[0-9]+$',
_("A 'slug' cannot consist solely of numbers."),
@@ 565,56 566,3 @@ class SearchForm(forms.Form):
'placeholder': _('Search...'),
'class': 'search-query'}),
required=False)
-
-
-class UserCreationForm(UserCreationForm):
- email = forms.EmailField(required=True)
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Add honeypots
- self.honeypot_fieldnames = "address", "phone"
- self.honeypot_class = ''.join(
- random.choice(string.ascii_uppercase + string.digits)
- for __ in range(10))
- self.honeypot_jsfunction = 'f' + ''.join(
- random.choice(string.ascii_uppercase + string.digits)
- for __ in range(10))
-
- for fieldname in self.honeypot_fieldnames:
- self.fields[fieldname] = forms.CharField(
- widget=forms.TextInput(attrs={'class': self.honeypot_class}),
- required=False,
- )
-
- def clean(self):
- cd = super().clean()
- for fieldname in self.honeypot_fieldnames:
- if cd[fieldname]:
- raise forms.ValidationError(
- "Thank you, non-human visitor. Please keep trying to fill in the form.")
- return cd
-
- class Meta:
- model = User
- fields = ("username", "email")
-
-
-class UserUpdateForm(forms.ModelForm):
- password1 = forms.CharField(label="New password", widget=forms.PasswordInput(), required=False)
- password2 = forms.CharField(label="Confirm password", widget=forms.PasswordInput(), required=False)
-
- def clean(self):
- cd = super().clean()
- password1 = cd.get('password1')
- password2 = cd.get('password2')
-
- if password1 and password1 != password2:
- raise forms.ValidationError(_("Passwords don't match"))
-
- return cd
-
- class Meta:
- model = User
- fields = ['email']
A src/wiki/forms_account_handling.py => src/wiki/forms_account_handling.py +89 -0
@@ 0,0 1,89 @@
+import random
+import string
+
+import django.contrib.auth.models
+from django import forms
+from django.contrib.auth import get_user_model
+from django.contrib.auth.forms import UserCreationForm
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models.fields import CharField, EmailField
+from django.utils.translation import gettext_lazy as _
+from wiki.conf import settings
+
+
+def _get_field(model, field):
+ try:
+ return model._meta.get_field(field)
+ except FieldDoesNotExist:
+ return
+
+
+User = get_user_model()
+
+
+def check_user_field(user_model=User):
+ return isinstance(_get_field(user_model, user_model.USERNAME_FIELD), CharField)
+
+
+def check_email_field(user_model=User):
+ return isinstance(_get_field(user_model, user_model.get_email_field_name()), EmailField)
+
+
+# django parses the ModelForm (and Meta classes) on class creation, which fails with custom models without expected fields.
+# We need to check this here, because if this module can't load then system checks can't run.
+CustomUser = User \
+ if (settings.ACCOUNT_HANDLING and check_user_field() and check_email_field()) \
+ else django.contrib.auth.models.User
+
+
+class UserCreationForm(UserCreationForm):
+ email = forms.EmailField(required=True)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Add honeypots
+ self.honeypot_fieldnames = "address", "phone"
+ self.honeypot_class = ''.join(
+ random.choice(string.ascii_uppercase + string.digits)
+ for __ in range(10))
+ self.honeypot_jsfunction = 'f' + ''.join(
+ random.choice(string.ascii_uppercase + string.digits)
+ for __ in range(10))
+
+ for fieldname in self.honeypot_fieldnames:
+ self.fields[fieldname] = forms.CharField(
+ widget=forms.TextInput(attrs={'class': self.honeypot_class}),
+ required=False,
+ )
+
+ def clean(self):
+ cd = super().clean()
+ for fieldname in self.honeypot_fieldnames:
+ if cd[fieldname]:
+ raise forms.ValidationError(
+ "Thank you, non-human visitor. Please keep trying to fill in the form.")
+ return cd
+
+ class Meta:
+ model = CustomUser
+ fields = (CustomUser.USERNAME_FIELD, CustomUser.get_email_field_name())
+
+
+class UserUpdateForm(forms.ModelForm):
+ password1 = forms.CharField(label="New password", widget=forms.PasswordInput(), required=False)
+ password2 = forms.CharField(label="Confirm password", widget=forms.PasswordInput(), required=False)
+
+ def clean(self):
+ cd = super().clean()
+ password1 = cd.get('password1')
+ password2 = cd.get('password2')
+
+ if password1 and password1 != password2:
+ raise forms.ValidationError(_("Passwords don't match"))
+
+ return cd
+
+ class Meta:
+ model = CustomUser
+ fields = [CustomUser.get_email_field_name()]
M tests/core/test_checks.py => tests/core/test_checks.py +41 -1
@@ 3,7 3,9 @@ import copy
from django.conf import settings
from django.core.checks import Error, registry
from django.test import TestCase
-from wiki.checks import OBSOLETE_INSTALLED_APPS, REQUIRED_CONTEXT_PROCESSORS, REQUIRED_INSTALLED_APPS, Tags
+from wiki.checks import FIELDS_IN_CUSTOM_USER_MODEL, REQUIRED_CONTEXT_PROCESSORS, REQUIRED_INSTALLED_APPS, Tags
+
+from ..base import wiki_override_settings
def _remove(settings, arg):
@@ 40,3 42,41 @@ class CheckTests(TestCase):
)
]
self.assertEqual(errors, expected_errors)
+
+ def test_custom_user_model_mitigation_required(self):
+ """
+ Django & six check django.forms.ModelForm.Meta on definition, and raises an error if Meta.fields don't exist in Meta.model.
+ This causes problems in wiki.forms.UserCreationForm and wiki.forms.UserUpdateForm when a custom user model doesn't have fields django-wiki assumes.
+ There is some code in wiki.forms that detects this situation.
+ This check asserts that Django/six are still raising an exception on definition, and asserts the mitigation code in wiki.forms,
+ and that test_check_for_fields_in_custom_user_model below are required.
+ """
+ from django.core.exceptions import FieldError
+ from django import forms
+ from ..testdata.models import VeryCustomUser
+ with self.assertRaisesRegex(FieldError, 'Unknown field\(s\) \((email|username|, )+\) specified for VeryCustomUser'):
+ class UserUpdateForm(forms.ModelForm):
+ class Meta:
+ model = VeryCustomUser
+ fields = ['username', 'email']
+
+ def test_check_for_fields_in_custom_user_model(self):
+ from django.contrib.auth import get_user_model
+ with wiki_override_settings(WIKI_ACCOUNT_HANDLING=False, AUTH_USER_MODEL='testdata.VeryCustomUser'):
+ errors = registry.run_checks(tags=[Tags.fields_in_custom_user_model])
+ self.assertEqual(errors, [])
+ with wiki_override_settings(WIKI_ACCOUNT_HANDLING=True, AUTH_USER_MODEL='testdata.VeryCustomUser'):
+ errors = registry.run_checks(tags=[Tags.fields_in_custom_user_model])
+ expected_errors = [
+ Error(
+ '%s.%s.%s refers to a field that is not of type %s' % (
+ get_user_model().__module__, get_user_model().__name__, field_fetcher, required_field_type),
+ hint='If you have your own login/logout views, turn off settings.WIKI_ACCOUNT_HANDLING',
+ obj=get_user_model(),
+ id='wiki.%s' % error_code,
+ )
+ for check_function_name, field_fetcher, required_field_type, error_code in FIELDS_IN_CUSTOM_USER_MODEL]
+ self.assertEqual(errors, expected_errors)
+ with wiki_override_settings(WIKI_ACCOUNT_HANDLING=True):
+ errors = registry.run_checks(tags=[Tags.fields_in_custom_user_model])
+ self.assertEqual(errors, [])
M tests/testdata/models.py => tests/testdata/models.py +7 -0
@@ 1,3 1,4 @@
+from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AbstractUser
from django.db import models
@@ 8,3 9,9 @@ class CustomUser(AbstractUser):
class CustomGroup(models.Model):
pass
+
+
+# user with invalid renamed identifier, and no email field
+class VeryCustomUser(AbstractBaseUser):
+ identifier = models.IntegerField()
+ USERNAME_FIELD = 'identifier'