From 329b3fecfba19cd6ce20cddd0957f5055205cafb Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Sat, 31 May 2025 23:59:03 +0200 Subject: [PATCH 1/3] [ADD] Custom Static Files Storage Class --- allianceauth/framework/staticfiles/storage.py | 87 +++++++++++++++++++ .../project_name/settings/base.py | 9 ++ 2 files changed, 96 insertions(+) create mode 100644 allianceauth/framework/staticfiles/storage.py diff --git a/allianceauth/framework/staticfiles/storage.py b/allianceauth/framework/staticfiles/storage.py new file mode 100644 index 00000000..489b2a79 --- /dev/null +++ b/allianceauth/framework/staticfiles/storage.py @@ -0,0 +1,87 @@ +""" +Custom static files storage for Alliance Auth. + +This module defines a custom static files storage class for +Alliance Auth, named `AaManifestStaticFilesStorage`. + +Using `ManifestStaticFilesStorage` will give us a hashed name for +our static files, which is useful for cache busting. + +This storage class extends Django's `ManifestStaticFilesStorage` to ignore missing files, +which the original class does not handle, and log them in debug mode. +It is useful for handling cases where static files may not exist, such as when a +CSS file references a background image that is not present in the static files directory. + +With debug mode enabled, it will print a message for each missing file when running `collectstatic`, +which can help identify issues with static file references during development. +""" + +from django.conf import settings +from django.contrib.staticfiles.storage import ManifestStaticFilesStorage + + +class AaManifestStaticFilesStorage(ManifestStaticFilesStorage): + """ + Custom static files storage that ignores missing files. + """ + + def __init__(self, *args, **kwargs): + """ + Initialize the static files storage, ignoring missing files. + + :param args: + :type args: + :param kwargs: + :type kwargs: + """ + + self.missing_files = [] + + super().__init__(*args, **kwargs) + + def hashed_name(self, name, *args, **kwargs): + """ + Generate a hashed name for the given static file, ignoring missing files. + + Ignore missing files, e.g. non-existent background image referenced from css. + Returns the original filename if the referenced file doesn't exist. + + :param name: + :type name: + :param args: + :type args: + :param kwargs: + :type kwargs: + :return: + :rtype: + """ + + try: + return super().hashed_name(name, *args, **kwargs) + except ValueError as e: + if settings.DEBUG: + # In debug mode, we log the missing file message + message = e.args[0].split(" with ")[0] + self.missing_files.append(message) + # print(f'\x1b[0;30;41m{message}\x1b[0m') + + return name + + def post_process(self, *args, **kwargs): + """ + Post-process the static files, printing any missing files in debug mode. + + :param args: + :type args: + :param kwargs: + :type kwargs: + :return: + :rtype: + """ + + yield from super().post_process(*args, **kwargs) + + if settings.DEBUG: + # In debug mode, print the missing files + for message in sorted(set(self.missing_files)): + print(f"\x1b[0;30;41m{message}\x1b[0m") diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index e81cc095..7bbd9c2d 100644 --- a/allianceauth/project_template/project_name/settings/base.py +++ b/allianceauth/project_template/project_name/settings/base.py @@ -219,6 +219,15 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "allianceauth.framework.staticfiles.storage.AaManifestStaticFilesStorage", + }, +} + STATIC_URL = '/static/' STATICFILES_DIRS = [ os.path.join(PROJECT_DIR, 'static'), From 6477c22308e8cfadf2bc595aada8a3511d181eb0 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Sun, 1 Jun 2025 00:05:34 +0200 Subject: [PATCH 2/3] [CHANGE] Use the same quotation marks for strings and not a mix of both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Just while we're at it … --- .../project_name/settings/base.py | 296 +++++++++--------- 1 file changed, 145 insertions(+), 151 deletions(-) diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index 7bbd9c2d..e7327e21 100644 --- a/allianceauth/project_template/project_name/settings/base.py +++ b/allianceauth/project_template/project_name/settings/base.py @@ -15,68 +15,68 @@ from django.contrib import messages from django.utils.translation import gettext_lazy as _ INSTALLED_APPS = [ - 'allianceauth', # needs to be on top of this list to support favicons in Django admin (see https://gitlab.com/allianceauth/allianceauth/-/issues/1301) - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.humanize', - 'django_celery_beat', - 'solo', - 'bootstrapform', - 'django_bootstrap5', # https://github.com/zostera/django-bootstrap5 - 'sortedm2m', - 'esi', - 'allianceauth.framework', - 'allianceauth.authentication', - 'allianceauth.services', - 'allianceauth.eveonline', - 'allianceauth.groupmanagement', - 'allianceauth.notifications', - 'allianceauth.thirdparty.navhelper', - 'allianceauth.analytics', - 'allianceauth.menu', - 'allianceauth.theme', - 'allianceauth.theme.darkly', - 'allianceauth.theme.flatly', - 'allianceauth.theme.materia', + "allianceauth", # needs to be on top of this list to support favicons in Django admin (see https://gitlab.com/allianceauth/allianceauth/-/issues/1301) + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "django_celery_beat", + "solo", + "bootstrapform", + "django_bootstrap5", # https://github.com/zostera/django-bootstrap5 + "sortedm2m", + "esi", + "allianceauth.framework", + "allianceauth.authentication", + "allianceauth.services", + "allianceauth.eveonline", + "allianceauth.groupmanagement", + "allianceauth.notifications", + "allianceauth.thirdparty.navhelper", + "allianceauth.analytics", + "allianceauth.menu", + "allianceauth.theme", + "allianceauth.theme.darkly", + "allianceauth.theme.flatly", + "allianceauth.theme.materia", "allianceauth.custom_css", - 'allianceauth.crontab', - 'sri', + "allianceauth.crontab", + "sri", ] SRI_ALGORITHM = "sha512" SECRET_KEY = "wow I'm a really bad default secret key" # Celery configuration -BROKER_URL = 'redis://localhost:6379/0' +BROKER_URL = "redis://localhost:6379/0" CELERYBEAT_SCHEDULER = "allianceauth.crontab.schedulers.OffsetDatabaseScheduler" CELERYBEAT_SCHEDULE = { - 'esi_cleanup_callbackredirect': { - 'task': 'esi.tasks.cleanup_callbackredirect', - 'schedule': crontab(minute='0', hour='*/4'), + "esi_cleanup_callbackredirect": { + "task": "esi.tasks.cleanup_callbackredirect", + "schedule": crontab(minute="0", hour="*/4"), }, - 'esi_cleanup_token': { - 'task': 'esi.tasks.cleanup_token', - 'schedule': crontab(minute='0', hour='0'), - 'apply_offset': True, + "esi_cleanup_token": { + "task": "esi.tasks.cleanup_token", + "schedule": crontab(minute="0", hour="0"), + "apply_offset": True, }, - 'run_model_update': { - 'task': 'allianceauth.eveonline.tasks.run_model_update', - 'schedule': crontab(minute='0', hour="*/6"), - 'apply_offset': True + "run_model_update": { + "task": "allianceauth.eveonline.tasks.run_model_update", + "schedule": crontab(minute="0", hour="*/6"), + "apply_offset": True, }, - 'check_all_character_ownership': { - 'task': 'allianceauth.authentication.tasks.check_all_character_ownership', - 'schedule': crontab(minute='0', hour='*/4'), - 'apply_offset': True + "check_all_character_ownership": { + "task": "allianceauth.authentication.tasks.check_all_character_ownership", + "schedule": crontab(minute="0", hour="*/4"), + "apply_offset": True, + }, + "analytics_daily_stats": { + "task": "allianceauth.analytics.tasks.analytics_daily_stats", + "schedule": crontab(minute="0", hour="2"), }, - 'analytics_daily_stats': { - 'task': 'allianceauth.analytics.tasks.analytics_daily_stats', - 'schedule': crontab(minute='0', hour='2'), - } } @@ -85,22 +85,20 @@ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(PROJECT_DIR) MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'allianceauth.authentication.middleware.UserSettingsMiddleware', - 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "allianceauth.authentication.middleware.UserSettingsMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'allianceauth.urls' +ROOT_URLCONF = "allianceauth.urls" -LOCALE_PATHS = ( - os.path.join(BASE_DIR, 'locale/'), -) +LOCALE_PATHS = (os.path.join(BASE_DIR, "locale/"),) LANGUAGES = ( # Sorted by Language Code alphabetical order + English at top ("en", _("English")), @@ -160,58 +158,58 @@ LANGUAGE_MAPPING = { TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(PROJECT_DIR, 'templates')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'allianceauth.context_processors.auth_settings', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(PROJECT_DIR, "templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "allianceauth.context_processors.auth_settings", ], }, }, ] -WSGI_APPLICATION = 'allianceauth.wsgi.application' +WSGI_APPLICATION = "allianceauth.wsgi.application" # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] AUTHENTICATION_BACKENDS = [ - 'allianceauth.authentication.backends.StateBackend', - 'django.contrib.auth.backends.ModelBackend' + "allianceauth.authentication.backends.StateBackend", + "django.contrib.auth.backends.ModelBackend", ] # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" LANGUAGE_COOKIE_AGE = 1209600 -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -228,44 +226,42 @@ STORAGES = { }, } -STATIC_URL = '/static/' +STATIC_URL = "/static/" STATICFILES_DIRS = [ - os.path.join(PROJECT_DIR, 'static'), + os.path.join(PROJECT_DIR, "static"), ] -STATIC_ROOT = os.path.join(BASE_DIR, 'static') +STATIC_ROOT = os.path.join(BASE_DIR, "static") # Bootstrap messaging css workaround -MESSAGE_TAGS = { - messages.ERROR: 'danger error' -} +MESSAGE_TAGS = {messages.ERROR: "danger error"} CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379/1" # change the 1 here for the DB used + "LOCATION": "redis://127.0.0.1:6379/1", # change the 1 here for the DB used } } SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" DEBUG = True -ALLOWED_HOSTS = ['*'] +ALLOWED_HOSTS = ["*"] DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': str(os.path.join(BASE_DIR, 'alliance_auth.sqlite3')), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": str(os.path.join(BASE_DIR, "alliance_auth.sqlite3")), }, } -SITE_NAME = 'Alliance Auth' +SITE_NAME = "Alliance Auth" DEFAULT_THEME = "allianceauth.theme.flatly.auth_hooks.FlatlyThemeHook" DEFAULT_THEME_DARK = "allianceauth.theme.darkly.auth_hooks.DarklyThemeHook" # Legacy AAv3 user.profile.night_mode=1 -LOGIN_URL = 'auth_login_user' # view that handles login logic +LOGIN_URL = "auth_login_user" # view that handles login logic -LOGIN_REDIRECT_URL = 'authentication:dashboard' # default destination when logging in if no redirect specified -LOGOUT_REDIRECT_URL = 'authentication:dashboard' # destination after logging out +LOGIN_REDIRECT_URL = "authentication:dashboard" # default destination when logging in if no redirect specified +LOGOUT_REDIRECT_URL = "authentication:dashboard" # destination after logging out # Both of these redirects accept values as per the django redirect shortcut # https://docs.djangoproject.com/en/1.11/topics/http/shortcuts/#redirect # - url names eg 'authentication:dashboard' @@ -273,73 +269,71 @@ LOGOUT_REDIRECT_URL = 'authentication:dashboard' # destination after logging ou # - absolute urls eg 'http://example.com/dashboard' # scopes required on new tokens when logging in. Cannot be blank. -LOGIN_TOKEN_SCOPES = ['publicData'] +LOGIN_TOKEN_SCOPES = ["publicData"] EMAIL_TIMEOUT = 15 # number of days email verification links are valid for ACCOUNT_ACTIVATION_DAYS = 1 -ESI_API_URL = 'https://esi.evetech.net/' +ESI_API_URL = "https://esi.evetech.net/" LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s", - 'datefmt': "%d/%b/%Y %H:%M:%S" + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s", + "datefmt": "%d/%b/%Y %H:%M:%S", }, - 'simple': { - 'format': '%(levelname)s %(message)s' + "simple": {"format": "%(levelname)s %(message)s"}, + }, + "handlers": { + "log_file": { + "level": "INFO", # edit this line to change logging level to file + "class": "logging.handlers.RotatingFileHandler", + "filename": os.path.join(BASE_DIR, "log/allianceauth.log"), + "formatter": "verbose", + "maxBytes": 1024 * 1024 * 5, # edit this line to change max log file size + "backupCount": 5, # edit this line to change number of log backups + }, + "extension_file": { + "level": "INFO", + "class": "logging.handlers.RotatingFileHandler", + "filename": os.path.join(BASE_DIR, "log/extensions.log"), + "formatter": "verbose", + "maxBytes": 1024 * 1024 * 5, # edit this line to change max log file size + "backupCount": 5, # edit this line to change number of log backups + }, + "console": { + "level": "DEBUG", # edit this line to change logging level to console + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + "notifications": { # creates notifications for users with logging_notifications permission + "level": "ERROR", # edit this line to change logging level to notifications + "class": "allianceauth.notifications.handlers.NotificationHandler", + "formatter": "verbose", }, }, - 'handlers': { - 'log_file': { - 'level': 'INFO', # edit this line to change logging level to file - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': os.path.join(BASE_DIR, 'log/allianceauth.log'), - 'formatter': 'verbose', - 'maxBytes': 1024 * 1024 * 5, # edit this line to change max log file size - 'backupCount': 5, # edit this line to change number of log backups + "loggers": { + "allianceauth": { + "handlers": ["log_file", "console", "notifications"], + "level": "DEBUG", }, - 'extension_file': { - 'level': 'INFO', - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': os.path.join(BASE_DIR, 'log/extensions.log'), - 'formatter': 'verbose', - 'maxBytes': 1024 * 1024 * 5, # edit this line to change max log file size - 'backupCount': 5, # edit this line to change number of log backups + "extensions": { + "handlers": ["extension_file", "console"], + "level": "DEBUG", }, - 'console': { - 'level': 'DEBUG', # edit this line to change logging level to console - 'class': 'logging.StreamHandler', - 'formatter': 'verbose', + "django": { + "handlers": ["log_file", "console"], + "level": "ERROR", }, - 'notifications': { # creates notifications for users with logging_notifications permission - 'level': 'ERROR', # edit this line to change logging level to notifications - 'class': 'allianceauth.notifications.handlers.NotificationHandler', - 'formatter': 'verbose', + "esi": { + "handlers": ["log_file", "console"], + "level": "DEBUG", }, }, - 'loggers': { - 'allianceauth': { - 'handlers': ['log_file', 'console', 'notifications'], - 'level': 'DEBUG', - }, - 'extensions': { - 'handlers': ['extension_file', 'console'], - 'level': 'DEBUG', - }, - 'django': { - 'handlers': ['log_file', 'console'], - 'level': 'ERROR', - }, - 'esi': { - 'handlers': ['log_file', 'console'], - 'level': 'DEBUG', - }, - } } DEFAULT_AUTO_FIELD = "django.db.models.AutoField" From fc51f6bea255bca74477981c021a0b2bf0896177 Mon Sep 17 00:00:00 2001 From: Peter Pfeufer Date: Thu, 12 Jun 2025 10:17:08 +0200 Subject: [PATCH 3/3] [FIX] Cleanup file path name to work with CSS `url("foobar")` notations This essentially removes quotes from the filename, which aren't allowed anyways. --- allianceauth/framework/staticfiles/storage.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/allianceauth/framework/staticfiles/storage.py b/allianceauth/framework/staticfiles/storage.py index 489b2a79..c0dae084 100644 --- a/allianceauth/framework/staticfiles/storage.py +++ b/allianceauth/framework/staticfiles/storage.py @@ -25,6 +25,22 @@ class AaManifestStaticFilesStorage(ManifestStaticFilesStorage): Custom static files storage that ignores missing files. """ + @classmethod + def _cleanup_name(cls, name: str) -> str: + """ + Clean up the name by removing quotes. + This method is used to ensure that the name does not contain any quotes, + which can cause issues with file paths. + + :param name: The name of the static file. + :type name: str + :return: The cleaned-up name without quotes. + :rtype: str + """ + + # Remove quotes from the name + return name.replace('"', "").replace("'", "") + def __init__(self, *args, **kwargs): """ Initialize the static files storage, ignoring missing files. @@ -39,25 +55,27 @@ class AaManifestStaticFilesStorage(ManifestStaticFilesStorage): super().__init__(*args, **kwargs) - def hashed_name(self, name, *args, **kwargs): + def hashed_name(self, name, content=None, filename=None): """ Generate a hashed name for the given static file, ignoring missing files. Ignore missing files, e.g. non-existent background image referenced from css. Returns the original filename if the referenced file doesn't exist. - :param name: - :type name: - :param args: - :type args: - :param kwargs: - :type kwargs: - :return: - :rtype: + :param name: The name of the static file to hash. + :type name: str + :param content: The content of the static file, if available. + :type content: bytes | None + :param filename: The original filename of the static file, if available. + :type filename: str | None + :return: The hashed name of the static file, or the original name if the file is missing. + :rtype: str """ try: - return super().hashed_name(name, *args, **kwargs) + clean_name = self._cleanup_name(name) + + return super().hashed_name(clean_name, content, filename) except ValueError as e: if settings.DEBUG: # In debug mode, we log the missing file message