~netlandish/django-wiki

f8770fa4d01252bb29805e4e7bedf2c2260091eb — Benjamin Bach 5 years ago 040d10a + acfe3c0
Merge pull request #865 from trevorpeacock/master

project fails to start when User model that doesn not have a 'username' field.
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 +20 -56
@@ 1,11 1,26 @@
import random
import string

__all__ = [
    'UserCreationForm',
    'UserUpdateForm',
    'WikiSlugField',
    'SpamProtectionMixin',
    'CreateRootForm',
    'MoveForm',
    'EditForm',
    'SelectWidgetBootstrap',
    'TextInputPrepend',
    'CreateForm',
    'DeleteForm',
    'PermissionsForm',
    'DirFilterForm',
    'SearchForm',
]

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 36,8 @@ from wiki.core.diff import simple_merge
from wiki.core.plugins.base import PluginSettingsFormMixin
from wiki.editors import getEditor

from .forms_account_handling import UserCreationForm, UserUpdateForm

validate_slug_numbers = RegexValidator(
    r'^[0-9]+$',
    _("A 'slug' cannot consist solely of numbers."),


@@ 565,56 582,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):
    return isinstance(_get_field(user_model, user_model.USERNAME_FIELD), CharField)


def check_email_field(user_model):
    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(User) and check_email_field(User)) \
    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 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'