M docs/release_notes.rst => docs/release_notes.rst +10 -1
@@ 11,13 11,22 @@ Release plan
* **0.5** should remove Django 1.11 support and target Bootstrap v4, if you are interested in this work, please get in touch on Github!
-0.4.3
+0.4.4
-----
Fixed
~~~~~
* Projects fail to load with custom ``User`` models without a ``username`` field :url-issue:`865` (trevorpeacock)
+* Use ``User.get_username()`` for article cache instead of ``User.__str__`` :url-issue:`931` (Ole Anders Stokker)
+
+
+0.4.3
+-----
+
+Discarded release do to git errors (the actual fixes were not merged in).
+
+* Automated language updates from Transifex
0.4.2
M src/wiki/__init__.py => src/wiki/__init__.py +1 -1
@@ 19,5 19,5 @@ from wiki.core.version import get_version
default_app_config = 'wiki.apps.WikiConfig'
-VERSION = (0, 4, 3, 'final', 0)
+VERSION = (0, 4, 4, 'final', 0)
__version__ = get_version(VERSION)
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 src/wiki/models/article.py => src/wiki/models/article.py +2 -2
@@ 207,9 207,9 @@ class Article(models.Model):
def get_cache_content_key(self, user=None):
"""Returns per-article-user cache key."""
- return "{key}:{user!s}".format(
+ return "{key}:{user}".format(
key=self.get_cache_key(),
- user=user if user else "")
+ user=user.get_username() if user else "")
def get_cached_content(self, user=None):
"""Returns cached version of rendered article.
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'