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 %} + +
+
{% csrf_token %} +
+ {{ form.email.errors }} + {{ form.email }} +
+
+ {{ form.password.errors }} + {{ form.password }} + +
+ {% url 'admin_password_reset' as password_reset_url %} + {% if password_reset_url %} + + {% endif %} +
+ +
+
+ + +
+{% endblock %} diff --git a/emailusernames/tests.py b/emailusernames/tests.py new file mode 100644 index 0000000000..2d698214b9 --- /dev/null +++ b/emailusernames/tests.py @@ -0,0 +1,151 @@ +from django.contrib.auth import authenticate +from django.contrib.auth.models import User +from django.db import IntegrityError, connection +from django.test import TestCase +from emailusernames.utils import create_user, get_user, _email_to_username +from emailusernames.models import _patched + + +class CreateUserTests(TestCase): + """ + Tests which create users. + """ + def setUp(self): + self.email = 'user@example.com' + self.password = 'password' + + def test_can_create_user(self): + user = create_user(self.email, self.password) + self.assertEqual(list(User.objects.all()), [user]) + + def test_can_create_user_with_long_email(self): + padding = 'a' * 30 + create_user(padding + self.email, self.password) + + def test_created_user_has_correct_details(self): + user = create_user(self.email, self.password) + self.assertEqual(user.email, self.email) + + def test_can_create_user_with_explicit_id(self): + """Regression test for + https://github.com/dabapps/django-email-as-username/issues/52 + + """ + User.objects.create(email=self.email, id=1) + + + +class ExistingUserTests(TestCase): + """ + Tests which require an existing user. + """ + + def setUp(self): + self.email = 'user@example.com' + self.password = 'password' + self.user = create_user(self.email, self.password) + + def test_user_can_authenticate(self): + auth = authenticate(email=self.email, password=self.password) + self.assertEqual(self.user, auth) + + def test_user_can_authenticate_with_case_insensitive_match(self): + auth = authenticate(email=self.email.upper(), password=self.password) + self.assertEqual(self.user, auth) + + def test_user_can_authenticate_with_username_parameter(self): + auth = authenticate(username=self.email, password=self.password) + self.assertEqual(self.user, auth) + # Invalid username should be ignored + auth = authenticate(email=self.email, password=self.password, + username='invalid') + self.assertEqual(self.user, auth) + + def test_user_emails_are_unique(self): + with self.assertRaises(IntegrityError) as ctx: + create_user(self.email, self.password) + if hasattr(ctx.exception, 'message'): + self.assertEqual(ctx.exception.message, 'user email is not unique') + else: + self.assertEqual(str(ctx.exception), 'user email is not unique') + + def test_user_emails_are_case_insensitive_unique(self): + with self.assertRaises(IntegrityError) as ctx: + create_user(self.email.upper(), self.password) + if hasattr(ctx.exception, 'message'): + self.assertEqual(ctx.exception.message, 'user email is not unique') + else: + self.assertEqual(str(ctx.exception), 'user email is not unique') + + def test_user_unicode(self): + if isinstance(self.email, str): + self.assertEqual(str(self.user), self.email) + else: + raise AssertionError("Test email is not a str type") + + +class MonkeyPatchTests(TestCase): + """ + Tests for the User model monkey patching that enables email-as-username. + + The monkey patch ensures: + - Usernames are stored as hashes in the database (to fit in 30 char limit) + - Usernames are displayed as emails in Python (for UX) + - Users can be looked up by email via get_user() + """ + + def setUp(self): + self.email = 'monkeypatch_test@example.com' + self.password = 'testpassword123' + + def test_monkey_patch_is_applied(self): + """Verify that the monkey patch has been applied via AppConfig.ready()""" + self.assertTrue(_patched, "Monkey patch should be applied after Django setup") + self.assertEqual(User.__init__.__name__, 'user_init_patch') + self.assertEqual(User.save_base.__name__, 'user_save_patch') + + def test_username_stored_as_hash_in_database(self): + """Verify that the username is stored as a hash, not the raw email""" + user = create_user(self.email, self.password) + expected_hash = _email_to_username(self.email) + + # Query the database directly to see what's actually stored + with connection.cursor() as cursor: + cursor.execute('SELECT username FROM auth_user WHERE id = %s', [user.pk]) + row = cursor.fetchone() + + actual_username_in_db = row[0] + self.assertEqual(actual_username_in_db, expected_hash, + "Username in database should be the hash, not the raw email") + self.assertNotEqual(actual_username_in_db, self.email, + "Username in database should NOT be the raw email") + + def test_username_displayed_as_email_in_python(self): + """Verify that user.username returns the email for display purposes""" + user = create_user(self.email, self.password) + + # The username attribute should show the email (for admin "Welcome, username") + self.assertEqual(user.username, self.email, + "user.username should display as email after creation") + + # Reload from database and verify it still shows as email + reloaded_user = User.objects.get(pk=user.pk) + self.assertEqual(reloaded_user.username, self.email, + "user.username should display as email after loading from DB") + + def test_get_user_lookup_by_email_works(self): + """Verify that get_user() can find users by their email address""" + user = create_user(self.email, self.password) + + # get_user internally converts email to hash for lookup + found_user = get_user(self.email) + self.assertEqual(found_user.pk, user.pk, + "get_user() should find the user by email") + + def test_get_user_lookup_case_insensitive(self): + """Verify that get_user() lookup is case-insensitive""" + user = create_user(self.email, self.password) + + found_user = get_user(self.email.upper()) + self.assertEqual(found_user.pk, user.pk, + "get_user() should find the user with case-insensitive email") diff --git a/emailusernames/utils.py b/emailusernames/utils.py new file mode 100644 index 0000000000..eff5a87da7 --- /dev/null +++ b/emailusernames/utils.py @@ -0,0 +1,117 @@ +import base64 +import hashlib +import os +import re +import sys + +from django.contrib.auth.models import User +from django.db import IntegrityError + + +# We need to convert emails to hashed versions when we store them in the +# username field. We can't just store them directly, or we'd be limited +# to Django's username <= 30 chars limit, which is really too small for +# arbitrary emails. +def _email_to_username(email): + # Emails should be case-insensitive unique + email = email.lower() + # Deal with internationalized email addresses + converted = email.encode('utf8', 'ignore') + return base64.urlsafe_b64encode(hashlib.sha256(converted).digest()).decode('ascii')[:30] + + +def get_user(email, queryset=None): + """ + Return the user with given email address. + Note that email address matches are case-insensitive. + """ + if queryset is None: + queryset = User.objects + return queryset.get(username=_email_to_username(email)) + + +def user_exists(email, queryset=None): + """ + Return True if a user with given email address exists. + Note that email address matches are case-insensitive. + """ + try: + get_user(email, queryset) + except User.DoesNotExist: + return False + return True + + +_DUPLICATE_USERNAME_ERRORS = ( + 'column username is not unique', + 'UNIQUE constraint failed: auth_user.username', + 'duplicate key value violates unique constraint "auth_user_username_key"\n' +) + + +def create_user(email, password=None, is_staff=None, is_active=None): + """ + Create a new user with the given email. + Use this instead of `User.objects.create_user`. + """ + try: + user = User.objects.create_user(email, email, password) + except IntegrityError as err: + regexp = '|'.join(re.escape(e) for e in _DUPLICATE_USERNAME_ERRORS) + if re.match(regexp, str(err)): + raise IntegrityError('user email is not unique') + raise + + if is_active is not None or is_staff is not None: + if is_active is not None: + user.is_active = is_active + if is_staff is not None: + user.is_staff = is_staff + user.save() + return user + + +def create_superuser(email, password): + """ + Create a new superuser with the given email. + Use this instead of `User.objects.create_superuser`. + """ + return User.objects.create_superuser(email, email, password) + + +def migrate_usernames(stream=None, quiet=False): + """ + Migrate all existing users to django-email-as-username hashed usernames. + If any users cannot be migrated an exception will be raised and the + migration will not run. + + @TODO: delete this function and associated management command after + """ + stream = stream or (quiet and open(os.devnull, 'w') or sys.stdout) + + # Check all users can be migrated before applying migration + emails = set() + errors = [] + for user in User.objects.all(): + if not user.email: + errors.append("Cannot convert user '%s' because email is not " + "set." % (user._username, )) + elif user.email.lower() in emails: + errors.append("Cannot convert user '%s' because email '%s' " + "already exists." % (user._username, user.email)) + else: + emails.add(user.email.lower()) + + # Cannot migrate. + if errors: + [stream.write(error + '\n') for error in errors] + raise Exception('django-email-as-username migration failed.') + + # Can migrate just fine. + total = User.objects.count() + for user in User.objects.all(): + user.username = _email_to_username(user.email) + user.save() + + stream.write("Successfully migrated usernames for all %d users\n" + % (total, )) diff --git a/remote_config/cache.py b/remote_config/cache.py index ae2758b0bc..0b6d6e1bda 100644 --- a/remote_config/cache.py +++ b/remote_config/cache.py @@ -44,7 +44,7 @@ def get_cache(self) -> dict[str, Any]: if self._cache is None: # double-checked locking for minimal contention try: self._cache = self._build_cache() - except OperationalError: + except (OperationalError, RuntimeError): self._cache = {} return self._cache diff --git a/requirements.txt b/requirements.txt index 1fd27bcf15..8fbbfd06fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,89 +1,407 @@ -Appium-Python-Client==1.2.0 -Cerberus -cryptography==42.0.7 -PyJWT==1.7.1 # pinned b/c current version 2.0.0 breaks simplejwt. waiting for 2.0.1 -babel -django-admin-sortable==2.1.13 +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile --output-file=/tmp/requirements.txt ./requirements.txt +# +amqp==5.3.1 + # via kombu +appium-python-client==1.2.0 + # via -r ./requirements.txt +appnope==0.1.4 + # via ipython +apscheduler==3.6.3 + # via -r ./requirements.txt +attrs==25.1.0 + # via pytest +babel==2.17.0 + # via -r ./requirements.txt +backcall==0.2.0 + # via ipython +beautifulsoup4==4.13.3 + # via bs4 +billiard<5.0,>=4.2.1 + # via celery bleach==1.4.2 + # via -r ./requirements.txt boto3==1.16.6 + # via -r ./requirements.txt +botocore==1.19.63 + # via + # boto3 + # s3transfer bs4==0.0.1 + # via -r ./requirements.txt +cachetools==4.2.4 + # via google-auth celery[redis]==5.5.2 + # via -r ./requirements.txt +cerberus==1.3.7 + # via -r ./requirements.txt +certifi==2025.1.31 + # via + # elastic-transport + # requests + # sentry-sdk +charset-normalizer==3.4.1 + # via requests +click==8.1.8 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl +click-didyoumean==0.3.1 + # via celery +click-plugins==1.1.1 + # via celery +click-repl==0.3.0 + # via celery convertdate==2.2.2 + # via -r ./requirements.txt cython==0.29.14 + # via -r ./requirements.txt dateutils==0.6.12 + # via -r ./requirements.txt datrie==0.8.2 + # via -r ./requirements.txt +decorator==5.1.1 + # via ipython deepdiff==3.3.0 -diff_match_patch==20200713 -django-anymail==7.2.* -django-debug-toolbar==2.2 # not used in prod + # via -r ./requirements.txt +diff-match-patch==20200713 + # via -r ./requirements.txt +dill==0.3.9 + # via + # multiprocess + # pathos +django==2.2.28 + # via + # -r ./requirements.txt + # django-anymail + # django-debug-toolbar + # django-recaptcha + # django-redis + # django-structlog + # django-user-agents + # djangorestframework + # djangorestframework-simplejwt +django-admin-sortable==2.3 +django-anymail==8.6 + # via -r ./requirements.txt +django-debug-toolbar==2.2 + # via -r ./requirements.txt +django-hosts==7.0.0 +django-ipware==7.0.1 + # via django-structlog +django-mobile==0.7.0 + # via -r ./requirements.txt django-recaptcha==2.0.6 -django-redis==4.11.* + # via -r ./requirements.txt +django-redis==4.11.0 + # via -r ./requirements.txt django-structlog==1.6.2 + # via -r ./requirements.txt django-user-agents==0.4.0 + # via -r ./requirements.txt django-webpack-loader==1.4.1 -django==1.11.* -django_mobile==0.7.0 -djangorestframework @ https://github.com/encode/django-rest-framework/archive/3.11.1.tar.gz -djangorestframework_simplejwt==3.3.0 -django-hosts==7.0.0 -dnspython~=2.5.0 + # via -r ./requirements.txt +djangorestframework==3.13.1 + # via + # -r ./requirements.txt + # djangorestframework-simplejwt +djangorestframework-simplejwt==5.2.2 + # via -r ./requirements.txt +dnspython==2.5.0 + # via -r ./requirements.txt +docopt==0.4.0 + # via mailchimp +elastic-transport==8.17.0 + # via elasticsearch elasticsearch==8.8.2 + # via + # -r ./requirements.txt + # elasticsearch-dsl +elasticsearch-dsl @ git+https://github.com/Sefaria/elasticsearch-dsl-py@v8.0.0 + # via -r ./requirements.txt +geographiclib==2.0 + # via geopy geojson==2.5.0 + # via -r ./requirements.txt geopy==2.3.0 + # via -r ./requirements.txt gevent==20.12.0; sys_platform != 'darwin' google-analytics-data==0.9.0 -git+https://github.com/Sefaria/LLM@v1.3.6#egg=sefaria_llm_interface&subdirectory=app/llm_interface -git+https://github.com/Sefaria/elasticsearch-dsl-py@v8.0.0#egg=elasticsearch-dsl -git+https://github.com/Sefaria/ne_span.git@v1.0.2 -langchain-openai==0.2.14 -langchain-anthropic==0.3.22 -langchain-core==0.3.83 -langchain-community==0.3.31 -langsmith==0.4.4 +google-api-core[grpc]==1.27.0 + # via + # google-api-python-client + # google-cloud-core + # google-cloud-logging google-api-python-client==1.12.5 -google-auth-oauthlib==0.4.2 + # via -r ./requirements.txt google-auth==1.24.0 + # via + # -r ./requirements.txt + # google-api-core + # google-api-python-client + # google-auth-httplib2 + # google-auth-oauthlib + # google-cloud-core + # google-cloud-storage +google-auth-httplib2==0.1.0 + # via google-api-python-client +google-auth-oauthlib==0.4.2 + # via -r ./requirements.txt +google-cloud-core==1.7.3 + # via + # google-cloud-logging + # google-cloud-storage google-cloud-logging==1.15.1 + # via -r ./requirements.txt google-cloud-storage==1.32.0 -google-re2 + # via -r ./requirements.txt +google-crc32c==1.6.0 + # via google-resumable-media +google-re2==1.1.20240702 + # via -r ./requirements.txt +google-resumable-media==1.3.3 + # via google-cloud-storage +googleapis-common-protos==1.66.0 + # via google-api-core +grpcio==1.70.0 + # via google-api-core gunicorn==23.0.0 -setuptools==69.5.1 + # via -r ./requirements.txt html5lib==0.9999999 + # via + # -r ./requirements.txt + # bleach httplib2==0.18.1 -ipython==7.34.* -jedi==0.18.1 # Ipython was previosuly pinned at 7.18 because Jedi 0.18 broke it. This is currently the latest version. + # via + # -r ./requirements.txt + # google-api-python-client + # google-auth-httplib2 +idna==3.10 + # via requests +importlib-metadata==8.6.1 + # via jsonpickle +iniconfig==2.0.0 + # via pytest +ipython==7.34.0 + # via -r ./requirements.txt +jedi==0.18.1 + # via + # -r ./requirements.txt + # ipython +jmespath==0.10.0 + # via + # boto3 + # botocore jsonpickle==1.4.1 + # via + # -r ./requirements.txt + # deepdiff +kombu<5.6,>=5.5.2 + # via celery lxml==4.6.1 + # via -r ./requirements.txt mailchimp==2.0.9 + # via -r ./requirements.txt +matplotlib-inline==0.1.7 + # via ipython +multiprocess==0.70.17 + # via pathos +oauthlib==3.2.2 + # via requests-oauthlib p929==0.6.2 + # via -r ./requirements.txt +packaging==24.2 + # via + # google-api-core + # pytest +parso==0.8.4 + # via jedi pathos==0.2.6 + # via -r ./requirements.txt +pexpect==4.9.0 + # via ipython +pickleshare==0.7.5 + # via ipython pillow==10.0.1; sys_platform != 'linux' pillow==8.0.1; sys_platform == 'linux' -psycopg2==2.8.6 #for dev: psycopg2-binary==2.8.6 + # via -r ./requirements.txt +pluggy==1.5.0 + # via pytest +pox==0.3.5 + # via pathos +ppft==1.7.6.9 + # via pathos +prompt-toolkit==3.0.50 + # via + # click-repl + # ipython +protobuf==5.29.3 + # via + # google-api-core + # googleapis-common-protos +psycopg2-binary==2.8.6 #for dev: psycopg2-binary==2.8.6 +ptyprocess==0.7.0 + # via pexpect +py==1.11.0 + # via pytest py2-py3-django-email-as-username==1.7.1 +pyasn1==0.6.1 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.4.1 + # via google-auth +pygments==2.19.1 + # via ipython +pyjwt==1.7.1 + # via + # -r ./requirements.txt + # djangorestframework-simplejwt +pymeeus==0.5.12 + # via convertdate pymongo==4.15.* + # via -r ./requirements.txt pytest~=7.4.4 -python-bidi -pytz + # via + # -r ./requirements.txt + # pytest-django +pytest-django==4.9.0 + # via -r ./requirements.txt +python-bidi==0.6.3 + # via -r ./requirements.txt +python-dateutil==2.9.0.post0 + # via + # botocore + # dateutils + # elasticsearch-dsl +python-ipware==3.0.0 + # via django-ipware +pytz==2025.1 + # via + # -r ./requirements.txt + # apscheduler + # celery + # convertdate + # dateutils + # django + # djangorestframework + # google-api-core pyyaml==6.0.1 + # via -r ./requirements.txt rauth==0.7.3 + # via -r ./requirements.txt regex==2024.9.11 -requests + # via -r ./requirements.txt +requests==2.32.5 + # via + # -r ./requirements.txt + # django-anymail + # google-api-core + # google-cloud-storage + # mailchimp + # rauth + # requests-oauthlib +requests-oauthlib==2.0.0 + # via google-auth-oauthlib roman==3.3 + # via -r ./requirements.txt +rsa==4.9 + # via google-auth +s3transfer==0.3.7 + # via boto3 +sefaria-llm-interface @ git+https://github.com/Sefaria/LLM@v1.3.6#subdirectory=app/llm_interface + # via -r ./requirements.txt selenium==3.141.0 + # via + # -r ./requirements.txt + # appium-python-client sentry-sdk==2.54.0 + # via -r ./requirements.txt +six==1.17.0 + # via + # apscheduler + # bleach + # google-api-core + # google-api-python-client + # google-auth + # google-auth-httplib2 + # google-cloud-core + # google-resumable-media + # html5lib + # python-dateutil +soupsieve==2.6 + # via beautifulsoup4 +sqlparse==0.5.3 + # via + # django + # django-debug-toolbar +structlog==25.1.0 + # via django-structlog +tomli==2.2.1 + # via pytest tqdm==4.51.0 + # via -r ./requirements.txt +traitlets==5.14.3 + # via + # ipython + # matplotlib-inline +typing-extensions==4.12.2 + # via + # beautifulsoup4 + # kombu + # structlog +tzdata==2025.2 + # via + # celery + # kombu +tzlocal==5.2 + # via apscheduler ua-parser==0.10.0 + # via + # -r ./requirements.txt + # user-agents undecorated==0.3.0 + # via -r ./requirements.txt unicodecsv==0.14.1 + # via -r ./requirements.txt unidecode==1.1.1 + # via -r ./requirements.txt +uritemplate==3.0.1 + # via google-api-python-client +urllib3==1.26.20 + # via + # botocore + # elastic-transport + # requests + # selenium + # sentry-sdk user-agents==2.2.0 -pytest-django==4.9.* + # via + # -r ./requirements.txt + # django-user-agents +vine==5.1.0 + # via + # amqp + # celery + # kombu +wcwidth==0.2.13 + # via prompt-toolkit +zipp==3.21.0 + # via importlib-metadata + +cryptography==42.0.7 +# Additional packages from master redis==5.2.1 +git+https://github.com/Sefaria/ne_span.git@v1.0.2 +langchain-openai==0.2.14 +langchain-anthropic==0.3.22 +langchain-core==0.3.83 +langchain-community==0.3.31 +langsmith==0.4.4 +setuptools==69.5.1 Pympler==1.1 - - -#opentelemetry-distro -#opentelemetry-exporter-otlp -#opentelemetry-propagator-b3 -#opentelemetry-propagator-jaeger diff --git a/sefaria/decorators.py b/sefaria/decorators.py index c63d2d5fe2..668ec2aeec 100644 --- a/sefaria/decorators.py +++ b/sefaria/decorators.py @@ -12,7 +12,7 @@ def _wrapped_view(request, *args, **kwargs): auth_header = request.META.get("HTTP_AUTHORIZATION") if not auth_header or not auth_header.startswith("Basic "): - return staff_member_required(view_func)(request, *args, **kwargs) + return staff_member_required(view_func, login_url='/login')(request, *args, **kwargs) try: encoded_credentials = auth_header.split(" ")[1] diff --git a/sefaria/gauth/views.py b/sefaria/gauth/views.py index 1f6ec6e839..7a05a2f3c1 100644 --- a/sefaria/gauth/views.py +++ b/sefaria/gauth/views.py @@ -3,7 +3,7 @@ import json from django.contrib.auth.decorators import login_required -from django.core.urlresolvers import reverse +from django.urls import reverse from django.http import HttpResponseBadRequest from django.shortcuts import redirect diff --git a/sefaria/helper/crm/dummy_crm.py b/sefaria/helper/crm/dummy_crm.py index 50e7e4be86..b3560e0316 100644 --- a/sefaria/helper/crm/dummy_crm.py +++ b/sefaria/helper/crm/dummy_crm.py @@ -28,3 +28,6 @@ def subscribe_to_lists(self, email, first_name=None, last_name=None, educator=Fa def find_crm_id(self, email=None): CrmConnectionManager.find_crm_id(self, email=email) return True + + def get_available_lists(self) -> list[str]: + return ["Dummy List 1", "Dummy List 2"] diff --git a/sefaria/system/cache.py b/sefaria/system/cache.py index 23450df930..c7082b03b4 100644 --- a/sefaria/system/cache.py +++ b/sefaria/system/cache.py @@ -92,7 +92,7 @@ def wrapper(*args, **kwargs): else: #logger.debug(['_cach_key.......',_cache_key]) result = get_cache_elem(_cache_key, cache_type=cache_type) - if decorate_data_with_key: + if decorate_data_with_key and result is not None: result = result["data"] if not result: diff --git a/sefaria/system/decorators.py b/sefaria/system/decorators.py index c6e902e694..eae3c51719 100644 --- a/sefaria/system/decorators.py +++ b/sefaria/system/decorators.py @@ -2,8 +2,7 @@ from typing import Any from django.http import HttpResponse, Http404 -from django.template import RequestContext -from django.shortcuts import render_to_response +from django.shortcuts import render from sefaria.client.util import jsonResponse import sefaria.system.exceptions as exps @@ -62,8 +61,8 @@ def wrapper(*args, **kwargs): raise except Exception as e: logger.exception("An exception occurred processing request for '{}' while running {}. Caught as HTTP".format(args[0].path, func.__name__)) - return render_to_response(args[0], 'static/generic.html', - {"content": "There was an error processing your request: {}".format(str(e))}) + return render(args[0], 'static/generic.html', + {"content": "There was an error processing your request: {}".format(str(e))}) return result return wrapper diff --git a/sefaria/urls_shared.py b/sefaria/urls_shared.py index dcf190ea87..b0893ea501 100644 --- a/sefaria/urls_shared.py +++ b/sefaria/urls_shared.py @@ -1,4 +1,5 @@ -from django.conf.urls import url, include +from django.urls import re_path +from django.conf.urls import url from django.contrib import admin from sefaria.settings import ADMIN_PATH import reader.views as reader_views @@ -272,7 +273,7 @@ url(r'^admin/descriptions/authors/update', sefaria_views.update_authors_from_sheet), url(r'^admin/descriptions/categories/update', sefaria_views.update_categories_from_sheet), url(r'^admin/descriptions/texts/update', sefaria_views.update_texts_from_sheet), - url(fr'^{ADMIN_PATH}/?', include(admin.site.urls)), + re_path(r'{ADMIN_PATH}/?', admin.site.urls), url(r'^(?P[^/]+)/(?P\w\w)/(?P.*)$', reader_views.old_versions_redirect), url(r'^api/remote-config/?$', remote_config_views.remote_config_values, name="remote_config_api"), url(r'^api/async/(?P.+)$', sefaria_views.async_task_status_api), @@ -287,7 +288,8 @@ # Keep admin accessible maintenance_patterns = [ url(r'^admin/reset/cache', sefaria_views.reset_cache), - url(r'^admin/?', include(admin.site.urls)), + re_path(r'admin/?', admin.site.urls), + re_path(r'{ADMIN_PATH}/?', admin.site.urls), url(r'^healthz/?$', reader_views.application_health_api), # this oddly is returning 'alive' when it's not. is k8s jumping in the way? url(r'^health-check/?$', reader_views.application_health_api), url(r'^healthz-rollout/?$', reader_views.rollout_health_api),