diff --git a/django/conf/__init__.py b/django/conf/__init__.py index b32e56184d..ec7efadf46 100644 --- a/django/conf/__init__.py +++ b/django/conf/__init__.py @@ -15,7 +15,8 @@ from pathlib import Path import django from django.conf import global_settings -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.validators import URLValidator from django.utils.deprecation import RemovedInDjango40Warning from django.utils.functional import LazyObject, empty @@ -109,6 +110,26 @@ class LazySettings(LazyObject): setattr(holder, name, value) self._wrapped = holder + @staticmethod + def _add_script_prefix(value): + """ + Add SCRIPT_NAME prefix to relative paths. + + Useful when the app is being served at a subpath and manually prefixing + subpath to STATIC_URL and MEDIA_URL in settings is inconvenient. + """ + # Don't apply prefix to valid URLs. + try: + URLValidator()(value) + return value + except (ValidationError, AttributeError): + pass + # Don't apply prefix to absolute paths. + if value.startswith('/'): + return value + from django.urls import get_script_prefix + return '%s%s' % (get_script_prefix(), value) + @property def configured(self): """Return True if the settings have already been configured.""" @@ -128,6 +149,14 @@ class LazySettings(LazyObject): ) return self.__getattr__('PASSWORD_RESET_TIMEOUT_DAYS') + @property + def STATIC_URL(self): + return self._add_script_prefix(self.__getattr__('STATIC_URL')) + + @property + def MEDIA_URL(self): + return self._add_script_prefix(self.__getattr__('MEDIA_URL')) + class Settings: def __init__(self, settings_module): diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 00538ccbf7..4405d152b2 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1989,6 +1989,13 @@ Example: ``"http://media.example.com/"`` :setting:`MEDIA_URL` and :setting:`STATIC_URL` must have different values. See :setting:`MEDIA_ROOT` for more details. +.. note:: + + If :setting:`MEDIA_URL` is a relative path, then it will be prefixed by the + server-provided value of ``SCRIPT_NAME`` (or ``/`` if not set). This makes + it easier to serve a Django application in a subpath without adding an + extra configuration to the settings. + .. setting:: MIDDLEWARE ``MIDDLEWARE`` @@ -3306,6 +3313,13 @@ You may need to :ref:`configure these files to be served in development ` and will definitely need to do so :doc:`in production `. +.. note:: + + If :setting:`STATIC_URL` is a relative path, then it will be prefixed by + the server-provided value of ``SCRIPT_NAME`` (or ``/`` if not set). This + makes it easier to serve a Django application in a subpath without adding + an extra configuration to the settings. + .. setting:: STATICFILES_DIRS ``STATICFILES_DIRS`` diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index 3a9381626b..da954bab56 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -233,6 +233,11 @@ Miscellaneous * The compatibility imports of ``Context``, ``ContextPopException``, and ``RequestContext`` in ``django.template.base`` are removed. +* The :setting:`STATIC_URL` and :setting:`MEDIA_URL` settings set to relative + paths are now prefixed by the server-provided value of ``SCRIPT_NAME`` (or + ``/`` if not set). This change should not affect settings set to valid URLs + or absolute paths. + .. _deprecated-features-3.1: Features deprecated in 3.1 diff --git a/tests/file_storage/tests.py b/tests/file_storage/tests.py index 1c4176014c..1f7fd94b5a 100644 --- a/tests/file_storage/tests.py +++ b/tests/file_storage/tests.py @@ -521,7 +521,7 @@ class FileStorageTests(SimpleTestCase): defaults_storage = self.storage_class() settings = { 'MEDIA_ROOT': 'overridden_media_root', - 'MEDIA_URL': 'overridden_media_url/', + 'MEDIA_URL': '/overridden_media_url/', 'FILE_UPLOAD_PERMISSIONS': 0o333, 'FILE_UPLOAD_DIRECTORY_PERMISSIONS': 0o333, } diff --git a/tests/settings_tests/tests.py b/tests/settings_tests/tests.py index d663f6e819..55ca0de524 100644 --- a/tests/settings_tests/tests.py +++ b/tests/settings_tests/tests.py @@ -12,6 +12,7 @@ from django.test import ( override_settings, signals, ) from django.test.utils import requires_tz_support +from django.urls import clear_script_prefix, set_script_prefix @modify_settings(ITEMS={ @@ -567,3 +568,51 @@ class OverrideSettingsIsolationOnExceptionTests(SimpleTestCase): signals.setting_changed.disconnect(self.receiver) # This call shouldn't raise any errors. decorated_function() + + +class MediaURLStaticURLPrefixTest(SimpleTestCase): + def set_script_name(self, val): + clear_script_prefix() + if val is not None: + set_script_prefix(val) + + def test_not_prefixed(self): + # Don't add SCRIPT_NAME prefix to valid URLs, absolute paths or None. + tests = ( + '/path/', + 'http://myhost.com/path/', + None, + ) + for setting in ('MEDIA_URL', 'STATIC_URL'): + for path in tests: + new_settings = {setting: path} + with self.settings(**new_settings): + for script_name in ['/somesubpath', '/somesubpath/', '/', '', None]: + with self.subTest(script_name=script_name, **new_settings): + try: + self.set_script_name(script_name) + self.assertEqual(getattr(settings, setting), path) + finally: + clear_script_prefix() + + def test_add_script_name_prefix(self): + tests = ( + # Relative paths. + ('/somesubpath', 'path/', '/somesubpath/path/'), + ('/somesubpath/', 'path/', '/somesubpath/path/'), + ('/', 'path/', '/path/'), + # Invalid URLs. + ('/somesubpath/', 'htp://myhost.com/path/', '/somesubpath/htp://myhost.com/path/'), + # Blank settings. + ('/somesubpath/', '', '/somesubpath/'), + ) + for setting in ('MEDIA_URL', 'STATIC_URL'): + for script_name, path, expected_path in tests: + new_settings = {setting: path} + with self.settings(**new_settings): + with self.subTest(script_name=script_name, **new_settings): + try: + self.set_script_name(script_name) + self.assertEqual(getattr(settings, setting), expected_path) + finally: + clear_script_prefix()