From c7e1e59bc338e290aebfdc859ff8f0858b567dc1 Mon Sep 17 00:00:00 2001
From: Dominik George <dominik.george@teckids.org>
Date: Fri, 10 Jan 2020 15:43:34 +0100
Subject: [PATCH] Make app settings merging generic

Implements a merge_app_settings() function that merges a named setting
from all installed apps into the original setting in core.
---
 aleksis/core/settings.py          | 12 ++++++++-
 aleksis/core/util/core_helpers.py | 42 +++++++++++++++++++++++--------
 2 files changed, 43 insertions(+), 11 deletions(-)

diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index bd0f30cff..28b637465 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _
 from dynaconf import LazySettings
 from easy_thumbnails.conf import Settings as thumbnail_settings
 
-from .util.core_helpers import get_app_packages
+from .util.core_helpers import get_app_packages, merge_app_settings
 
 ENVVAR_PREFIX_FOR_DYNACONF = "ALEKSIS"
 DIRS_FOR_DYNACONF = ["/etc/aleksis"]
@@ -77,6 +77,7 @@ INSTALLED_APPS = [
     "pwa",
 ]
 
+merge_app_settings("INSTALLED_APPS", INSTALLED_APPS, True)
 INSTALLED_APPS += get_app_packages()
 
 STATICFILES_FINDERS = [
@@ -150,6 +151,8 @@ DATABASES = {
     }
 }
 
+merge_app_settings("DATABASES", DATABASES, False)
+
 if _settings.get("caching.memcached.enabled", True):
     CACHES = {
         "default": {
@@ -239,6 +242,8 @@ YARN_INSTALLED_APPS = [
     "select2",
 ]
 
+merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True)
+
 JS_URL = _settings.get("js_assets.url", STATIC_URL)
 JS_ROOT = _settings.get("js_assets.root", NODE_MODULES_ROOT + "/node_modules")
 
@@ -255,6 +260,8 @@ ANY_JS = {
     },
 }
 
+merge_app_settings("ANY_JS", ANY_JS, True)
+
 SASS_PROCESSOR_AUTO_INCLUDE = False
 SASS_PROCESSOR_CUSTOM_FUNCTIONS = {
     "get-colour": "aleksis.core.util.sass_helpers.get_colour",
@@ -302,6 +309,9 @@ CONSTANCE_CONFIG_FIELDSETS = {
     "Footer settings": ("PRIVACY_URL", "IMPRINT_URL"),
 }
 
+merge_app_settings("CONSTANCE_CONFIG", CONSTANCE_CONFIG, False)
+merge_app_settings("CONSTANCE_CONFIG_FIELDSETS", CONSTANCE_CONFIG_FIELDSETS, False)
+
 MAINTENANCE_MODE = _settings.get("maintenance.enabled", None)
 MAINTENANCE_MODE_IGNORE_IP_ADDRESSES = _settings.get(
     "maintenance.ignore_ips", _settings.get("maintenance.internal_ips", [])
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index 4fb88fa49..e2cdd439d 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -30,19 +30,41 @@ def get_app_packages() -> Sequence[str]:
     except ImportError:
         return []
 
-    pkgs = []
-    for pkg in pkgutil.iter_modules(aleksis.apps.__path__):
-        mod = import_module("aleksis.apps.%s" % pkg[1])
+    return ["aleksis.apps.%s" % pkg[1] for pkg in pkgutil.iter_modules(aleksis.apps.__path__)]
 
-        # Add additional apps defined in module's INSTALLED_APPS constant
-        additional_apps = getattr(mod, "INSTALLED_APPS", [])
-        for app in additional_apps:
-            if app not in pkgs:
-                pkgs.append(app)
 
-        pkgs.append("aleksis.apps.%s" % pkg[1])
+def merge_app_settings(setting: str, original: Union[dict, list], deduplicate: bool = False) -> Union[dict, list]:
+    """ Get a named settings constant from all apps and merge it into the original.
+    To use this, add a settings.py file to the app, in the same format as Django's
+    main settings.py.
 
-    return pkgs
+    Note: Only selected names will be imported frm it to minimise impact of
+    potentially malicious apps!
+    """
+
+    for pkg in get_app_packages():
+        try:
+            mod_settings = import_module(pkg)
+        except ImportError:
+            # Import errors are non-fatal. They mean that the app has no settings.py.
+            continue
+
+        app_setting = getattr(mod_settings, setting, None)
+        if not app_setting:
+            # The app might not have this setting or it might be empty. Ignore it in that case.
+            continue
+
+        for entry in app_setting:
+            if entry in original:
+                if not deduplicate:
+                    raise AttributeError("%s already set in original.")
+            else:
+                if isinstance(original, list):
+                    original.append(entry)
+                elif isinstance(original, dict):
+                    original[entry] = app_setting[entry]
+                else:
+                    raise TypeError("Only dict and list settings can be merged.")
 
 
 def is_impersonate(request: HttpRequest) -> bool:
-- 
GitLab