diff --git a/api-tests/conftest.py b/api-tests/conftest.py new file mode 100644 index 0000000000..910fce15d9 --- /dev/null +++ b/api-tests/conftest.py @@ -0,0 +1,30 @@ +import os +import pytest +import requests + + +API_BASE_URL = os.environ.get("API_BASE_URL", "https://www.sefaria.org") + + +@pytest.fixture(scope="session") +def base_url(): + return API_BASE_URL.rstrip("/") + + +@pytest.fixture(scope="session") +def api(base_url): + """Session-scoped requests session with base URL helper.""" + session = requests.Session() + session.headers.update({"Accept": "application/json"}) + session.base_url = base_url + + class ApiClient: + def get(self, path, **kwargs): + kwargs.setdefault("timeout", 30) + return session.get(f"{base_url}{path}", **kwargs) + + def post(self, path, **kwargs): + kwargs.setdefault("timeout", 30) + return session.post(f"{base_url}{path}", **kwargs) + + return ApiClient() diff --git a/api-tests/test_endpoints.py b/api-tests/test_endpoints.py new file mode 100644 index 0000000000..cf18f5451a --- /dev/null +++ b/api-tests/test_endpoints.py @@ -0,0 +1,86 @@ +"""Core API endpoint tests.""" +from urllib.parse import quote + + +def test_index_titles(api): + r = api.get("/api/index/titles") + assert r.status_code == 200 + body = r.json() + titles = body if isinstance(body, list) else body.get("books", []) + assert isinstance(titles, list) + assert len(titles) > 0 + + +def test_index_by_title(api): + r = api.get("/api/index/Genesis") + assert r.status_code == 200 + body = r.json() + assert "title" in body + assert "categories" in body + + +def test_links(api): + r = api.get(f"/api/links/{quote('Genesis 1:1')}") + assert r.status_code in (200, 404) + if r.status_code == 200: + assert isinstance(r.json(), list) + + +def test_related(api): + r = api.get(f"/api/related/{quote('Genesis 1:1')}") + assert r.status_code in (200, 404) + + +def test_terms(api): + r = api.get("/api/terms/Torah") + assert r.status_code in (200, 404) + + +def test_category(api): + r = api.get("/api/category") + assert r.status_code in (200, 404) + + +def test_translations(api): + r = api.get("/api/texts/translations") + assert r.status_code == 200 + body = r.json() + assert isinstance(body, (list, dict)) + + +def test_counts(api): + r = api.get("/api/counts/Genesis") + assert r.status_code in (200, 404) + + +def test_shape(api): + r = api.get("/api/shape/Genesis") + assert r.status_code in (200, 404) + + +def test_preview(api): + r = api.get("/api/preview/Genesis") + assert r.status_code in (200, 404) + + +def test_name(api): + r = api.get("/api/name/Genesis") + assert r.status_code in (200, 404) + + +def test_topics_list(api): + r = api.get("/api/topics") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +def test_topic_by_slug(api): + r = api.get("/api/topics/torah") + assert r.status_code in (200, 404) + + +def test_calendars(api): + r = api.get("/api/calendars") + assert r.status_code == 200 + body = r.json() + assert isinstance(body, (list, dict)) diff --git a/api-tests/test_health.py b/api-tests/test_health.py new file mode 100644 index 0000000000..1ea3e38e81 --- /dev/null +++ b/api-tests/test_health.py @@ -0,0 +1,24 @@ +"""Health and connectivity checks.""" + + +def test_healthz(api): + r = api.get("/healthz") + assert r.status_code in (200, 403) + if r.status_code == 200: + assert len(r.text) > 0 + + +def test_health_check(api): + r = api.get("/health-check") + assert r.status_code == 200 + + +def test_index_returns_toc(api): + r = api.get("/api/index") + assert r.status_code == 200 + body = r.json() + if isinstance(body, list): + assert len(body) > 0 + assert "contents" in body[0] + else: + assert "contents" in body diff --git a/api-tests/test_profile_auth.py b/api-tests/test_profile_auth.py new file mode 100644 index 0000000000..3b1eafe3d9 --- /dev/null +++ b/api-tests/test_profile_auth.py @@ -0,0 +1,35 @@ +"""Profile and authentication API tests.""" + + +def test_profile_without_auth(api): + r = api.get("/api/profile") + assert r.status_code in (200, 301, 302, 401, 403, 404) + + +def test_public_profile(api): + r = api.get("/api/profile/public") + assert r.status_code in (200, 404) + + +def test_saved_texts(api): + r = api.get("/api/user_history/saved") + assert r.status_code in (200, 401, 403) + + +def test_user_history(api): + r = api.get("/api/profile/user_history") + assert r.status_code in (200, 401, 403) + + +def test_register_endpoint_exists(api): + r = api.post("/api/register", json={ + "email": "test@example.com", + "username": "testuser", + "password": "password123", + }) + assert r.status_code < 500 + + +def test_login_refresh_endpoint_exists(api): + r = api.post("/api/login/refresh/", json={"refresh": "dummy_token"}) + assert r.status_code < 500 diff --git a/api-tests/test_search.py b/api-tests/test_search.py new file mode 100644 index 0000000000..b62b7320eb --- /dev/null +++ b/api-tests/test_search.py @@ -0,0 +1,32 @@ +"""Search API endpoint tests.""" + + +def test_search_page(api): + r = api.get("/search?q=Torah") + assert r.status_code == 200 + + +def test_search_genesis(api): + r = api.get("/search?q=Genesis") + assert r.status_code == 200 + + +def test_search_wrapper_es8(api): + r = api.get("/api/search-wrapper/es8") + assert r.status_code == 200 + + +def test_search_wrapper(api): + r = api.get("/api/search-wrapper") + assert r.status_code == 200 + + +def test_opensearch_suggestions(api): + r = api.get("/api/opensearch-suggestions?q=Gene") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +def test_search_with_limit(api): + r = api.get("/search?q=Torah&size=5") + assert r.status_code == 200 diff --git a/api-tests/test_sheets.py b/api-tests/test_sheets.py new file mode 100644 index 0000000000..26d98d0ac9 --- /dev/null +++ b/api-tests/test_sheets.py @@ -0,0 +1,60 @@ +"""Sheets API endpoint tests.""" +from urllib.parse import quote + + +def test_sheets_by_tag(api): + r = api.get("/api/sheets/tag/Torah") + assert r.status_code in (200, 504) + if r.status_code == 200: + body = r.json() + assert isinstance(body, (list, dict)) + + +def test_trending_tags(api): + r = api.get("/api/sheets/trending-tags") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +def test_tag_list(api): + r = api.get("/api/sheets/tag-list") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +def test_all_sheets(api): + r = api.get("/api/sheets/all-sheets/10/0") + assert r.status_code == 200 + body = r.json() + assert isinstance(body, (list, dict)) + + +def test_sheets_for_ref(api): + r = api.get(f"/api/sheets/ref/{quote('Genesis 1:1')}") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +def test_create_sheet_requires_auth(api): + r = api.post("/api/sheets", json={"title": "Test Sheet", "sources": []}) + assert r.status_code < 500 + + +def test_get_sheet_by_id(api): + r = api.get("/api/sheets/1") + assert r.status_code in (200, 404) + + +def test_user_sheets(api): + r = api.get("/api/sheets/user/1") + assert r.status_code == 200 + body = r.json() + assert isinstance(body, (list, dict)) + + +def test_v2_sheets_by_tag(api): + r = api.get("/api/v2/sheets/tag/Torah") + assert r.status_code in (200, 504) + if r.status_code == 200: + body = r.json() + assert isinstance(body, (list, dict)) diff --git a/api-tests/test_texts.py b/api-tests/test_texts.py new file mode 100644 index 0000000000..1b070bdf07 --- /dev/null +++ b/api-tests/test_texts.py @@ -0,0 +1,61 @@ +"""Text API endpoint tests.""" +import pytest +from urllib.parse import quote + + +TEXT_CASES = [ + ("Genesis 1:1", "First verse of Torah"), + ("Genesis 1:1-5", "First 5 verses"), + ("Exodus 20:1", "10 Commandments start"), + ("Psalms 23", "Psalm 23"), + ("Berakhot 2a", "Talmud page"), +] + + +@pytest.mark.parametrize("ref,description", TEXT_CASES, ids=[t[0] for t in TEXT_CASES]) +def test_get_text(api, ref, description): + r = api.get(f"/api/texts/{quote(ref)}") + assert r.status_code == 200 + body = r.json() + assert "ref" in body + assert "text" in body + assert "he" in body + assert "heRef" in body + assert "sections" in body + assert body["ref"] + + +def test_get_text_with_version(api): + r = api.get(f"/api/texts/{quote('Genesis 1:1')}?version=Leningrad%20Codex") + assert r.status_code == 200 + assert "ref" in r.json() + + +def test_get_text_with_commentary(api): + r = api.get(f"/api/texts/{quote('Genesis 1:1')}?commentary=1", timeout=30) + assert r.status_code in (200, 404, 504) + + +def test_get_random_text(api): + r = api.get("/api/texts/random") + assert r.status_code == 200 + body = r.json() + assert "ref" in body + assert "text" in body + + +def test_get_versions(api): + r = api.get("/api/versions") + assert r.status_code in (200, 404) + + +def test_get_text_versions(api): + r = api.get(f"/api/texts/versions/{quote('Genesis 1:1')}") + assert r.status_code == 200 + body = r.json() + assert isinstance(body, (list, dict)) + + +def test_invalid_ref(api): + r = api.get(f"/api/texts/{quote('InvalidBookName 999:999')}") + assert r.status_code in (200, 404) diff --git a/emailusernames/__init__.py b/emailusernames/__init__.py new file mode 100644 index 0000000000..8a7a1cc116 --- /dev/null +++ b/emailusernames/__init__.py @@ -0,0 +1,3 @@ +__version__ = '1.7.1' + +default_app_config = 'emailusernames.apps.EmailUsernamesConfig' diff --git a/emailusernames/admin.py b/emailusernames/admin.py new file mode 100644 index 0000000000..1ae34143b9 --- /dev/null +++ b/emailusernames/admin.py @@ -0,0 +1,37 @@ +""" +Override the add- and change-form in the admin, to hide the username. +""" +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User +from django.contrib import admin +from emailusernames.forms import EmailUserCreationForm, EmailUserChangeForm +from django.utils.translation import ugettext_lazy as _ + + +class EmailUserAdmin(UserAdmin): + add_form = EmailUserCreationForm + form = EmailUserChangeForm + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2')} + ), + ) + fieldsets = ( + (None, {'fields': ('email', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name')}), + (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (_('Groups'), {'fields': ('groups',)}), + ) + list_display = ('email', 'first_name', 'last_name', 'is_staff') + ordering = ('email',) + + +admin.site.unregister(User) +admin.site.register(User, EmailUserAdmin) + + +def __email_unicode__(self): + return self.email diff --git a/emailusernames/apps.py b/emailusernames/apps.py new file mode 100644 index 0000000000..52120dbdc4 --- /dev/null +++ b/emailusernames/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class EmailUsernamesConfig(AppConfig): + name = 'emailusernames' + verbose_name = 'Email Usernames' + + def ready(self): + # Apply monkey patches after all apps are loaded + # This is the Django 2.0+ recommended way to do monkey patching + from emailusernames.models import monkeypatch_user + monkeypatch_user() diff --git a/emailusernames/backends.py b/emailusernames/backends.py new file mode 100644 index 0000000000..97151094f2 --- /dev/null +++ b/emailusernames/backends.py @@ -0,0 +1,28 @@ +from django.contrib.auth.models import User +from django.contrib.auth.backends import ModelBackend + +from emailusernames.utils import get_user + + +class EmailAuthBackend(ModelBackend): + + """Allow users to log in with their email address""" + + def authenticate(self, request=None, email=None, password=None, **kwargs): + # Some authenticators expect to authenticate by 'username' + if email is None: + email = kwargs.get('username') + + try: + user = get_user(email) + if user.check_password(password): + user.backend = "%s.%s" % (self.__module__, self.__class__.__name__) + return user + except User.DoesNotExist: + return None + + def get_user(self, user_id): + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None diff --git a/emailusernames/forms.py b/emailusernames/forms.py new file mode 100644 index 0000000000..da85bcb0aa --- /dev/null +++ b/emailusernames/forms.py @@ -0,0 +1,129 @@ +from django import forms, VERSION +from django.contrib.auth import authenticate +from django.contrib.auth.forms import UserCreationForm, UserChangeForm, AuthenticationForm +from django.contrib.admin.forms import AdminAuthenticationForm +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ +from emailusernames.utils import user_exists + + +ERROR_MESSAGE = _("Please enter a correct email and password. ") +ERROR_MESSAGE_RESTRICTED = _("You do not have permission to access the admin.") +ERROR_MESSAGE_INACTIVE = _("This account is inactive.") + + +class EmailAuthenticationForm(AuthenticationForm): + """ + Override the default AuthenticationForm to force email-as-username behavior. + """ + email = forms.EmailField(label=_("Email"), max_length=75) + message_incorrect_password = ERROR_MESSAGE + message_inactive = ERROR_MESSAGE_INACTIVE + + def __init__(self, request=None, *args, **kwargs): + super(EmailAuthenticationForm, self).__init__(request, *args, **kwargs) + if self.fields.get('username'): + del self.fields['username'] + if hasattr(self.fields, 'keyOrder'): + # Django < 1.6 + self.fields.keyOrder = ['email', 'password'] + else: + # Django >= 1.6 + from collections import OrderedDict + fields = OrderedDict() + for key in ('email', 'password'): + fields[key] = self.fields.pop(key) + for key, value in self.fields.items(): + fields[key] = value + self.fields = fields + + def clean(self): + email = self.cleaned_data.get('email') + password = self.cleaned_data.get('password') + + if email and password: + self.user_cache = authenticate(email=email, password=password) + if (self.user_cache is None): + raise forms.ValidationError(self.message_incorrect_password) + if not self.user_cache.is_active: + raise forms.ValidationError(self.message_inactive) + # check_for_test_cookie was removed in django 1.7 + if hasattr(self, 'check_for_test_cookie'): + self.check_for_test_cookie() + return self.cleaned_data + + +class EmailAdminAuthenticationForm(AdminAuthenticationForm): + """ + Override the default AuthenticationForm to force email-as-username behavior. + """ + email = forms.EmailField(label=_("Email"), max_length=75) + message_incorrect_password = ERROR_MESSAGE + message_inactive = ERROR_MESSAGE_INACTIVE + message_restricted = ERROR_MESSAGE_RESTRICTED + + def __init__(self, *args, **kwargs): + super(EmailAdminAuthenticationForm, self).__init__(*args, **kwargs) + if self.fields.get('username'): + del self.fields['username'] + + def clean(self): + email = self.cleaned_data.get('email') + password = self.cleaned_data.get('password') + + if email and password: + self.user_cache = authenticate(email=email, password=password) + if (self.user_cache is None): + raise forms.ValidationError(self.message_incorrect_password) + if not self.user_cache.is_active: + raise forms.ValidationError(self.message_inactive) + if not self.user_cache.is_staff: + raise forms.ValidationError(self.message_restricted) + # check_for_test_cookie was removed in django 1.7 + if hasattr(self, 'check_for_test_cookie'): + self.check_for_test_cookie() + return self.cleaned_data + + +class EmailUserCreationForm(UserCreationForm): + """ + Override the default UserCreationForm to force email-as-username behavior. + """ + email = forms.EmailField(label=_("Email"), max_length=75) + + class Meta: + model = User + fields = ("email",) + + def __init__(self, *args, **kwargs): + super(EmailUserCreationForm, self).__init__(*args, **kwargs) + if self.fields.get('username'): + del self.fields['username'] + + def clean_email(self): + email = self.cleaned_data["email"] + if user_exists(email): + raise forms.ValidationError(_("A user with that email already exists.")) + return email + + def save(self, commit=True): + # Ensure that the username is set to the email address provided, + # so the user_save_patch() will keep things in sync. + self.instance.username = self.instance.email + return super(EmailUserCreationForm, self).save(commit=commit) + + +class EmailUserChangeForm(UserChangeForm): + """ + Override the default UserChangeForm to force email-as-username behavior. + """ + email = forms.EmailField(label=_("Email"), max_length=75) + + class Meta: + model = User + fields = '__all__' + + def __init__(self, *args, **kwargs): + super(EmailUserChangeForm, self).__init__(*args, **kwargs) + if self.fields.get('username'): + del self.fields['username'] diff --git a/emailusernames/management/__init__.py b/emailusernames/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/emailusernames/management/commands/__init__.py b/emailusernames/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/emailusernames/management/commands/createsuperuser.py b/emailusernames/management/commands/createsuperuser.py new file mode 100644 index 0000000000..f8b607d38a --- /dev/null +++ b/emailusernames/management/commands/createsuperuser.py @@ -0,0 +1,109 @@ +""" +Management utility to create superusers. +Replace default behaviour to use emails as usernames. +""" + +import getpass +import re +import sys +from optparse import make_option +from django.contrib.auth.models import User +from django.core import exceptions +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import ugettext as _ +from emailusernames.utils import get_user, create_superuser + +def is_valid_email(value): + # copied from https://github.com/django/django/blob/1.5.1/django/core/validators.py#L98 + email_re = re.compile( + r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom + # quoted-string, see also http://tools.ietf.org/html/rfc2822#section-3.2.5 + r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*"' + r')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)$)' # domain + r'|\[(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\]$', re.IGNORECASE) # literal form, ipv4 address (SMTP 4.1.3) + + if not email_re.search(value): + raise exceptions.ValidationError(_('Enter a valid e-mail address.')) + + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('--email', dest='email', default=None, + help='Specifies the email address for the superuser.'), + make_option('--noinput', action='store_false', dest='interactive', default=True, + help=('Tells Django to NOT prompt the user for input of any kind. ' + 'You must use --username and --email with --noinput, and ' + 'superusers created with --noinput will not be able to log ' + 'in until they\'re given a valid password.')), + ) + help = 'Used to create a superuser.' + + def handle(self, *args, **options): + email = options.get('email', None) + interactive = options.get('interactive') + verbosity = int(options.get('verbosity', 1)) + + # Do quick and dirty validation if --noinput + if not interactive: + if not email: + raise CommandError("You must use --email with --noinput.") + try: + is_valid_email(email) + except exceptions.ValidationError: + raise CommandError("Invalid email address.") + + # If not provided, create the user with an unusable password + password = None + + # Prompt for username/email/password. Enclose this whole thing in a + # try/except to trap for a keyboard interrupt and exit gracefully. + if interactive: + try: + # Get an email + while 1: + if not email: + email = raw_input('E-mail address: ') + + try: + is_valid_email(email) + except exceptions.ValidationError: + sys.stderr.write("Error: That e-mail address is invalid.\n") + email = None + continue + + try: + get_user(email) + except User.DoesNotExist: + break + else: + sys.stderr.write("Error: That email is already taken.\n") + email = None + + # Get a password + while 1: + if not password: + password = getpass.getpass() + password2 = getpass.getpass('Password (again): ') + if password != password2: + sys.stderr.write("Error: Your passwords didn't match.\n") + password = None + continue + if password.strip() == '': + sys.stderr.write("Error: Blank passwords aren't allowed.\n") + password = None + continue + break + except KeyboardInterrupt: + sys.stderr.write("\nOperation cancelled.\n") + sys.exit(1) + + # Make Django's tests work by accepting a username through + # call_command() but not through manage.py + username = options.get('username', None) + if username is None: + create_superuser(email, password) + else: + User.objects.create_superuser(username, email, password) + + if verbosity >= 1: + self.stdout.write("Superuser created successfully.\n") diff --git a/emailusernames/management/commands/dumpdata.py b/emailusernames/management/commands/dumpdata.py new file mode 100644 index 0000000000..0c7b208fe1 --- /dev/null +++ b/emailusernames/management/commands/dumpdata.py @@ -0,0 +1,16 @@ +from django.core.management.commands import dumpdata +from emailusernames.models import unmonkeypatch_user, monkeypatch_user + + +class Command(dumpdata.Command): + + """ + Override the built-in dumpdata command to un-monkeypatch the User + model before dumping, to allow usernames to be dumped correctly + """ + + def handle(self, *args, **kwargs): + unmonkeypatch_user() + ret = super(Command, self).handle(*args, **kwargs) + monkeypatch_user() + return ret diff --git a/emailusernames/management/commands/loaddata.py b/emailusernames/management/commands/loaddata.py new file mode 100644 index 0000000000..542d1b1927 --- /dev/null +++ b/emailusernames/management/commands/loaddata.py @@ -0,0 +1,17 @@ +from django.core.management.commands import loaddata +from emailusernames.models import unmonkeypatch_user, monkeypatch_user + + +class Command(loaddata.Command): + + """ + Override the built-in loaddata command to un-monkeypatch the User + model before loading, to allow usernames to be loaded correctly + """ + + def handle(self, *args, **kwargs): + unmonkeypatch_user() + ret = super(Command, self).handle(*args, **kwargs) + monkeypatch_user() + return ret + diff --git a/emailusernames/models.py b/emailusernames/models.py new file mode 100644 index 0000000000..caf46d7710 --- /dev/null +++ b/emailusernames/models.py @@ -0,0 +1,72 @@ +from django.contrib.admin.sites import AdminSite +from django.contrib.auth.models import User +from emailusernames.forms import EmailAdminAuthenticationForm +from emailusernames.utils import _email_to_username + + +# Horrible monkey patching. +# User.username always presents as the email, but saves as a hash of the email. +# It would be possible to avoid such a deep level of monkey-patching, +# but Django's admin displays the "Welcome, username" using user.username, +# and there's really no other way to get around it. +def user_init_patch(self, *args, **kwargs): + super(User, self).__init__(*args, **kwargs) + self._username = self.username + if self.username == _email_to_username(self.email): + # Username should be replaced by email, since the hashes match + self.username = self.email + + +def user_save_patch(self, *args, **kwargs): + email_as_username = (self.username.lower() == self.email.lower()) + if self.pk is not None: + try: + old_user = self.__class__.objects.get(pk=self.pk) + email_as_username = ( + email_as_username or + ('@' in self.username and old_user.username == old_user.email) + ) + except self.__class__.DoesNotExist: + pass + + if email_as_username: + self.username = _email_to_username(self.email) + try: + super(User, self).save_base(*args, **kwargs) + finally: + if email_as_username: + self.username = self.email + + +original_init = User.__init__ +original_save_base = User.save_base + +_patched = False + + +def monkeypatch_user(): + global _patched + if _patched: + # Django's test database creation can overwrite User.__dict__['__init__'] + # with Model.__init__, removing our patch. Check and re-apply if needed. + if User.__dict__.get('__init__') is not user_init_patch: + User.__init__ = user_init_patch + User.save_base = user_save_patch + return + User.__init__ = user_init_patch + User.save_base = user_save_patch + _patched = True + + +def unmonkeypatch_user(): + User.__init__ = original_init + User.save_base = original_save_base + + +# Note: monkeypatch_user() is now called from EmailUsernamesConfig.ready() +# in apps.py to ensure proper timing with Django 2.0+ app loading + + +# Monkey-path the admin site to use a custom login form +AdminSite.login_form = EmailAdminAuthenticationForm +AdminSite.login_template = 'email_usernames/login.html' diff --git a/emailusernames/templates/email_usernames/login.html b/emailusernames/templates/email_usernames/login.html new file mode 100644 index 0000000000..c1a05b1de2 --- /dev/null +++ b/emailusernames/templates/email_usernames/login.html @@ -0,0 +1,53 @@ +{% extends "admin/login.html" %} +{% load i18n %} + +{% block extrastyle %}{{ block.super }} + +{% endblock %} + +{% block content %} +{% if form.errors and not form.non_field_errors %} +
+{% blocktrans count counter=form.errors.items|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} +
+{% endif %} + +{% if form.non_field_errors %} +{% for error in form.non_field_errors %} ++ {{ error }} +
+{% endfor %} +{% endif %} + +