diff --git a/README.md b/README.md index 7ca53b2..0ea7ce8 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,32 @@ Notes: ## Developer tools +### Adding Django Settings +If modules are using additional libraries that would usually require changes in `settings.py`, +these settings should be added within the module and not directly in assembly. +To do this, `django_settings.py` file with `SETTINGS` variable containing +a list of `SettingAttribute` objects has to be added to module (the same as in assembly module). +`SettingAttribute` provides information regarding setting `name`, setting `value` and conflict `resolving policy`. + +#### Example: +```python +# django_settings.py +from openIMIS.modulesettingsloader import SettingsAttribute, SettingsAttributeConflictPolicy as setting_type + +SETTINGS = [ + SettingAttribute('CUSTOM_REQUIRED_SETTING', {'key': 'value'}, setting_type.MERGE_YIELD) +] +``` +Is equivalent of assigning: +```python +# settings.py +CUSTOM_REQUIRED_SETTING = {'key': 'value'} +``` +in `settings.py`. + +Last parameter is used for resolving conflicts between overlying settings. +By default, values are merged (dictionaries are combined, lists concatenated), and if it's not possible +then already declared settings are not overridden. ### To create backend module skeleton in single command * from `/openimis-be_py/openIMIS`: diff --git a/openIMIS/openIMIS/django_settings.py b/openIMIS/openIMIS/django_settings.py new file mode 100644 index 0000000..18ca6c9 --- /dev/null +++ b/openIMIS/openIMIS/django_settings.py @@ -0,0 +1,486 @@ +""" +Django settings for openIMIS project. +OpenIMIS have custom solution for adding settings. +Instead of declaring them explicitly in settings.py, which is standard +Django approach (see https://docs.djangoproject.com/en/2.1/topics/settings/) +settings are declared as list of SettingsAttribute objects in django_settings.SETTINGS +and imported from modules (in order of modules loading, starting from assembly) to settings.py. +See 'Adding Django Settings' section of openimis_be README for more information. +""" +import json +import logging +import os + +from .modulesettingsloader import SettingsAttribute, SettingsAttributeConflictPolicy as setting_type +from .openimisapps import get_locale_folders + + +def __auth_backends(): + ab = [] + if os.environ.get("REMOTE_USER_AUTHENTICATION", "false").lower() == "true": + ab += ["django.contrib.auth.backends.RemoteUserBackend"] + + ab += [ + "rules.permissions.ObjectPermissionBackend", + "graphql_jwt.backends.JSONWebTokenBackend", + "django.contrib.auth.backends.ModelBackend", + ] + return ab + + +def __rest_setup(): + drf = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "core.jwt_authentication.JWTAuthentication", + "rest_framework.authentication.BasicAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], + "EXCEPTION_HANDLER": "openIMIS.rest_exception_handler.fhir_rest_api_exception_handler", + } + + if os.environ.get("REMOTE_USER_AUTHENTICATION", "false").lower() == "true": + drf["DEFAULT_AUTHENTICATION_CLASSES"].insert( + 0, + "rest_framework.authentication.RemoteUserAuthentication", + ) + return drf + + +def _middleware_setup(): + mid = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + ] + + if os.environ.get("DEBUG", "False").lower() == "true": + # Attach profiler middleware + mid.append( + "django_cprofile_middleware.middleware.ProfilerMiddleware" + ) + + if os.environ.get("REMOTE_USER_AUTHENTICATION", "false").lower() == "true": + mid += ["core.security.RemoteUserMiddleware"] + + mid += [ + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + ] + return mid + + +def __db_options(): + if "DB_OPTIONS" in os.environ: + return json.loads(os.environ["DB_OPTIONS"]) + elif os.name == "nt": + return { + "driver": "ODBC Driver 17 for SQL Server", + "extra_params": "Persist Security Info=False;server=%s" + % os.environ.get("DB_HOST"), + "unicode_results": True, + } + else: + return{ + "driver": "ODBC Driver 17 for SQL Server", + "unicode_results": True, + } + + +def __databases(): + if not os.environ.get("NO_DATABASE_ENGINE", "False") == "True": + return { + "default": { + "ENGINE": os.environ.get("DB_ENGINE", "sql_server.pyodbc"), + "NAME": os.environ.get("DB_NAME"), + "USER": os.environ.get("DB_USER"), + "PASSWORD": os.environ.get("DB_PASSWORD"), + "HOST": os.environ.get("DB_HOST"), + "PORT": os.environ.get("DB_PORT"), + "OPTIONS": __db_options(), + } + } + else: + return {} + + +def __scheduler_jobs(): + return [ + { + "method": "core.tasks.openimis_test_batch", + "args": ["cron"], + "kwargs": {"id": "openimis_test_batch", "minute": 16, "replace_existing": True}, + }, + # { + # "method": "policy.tasks.get_policies_for_renewal", + # "args": ["cron"], + # "kwargs": {"id": "openimis_renewal_batch", "hour": 8, "minute": 30, "replace_existing": True}, + # }, + # { + # "method": "policy_notification.tasks.send_notification_messages", + # "args": ["cron"], + # "kwargs": {"id": "openimis_notification_batch", 'day_of_week': '*', + # "hour": "8,12,16,20", "replace_existing": True}, + # }, + # { + # "method": "claim_ai_quality.tasks.claim_ai_processing", + # "args": ["cron"], + # "kwargs": {"id": "claim_ai_processing", + # "hour": 0 + # "minute", 30 + # "replace_existing": True}, + # }, + ] + + +def __password_validators(): + return [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, + ] + + +def __site_root(): + root = os.environ.get("SITE_ROOT", "") + if root == "": + return root + elif root.endswith("/"): + return root + else: + return "%s/" % root + + +def __site_url(): + url = os.environ.get("SITE_URL", "") + if url == "": + return url + elif url.endswith("/"): + return url[:-1] + else: + return url + + +def __base_logging(): + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, + "short": {"format": "%(name)s: %(message)s"}, + }, + "handlers": { + "db-queries": { + "level": os.getenv("DJANGO_LOG_LEVEL", "WARNING"), + "class": "logging.handlers.RotatingFileHandler", + "filename": os.environ.get("DB_QUERIES_LOG_FILE", "db-queries.log"), + "maxBytes": 1024 * 1024 * 5, # 5 MB + "backupCount": 10, + "formatter": "standard", + }, + "debug-log": { + "level": os.getenv("DJANGO_LOG_LEVEL", "WARNING"), + "class": "logging.handlers.RotatingFileHandler", + "filename": os.environ.get("DEBUG_LOG_FILE", "debug.log"), + "maxBytes": 1024 * 1024 * 5, # 5 MB + "backupCount": 3, + "formatter": "standard", + }, + "console": {"class": "logging.StreamHandler", "formatter": "short"}, + }, + "loggers": { + "": { + "level": os.getenv("DJANGO_LOG_LEVEL", "WARNING"), + "handlers": [os.getenv("DJANGO_LOG_HANDLER", "debug-log")], + }, + "django.db.backends": { + "level": os.getenv("DJANGO_LOG_LEVEL", "WARNING"), + "propagate": False, + "handlers": ["db-queries"], + }, + "openIMIS": { + "level": os.getenv("DJANGO_LOG_LEVEL", "WARNING"), + "handlers": [os.getenv("DJANGO_LOG_HANDLER", "debug-log")], + }, + }, + } + + +def __initialize_sentry(): + IS_SENTRY_ENABLED = False + SENTRY_DSN = os.environ.get("SENTRY_DSN", None) + SENTRY_SAMPLE_RATE = os.environ.get("SENTRY_SAMPLE_RATE", "0.2") + if SENTRY_DSN is not None: + try: + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[DjangoIntegration()], + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + # We recommend adjusting this value in production, + traces_sample_rate=float(SENTRY_SAMPLE_RATE), + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + send_default_pii=True, + # By default the SDK will try to use the SENTRY_RELEASE + # environment variable, or infer a git commit + # SHA as release, however you may want to set + # something more human-readable. + # release="myapp@1.0.0", + ) + IS_SENTRY_ENABLED = True + except ModuleNotFoundError: + logging.error( + "sentry_sdk has to be installed to use Sentry. Run `pip install --upgrade sentry_sdk` to install it." + ) + return IS_SENTRY_ENABLED + + +def __base_installed_apps(): + return [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "graphene_django", + "graphql_jwt.refresh_token.apps.RefreshTokenConfig", + "test_without_migrations", + "rest_framework", + "rules", + "rest_framework_rules", + "health_check", # required + "health_check.db", # stock Django health checkers + "health_check.cache", + "health_check.storage", + "django_apscheduler", + "channels", # Websocket support + "developer_tools" + ] + + +def __templates(): + return [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "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", + ], + }, + }, + ] + + +def __graphene(): + return { + "SCHEMA": "openIMIS.schema.schema", + "RELAY_CONNECTION_MAX_LIMIT": 100, + "GRAPHIQL_HEADER_EDITOR_ENABLED": True, + "MIDDLEWARE": [ + "openIMIS.tracer.TracerMiddleware", + "openIMIS.schema.GQLUserLanguageMiddleware", + "graphql_jwt.middleware.JSONWebTokenMiddleware", + "graphene_django.debug.DjangoDebugMiddleware", # adds a _debug query to graphQL with sql debug info + ], + } + + +def __graphene_jwt(): + return { + "JWT_VERIFY_EXPIRATION": True, + "JWT_LONG_RUNNING_REFRESH_TOKEN": True, + "JWT_AUTH_HEADER_PREFIX": "Bearer", + "JWT_ENCODE_HANDLER": "core.jwt.jwt_encode_user_key", + "JWT_DECODE_HANDLER": "core.jwt.jwt_decode_user_key", + # This can be used to expose some resources without authentication + "JWT_ALLOW_ANY_CLASSES": [ + "graphql_jwt.mutations.ObtainJSONWebToken", + "graphql_jwt.mutations.Verify", + "graphql_jwt.mutations.Refresh", + "graphql_jwt.mutations.Revoke", + "core.schema.ResetPasswordMutation", + "core.schema.SetPasswordMutation", + ], + } + + +def __scheduler_config(): + return { + "apscheduler.jobstores.default": { + "class": "django_apscheduler.jobstores:DjangoJobStore" + }, + "apscheduler.executors.processpool": {"type": "threadpool"}, + } + + +def __scheduler_custom(): + return [ + { + "method": "core.tasks.sample_method", + "args": ["sample"], + "kwargs": {"sample_named": "param"}, + }, + ] + + +def __channel_layers(): + return { + "default": { + "BACKEND": "channels_rabbitmq.core.RabbitmqChannelLayer", + "CONFIG": { + "host": os.environ.get("CHANNELS_HOST", "amqp://guest:guest@127.0.0.1/"), + # "ssl_context": ... (optional) + }, + }, + } + + +def __base_dir(): + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def __row_security(): + return os.environ.get("ROW_SECURITY", "True").lower() == "true" + + +def __allowed_hosts(): + return json.loads(os.environ["ALLOWED_HOSTS"]) if "ALLOWED_HOSTS" in os.environ else ["*"] + + +def __secret_key(): + return os.environ.get("SECRET_KEY", "chv^^7i_v3-04!rzu&qe#+h*a=%h(ib#5w9n$!f2q7%2$qp=zz") + + +def __insuree_number_validator(x): + # Insuree number validation. One can use the validator function for specific processing or just specify the length + # and modulo for the typical use case. These two can be overridden from the environment but the validator being a + # function, it is not possible. + if str(x)[0] != "x": + return ["don't start with x"] + else: + return [] + + +SETTINGS = [ + SettingsAttribute('BASE_DIR', __base_dir(), setting_type.FIX), + SettingsAttribute('LOGGING_LEVEL', os.getenv("DJANGO_LOG_LEVEL", "WARNING"), setting_type.YIELD), + SettingsAttribute('DEFAULT_LOGGING_HANDLER', os.getenv("DJANGO_LOG_HANDLER", "debug-log"), setting_type.YIELD), + SettingsAttribute('LOGGING', __base_logging()), + # Sentry + SettingsAttribute('SENTRY_DSN', os.environ.get("SENTRY_DSN", None), setting_type.YIELD), + SettingsAttribute('SENTRY_SAMPLE_RATE', os.environ.get("SENTRY_SAMPLE_RATE", "0.2"), setting_type.YIELD), + SettingsAttribute('IS_SENTRY_ENABLED', __initialize_sentry(), setting_type.FIX), + + # SECURITY WARNING: keep the secret key used in production secret! + SettingsAttribute('SECRET_KEY', __secret_key(), setting_type.FIX), + + # SECURITY WARNING: don't run with debug turned on in production! + SettingsAttribute('DEBUG', os.environ.get("DEBUG", "False").lower() == "true", setting_type.FIX), + + # SECURITY WARNING: don't run without row security in production! + # Row security is dedicated to filter the data result sets according to users' right + # Example: user registered at a Health Facility should only see claims recorded for that Health Facility + SettingsAttribute('ROW_SECURITY', __row_security(), setting_type.FIX), + SettingsAttribute('ALLOWED_HOSTS', __allowed_hosts(), setting_type.FIX), + SettingsAttribute('SITE_ROOT', __site_root, setting_type.FIX), + + SettingsAttribute('SITE_URL', __site_url, setting_type.FIX), + + SettingsAttribute('INSTALLED_APPS', __base_installed_apps()), + + SettingsAttribute('AUTHENTICATION_BACKENDS', __auth_backends()), + SettingsAttribute('ANONYMOUS_USER_NAME', None), + + SettingsAttribute('REST_FRAMEWORK', __rest_setup()), + + SettingsAttribute('MIDDLEWARE', _middleware_setup()), + + SettingsAttribute('DJANGO_CPROFILE_MIDDLEWARE_REQUIRE_STAFF', False), + + SettingsAttribute('ROOT_URLCONF', "openIMIS.urls"), + + SettingsAttribute('TEMPLATES', __templates()), + + SettingsAttribute('WSGI_APPLICATION', "openIMIS.wsgi.application"), + + SettingsAttribute('GRAPHENE', __graphene()), + SettingsAttribute('GRAPHQL_JWT', __graphene_jwt(), setting_type.FIX), + # Database + # https://docs.djangoproject.com/en/2.1/ref/settings/#databases + SettingsAttribute('DATABASE_OPTIONS', __db_options()), + SettingsAttribute('DATABASES', __databases()), + + SettingsAttribute('CELERY_BROKER_URL', os.environ.get("CELERY_BROKER_URL", "amqp://127.0.0.1"), setting_type.FIX), + SettingsAttribute('SCHEDULER_CONFIG', __scheduler_config(), setting_type.FIX), + SettingsAttribute('SCHEDULER_AUTOSTART', os.environ.get("SCHEDULER_AUTOSTART", False)), + SettingsAttribute('SCHEDUER_JOBS', __scheduler_jobs()), + SettingsAttribute('SCHEDULER_CUSTOM', __scheduler_custom()), + + SettingsAttribute('AUTH_USER_MODEL', "core.User"), + SettingsAttribute('AUTH_PASSWORD_VALIDATORS', __password_validators()), + + # Internationalization + # https://docs.djangoproject.com/en/2.1/topics/i18n/ + SettingsAttribute('LANGUAGE_CODE', "en-GB"), + SettingsAttribute('TIME_ZONE', "UTC"), + SettingsAttribute('USE_I18N', True), + SettingsAttribute('USE_L10N', True), + SettingsAttribute('USE_TZ', False), + SettingsAttribute('LOCALE_PATHS', get_locale_folders() + [ + os.path.join(__base_dir(), "locale"), + ]), + # Static files (CSS, JavaScript, Images) + # https://docs.djangoproject.com/en/2.1/howto/static-files/ + SettingsAttribute('STATIC_ROOT', os.path.join(__base_dir(), "staticfiles")), + SettingsAttribute('STATICFILES_STORAGE', "whitenoise.storage.CompressedManifestStaticFilesStorage"), + SettingsAttribute('STATIC_URL', "/%sstatic/" % __site_root()), + + # Django channels require rabbitMQ server, by default it use 127.0.0.1, port 5672 + SettingsAttribute('ASGI_APPLICATION', "openIMIS.asgi.application"), + SettingsAttribute('CHANNEL_LAYERS', __channel_layers()), + + # Django email settings + SettingsAttribute('EMAIL_BACKEND', "django.core.mail.backends.smtp.EmailBackend"), + SettingsAttribute('EMAIL_HOST', os.environ.get("EMAIL_HOST", "localhost")), + SettingsAttribute('EMAIL_PORT', os.environ.get("EMAIL_PORT", "1025")), + SettingsAttribute('EMAIL_HOST_USER', os.environ.get("EMAIL_HOST_USER", "")), + SettingsAttribute('EMAIL_HOST_PASSWORD', os.environ.get("EMAIL_HOST_PASSWORD", "")), + SettingsAttribute('EMAIL_USE_TLS', os.environ.get("EMAIL_USE_TLS", False)), + SettingsAttribute('EMAIL_USE_SSL', os.environ.get("EMAIL_USE_SSL", False)), + + # By default, the maximum upload size is 2.5Mb, which is a bit short + # for base64 picture upload + SettingsAttribute('DATA_UPLOAD_MAX_MEMORY_SIZE', + int(os.environ.get('DATA_UPLOAD_MAX_MEMORY_SIZE', 10*1024*1024))), + SettingsAttribute('INSUREE_NUMBER_LENGTH', os.environ.get("INSUREE_NUMBER_LENGTH", None)), + SettingsAttribute('INSUREE_NUMBER_MODULE_ROOT', os.environ.get("INSUREE_NUMBER_MODULE_ROOT", None)), + + # SettingsAttribute('TEST_WITHOUT_MIGRATIONS_COMMAND', 'django_nose.management.commands.test.Command'), + # SettingsAttribute('TEST_RUNNER', 'core.test_utils.UnManagedModelTestRunner'), + # SettingsAttribute('INSUREE_NUMBER_VALIDATOR', __insuree_number_validator), + + SettingsAttribute('FRONTEND_URL', os.environ.get("FRONTENT_URL", "")), +] + diff --git a/openIMIS/openIMIS/modulesettingsloader/__init__.py b/openIMIS/openIMIS/modulesettingsloader/__init__.py new file mode 100644 index 0000000..191b7af --- /dev/null +++ b/openIMIS/openIMIS/modulesettingsloader/__init__.py @@ -0,0 +1,84 @@ +import logging +import os +import sys + +import typing + +from ..openimisapps import openimis_apps +from .settings_attributes import SettingsAttribute, SettingsAttributeConflictPolicy +from .conflict_resolve_strategies import REGISTERED_STRATEGIES +logger = logging.getLogger(__name__) + + +class SettingAttributeConflictResolver: + @classmethod + def resolve_conflict( + cls, existing_attribute: typing.Union[None, SettingsAttribute], new_attribute: SettingsAttribute): + resolving_policy = cls.get_conflict_resolve_policy(existing_attribute, new_attribute) + return resolving_policy(existing_attribute, new_attribute) + + @classmethod + def get_conflict_resolve_policy(cls, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute): + for registered_resolving_strategy in REGISTERED_STRATEGIES: + if registered_resolving_strategy.is_strategy_matching(existing_attribute, new_attribute): + return registered_resolving_strategy.resolve_conflict + + +class ModulesSettingsLoader: + @classmethod + def load_settings_extensions(cls): + resolved_settings = cls._resolve_settings() + for attribute in resolved_settings: + setattr(sys.modules['openIMIS.settings'], attribute.SETTING_NAME, attribute.SETTING_VALUE) + + @classmethod + def _resolve_settings(cls) -> typing.Iterable[SettingsAttribute]: + openimis_apps_extensions = cls._settings_extensions() + resolved_settings = {} + for module, settings in openimis_apps_extensions.items(): + for setting in settings: + if setting.SETTING_NAME not in resolved_settings: + resolved_settings[setting.SETTING_NAME] = setting + else: + resolved_settings[setting.SETTING_NAME] = SettingAttributeConflictResolver\ + .resolve_conflict(resolved_settings[setting.SETTING_NAME], setting) + return resolved_settings.values() + + @classmethod + def _settings_extensions(cls) -> typing.Dict[str, typing.Iterable[SettingsAttribute]]: + out = {} + modules = ['openIMIS', *openimis_apps()] # Assembly + installed modules + for module in modules: + if setting_extension := cls._get_module_setting_extension(module): + out[module] = setting_extension + return out + + @classmethod + def _get_module_setting_extension(cls, app_): + module_settings_extension = None + try: + settings_extensions = __import__(f"{app_}.django_settings") + if hasattr(settings_extensions.django_settings, "SETTINGS"): + settings = settings_extensions.django_settings.SETTINGS + cls._validate_module_settings(settings) + module_settings_extension = settings + logger.debug(f"{app_} Module SETTINGS loaded.") + else: + logger.debug(f"{app_} has a django_settings attached but no SETTINGS variable") + except ModuleNotFoundError as exc: + logger.debug(f"{app_} has no django_settings, skipping") + except AssertionError as e: + logger.error(e) + except Exception as exc: + logger.debug(f"{app_}: unknown exception occurred during loading SETTINGSn: {exc}") + finally: + return module_settings_extension + + @classmethod + def _validate_module_settings(cls, settings): + assert isinstance(settings, list) and all([isinstance(next_, SettingsAttribute) for next_ in settings]),\ + "Invalid type of django_settings.SETTINGS. It should provide list of SettingsAttribute elements." + + +def load_settings_from_modules(): + ModulesSettingsLoader.load_settings_extensions() diff --git a/openIMIS/openIMIS/modulesettingsloader/conflict_resolve_strategies.py b/openIMIS/openIMIS/modulesettingsloader/conflict_resolve_strategies.py new file mode 100644 index 0000000..8077765 --- /dev/null +++ b/openIMIS/openIMIS/modulesettingsloader/conflict_resolve_strategies.py @@ -0,0 +1,144 @@ +import logging +from typing import Protocol +from .settings_attributes import SettingsAttribute, SettingsAttributeError, SettingsAttributeConflictPolicy + + +logger = logging.getLogger(__name__) + + +class SettingsAttributeConflictResolveStrategy(Protocol): + def is_strategy_matching(self, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute) -> bool: + ... + + def resolve_conflict(self, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute) \ + -> SettingsAttribute: + ... + + +class _AssignIfEmpty(SettingsAttributeConflictResolveStrategy): + def is_strategy_matching(self, existing_attribute, new_attribute): + return existing_attribute is None + + def resolve_conflict(self, existing_attribute, new_attribute): + return new_attribute + + +class _SkipIfYielding(SettingsAttributeConflictResolveStrategy): + def is_strategy_matching(self, existing_attribute, new_attribute): + return new_attribute.is_yielding() + + def resolve_conflict(self, existing_attribute, new_attribute): + return existing_attribute + + +class _RaiseExceptionOnFixed(SettingsAttributeConflictResolveStrategy): + def is_strategy_matching(self, existing_attribute, new_attribute): + return existing_attribute.is_fixed() + + def resolve_conflict(self, existing_attribute, new_attribute): + self._raise_fixed_override_exception(new_attribute) + + @classmethod + def _raise_fixed_override_exception(cls, current): + raise SettingsAttributeError(F"Immutable SETTINGS attribute {current.SETTING_NAME} can't be modified.") + + +class _EnforceNewValue(SettingsAttributeConflictResolveStrategy): + def is_strategy_matching(self, existing_attribute, new_attribute): + return new_attribute.is_enforced() + + def resolve_conflict(self, existing_attribute, new_attribute): + return new_attribute + + +class _MergeAttributes(SettingsAttributeConflictResolveStrategy): + def is_strategy_matching(self, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute) -> bool: + return existing_attribute and new_attribute.is_mergeable() + + def resolve_conflict(self, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute) \ + -> SettingsAttribute: + return self._merge_settings(existing_attribute, new_attribute) + + @classmethod + def _merge_settings(cls, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute): + types_ = (existing_attribute.SETTING_TYPE, new_attribute.SETTING_TYPE) + if types_ == (dict, dict): + return cls._merge_recursively(existing_attribute, new_attribute) + elif types_ == (list, list): + return cls._combine_lists(existing_attribute, new_attribute) + elif types_ == (list, str): + return cls._append_to_list(existing_attribute, new_attribute) + else: + replace_primitive = new_attribute.CONFLICT_POLICY == SettingsAttributeConflictPolicy.MERGE_OVERRIDE + logger.warning(F"Attributes of types {types_} cannot be merged." + F"Value of `{existing_attribute.SETTING_NAME}` setting attribute will " + F"{'not ' if not replace_primitive else ''}" + F"be replaced with incoming value.") + new_attribute.SETTING_VALUE = \ + new_attribute.SETTING_VALUE if replace_primitive else existing_attribute.SETTING_VALUE + return new_attribute + + @classmethod + def _merge_recursively(cls, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute): + new_value = cls._merge_dictionaries( + existing_attribute.SETTING_VALUE, new_attribute.SETTING_VALUE, new_attribute.CONFLICT_POLICY) + new_attribute.SETTING_VALUE = new_value + return new_attribute + + @classmethod + def _combine_lists(cls, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute): + to_add = new_attribute.SETTING_VALUE + current_setting = existing_attribute.SETTING_VALUE + new_value = cls._add_to_list(current_setting, to_add) + new_attribute.SETTING_VALUE = new_value + return new_attribute + + @classmethod + def _append_to_list(cls, existing_attribute: SettingsAttribute, new_attribute: SettingsAttribute): + if new_attribute.SETTING_VALUE not in existing_attribute: + new_attribute.SETTING_VALUE = [*existing_attribute.SETTING_VALUE, new_attribute.SETTING_VALUE] + else: + new_attribute.SETTING_VALUE = existing_attribute.SETTING_VALUE + return new_attribute + + @classmethod + def _merge_dictionaries(cls, existing_dict, new_dict, conflict_policy): + def _is_node_dict(first, second): + return isinstance(first, dict) and isinstance(second, dict) + + def _set_non_dict_value(dict_, value_, key_): + current_value = dict_.get(key_) + if isinstance(current_value, list) and isinstance(value_, list): + dict_[key_] = cls._add_to_list(current_value, value_) + elif current_value and conflict_policy == SettingsAttributeConflictPolicy.MERGE_YIELD: + pass + elif not current_value or conflict_policy == SettingsAttributeConflictPolicy.MERGE_OVERRIDE: + dict_[key_] = value_ + + for key in new_dict.keys(): + current_node, new_node = existing_dict.get(key), new_dict.get(key) + if new_node: + if _is_node_dict(current_node, new_node): + cls._merge_dictionaries(current_node, new_node, conflict_policy) + else: + _set_non_dict_value(existing_dict, new_node, key) + else: + existing_dict[key] = new_node + + return existing_dict + + @classmethod + def _add_to_list(cls, current_setting: list, to_add: list): + for element in to_add: + if element not in current_setting: + current_setting.append(element) + return current_setting + + +REGISTERED_STRATEGIES = [ + _AssignIfEmpty(), + _SkipIfYielding(), + _RaiseExceptionOnFixed(), + _EnforceNewValue(), + _MergeAttributes() +] diff --git a/openIMIS/openIMIS/modulesettingsloader/settings_attributes.py b/openIMIS/openIMIS/modulesettingsloader/settings_attributes.py new file mode 100644 index 0000000..66ad300 --- /dev/null +++ b/openIMIS/openIMIS/modulesettingsloader/settings_attributes.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from enum import Enum + +import typing + + +class SettingsAttributeConflictPolicy(Enum): + """ + Determines behaviour on conflicting settings attributes coming from installed modules. + + MERGE_YIELD - add new or combine with an already existing variable, if it's already determined then: + * for primitives / objects: don't perform action + * for dictionaries: add keys (recursively), if keys are already determined then don't override them + * for iterables: combine iterables + + MERGE_OVERRIDE - add new or combine with an already existing variable, if it's already determined then: + * for primitives / objects: override existing variable + * for dictionaries: add keys (recursively), if keys are already determined then override them + * for iterables: combine iterables + + ENFORCE - assign variable, if it's already determined then: + * override existing one with new definition + + FIX - assign variable, if it's already determined then: + * override existing one with new definition without combining with existing content. + Can't be overridden. If another module attempted to override it - raise FixedSettingAttribute exception. + + YIELD - assign variable, if it's already determined then: + * don't perform any action + + """ + MERGE_YIELD = 'MERGE_YIELD' + MERGE_OVERRIDE = 'MERGE_OVERRIDE' + ENFORCE = 'ENFORCE' + FIX = 'FIX' + YIELD = 'YIELD' + + +@dataclass +class SettingsAttribute: + """ + Settings attributes added through module.SettingsExtension have to provide information from this dataclass. + Attributes: + SETTING_NAME - Name of setting attribute + SETTING_VALUE - Value of setting + CONFLICT_POLICY - Behaviour on multiple attribute assignments + """ + SETTING_NAME: str + SETTING_VALUE: 'typing.Any' + CONFLICT_POLICY: SettingsAttributeConflictPolicy = SettingsAttributeConflictPolicy.MERGE_YIELD + + @property + def SETTING_TYPE(self): + return type(self.SETTING_VALUE) + + def is_fixed(self): + return self.CONFLICT_POLICY == SettingsAttributeConflictPolicy.FIX + + def is_yielding(self): + return self.CONFLICT_POLICY == SettingsAttributeConflictPolicy.YIELD + + def is_enforced(self): + return self.CONFLICT_POLICY == SettingsAttributeConflictPolicy.ENFORCE + + def is_mergeable(self): + return self.CONFLICT_POLICY in \ + (SettingsAttributeConflictPolicy.MERGE_YIELD, SettingsAttributeConflictPolicy.MERGE_OVERRIDE) + + +class SettingsAttributeError(AttributeError): + pass diff --git a/openIMIS/openIMIS/openimisconf.py b/openIMIS/openIMIS/openimisconf.py index 1d31b0f..026a70d 100644 --- a/openIMIS/openIMIS/openimisconf.py +++ b/openIMIS/openIMIS/openimisconf.py @@ -2,7 +2,7 @@ import os import io -def load_openimis_conf( conf_file_param = "../openimis.json" ): +def load_openimis_conf(conf_file_param = "../openimis.json" ): conf_json_env = os.environ.get("OPENIMIS_CONF_JSON", "") conf_file_path = os.environ.get("OPENIMIS_CONF", conf_file_param) if not conf_json_env: diff --git a/openIMIS/openIMIS/settings.py b/openIMIS/openIMIS/settings.py index 24b5c3b..93def66 100644 --- a/openIMIS/openIMIS/settings.py +++ b/openIMIS/openIMIS/settings.py @@ -1,474 +1,25 @@ """ Django settings for openIMIS project. -""" -import json -import logging -import os +Settings and values are added to settings.py +through load_settings_from_modules() +""" from dotenv import load_dotenv -from .openimisapps import openimis_apps, get_locale_folders -from datetime import timedelta - -load_dotenv() - -# Makes openimis_apps available to other modules -OPENIMIS_APPS = openimis_apps() - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -LOGGING_LEVEL = os.getenv("DJANGO_LOG_LEVEL", "WARNING") -DEFAULT_LOGGING_HANDLER = os.getenv("DJANGO_LOG_HANDLER", "debug-log") - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, - "short": {"format": "%(name)s: %(message)s"}, - }, - "handlers": { - "db-queries": { - "level": LOGGING_LEVEL, - "class": "logging.handlers.RotatingFileHandler", - "filename": os.environ.get("DB_QUERIES_LOG_FILE", "db-queries.log"), - "maxBytes": 1024 * 1024 * 5, # 5 MB - "backupCount": 10, - "formatter": "standard", - }, - "debug-log": { - "level": LOGGING_LEVEL, - "class": "logging.handlers.RotatingFileHandler", - "filename": os.environ.get("DEBUG_LOG_FILE", "debug.log"), - "maxBytes": 1024 * 1024 * 5, # 5 MB - "backupCount": 3, - "formatter": "standard", - }, - "console": {"class": "logging.StreamHandler", "formatter": "short"}, - }, - "loggers": { - "": { - "level": LOGGING_LEVEL, - "handlers": [DEFAULT_LOGGING_HANDLER], - }, - "django.db.backends": { - "level": LOGGING_LEVEL, - "propagate": False, - "handlers": ["db-queries"], - }, - "openIMIS": { - "level": LOGGING_LEVEL, - "handlers": [DEFAULT_LOGGING_HANDLER], - }, - }, -} - -SENTRY_DSN = os.environ.get("SENTRY_DSN", None) -SENTRY_SAMPLE_RATE = os.environ.get("SENTRY_SAMPLE_RATE", "0.2") -IS_SENTRY_ENABLED = False - -if SENTRY_DSN is not None: - try: - import sentry_sdk - from sentry_sdk.integrations.django import DjangoIntegration - - sentry_sdk.init( - dsn=SENTRY_DSN, - integrations=[DjangoIntegration()], - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - # We recommend adjusting this value in production, - traces_sample_rate=float(SENTRY_SAMPLE_RATE), - # If you wish to associate users to errors (assuming you are using - # django.contrib.auth) you may enable sending PII data. - send_default_pii=True, - # By default the SDK will try to use the SENTRY_RELEASE - # environment variable, or infer a git commit - # SHA as release, however you may want to set - # something more human-readable. - # release="myapp@1.0.0", - ) - IS_SENTRY_ENABLED = True - except ModuleNotFoundError: - logging.error( - "sentry_sdk has to be installed to use Sentry. Run `pip install --upgrade sentry_sdk` to install it." - ) - - -def SITE_ROOT(): - root = os.environ.get("SITE_ROOT", "") - if root == "": - return root - elif root.endswith("/"): - return root - else: - return "%s/" % root - - -def SITE_URL(): - url = os.environ.get("SITE_URL", "") - if url == "": - return url - elif url.endswith("/"): - return url[:-1] - else: - return url - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ +from .openimisapps import openimis_apps +from .modulesettingsloader import load_settings_from_modules -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get( - "SECRET_KEY", "chv^^7i_v3-04!rzu&qe#+h*a=%h(ib#5w9n$!f2q7%2$qp=zz" -) -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.environ.get("DEBUG", "False").lower() == "true" -# SECURITY WARNING: don't run without row security in production! -# Row security is dedicated to filter the data result sets according to users' right -# Example: user registered at a Health Facility should only see claims recorded for that Health Facility -ROW_SECURITY = os.environ.get("ROW_SECURITY", "True").lower() == "true" - -if "ALLOWED_HOSTS" in os.environ: - ALLOWED_HOSTS = json.loads(os.environ["ALLOWED_HOSTS"]) -else: - ALLOWED_HOSTS = ["*"] +load_dotenv() -# TEST_WITHOUT_MIGRATIONS_COMMAND = 'django_nose.management.commands.test.Command' -# TEST_RUNNER = 'core.test_utils.UnManagedModelTestRunner' +# Loads django settings from assembly and modules +load_settings_from_modules() -# Application definition +# Makes openimis_apps available to other modules +OPENIMIS_APPS = openimis_apps() -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "graphene_django", - "graphql_jwt.refresh_token.apps.RefreshTokenConfig", - "test_without_migrations", - "rest_framework", - "rules", - "health_check", # required - "health_check.db", # stock Django health checkers - "health_check.cache", - "health_check.storage", - "django_apscheduler", - "channels", # Websocket support - "developer_tools", - "drf_spectacular" # Swagger UI for FHIR API -] +# Base INSTALLED_APPS are taken from openIMIS.django_settings, +# imis apps and signals are added directly in settings as they should be installed at the end. INSTALLED_APPS += OPENIMIS_APPS -INSTALLED_APPS += ["apscheduler_runner", "signal_binding"] # Signal binding should be last installed module - -AUTHENTICATION_BACKENDS = [] - -if os.environ.get("REMOTE_USER_AUTHENTICATION", "false").lower() == "true": - AUTHENTICATION_BACKENDS += ["django.contrib.auth.backends.RemoteUserBackend"] - -AUTHENTICATION_BACKENDS += [ - "rules.permissions.ObjectPermissionBackend", - "graphql_jwt.backends.JSONWebTokenBackend", - "django.contrib.auth.backends.ModelBackend", -] - -ANONYMOUS_USER_NAME = None - -REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": [ - "core.jwt_authentication.JWTAuthentication", - "rest_framework.authentication.BasicAuthentication", - "rest_framework.authentication.SessionAuthentication", - ], - "EXCEPTION_HANDLER": "openIMIS.ExceptionHandlerDispatcher.dispatcher", - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', -} - -SPECTACULAR_SETTINGS = { - 'TITLE': 'FHIR R4', - 'DESCRIPTION': 'openIMIS FHIR R4 API', - 'VERSION': '1.0.0', - 'AUTHENTICATION_WHITELIST': [ - 'core.jwt_authentication.JWTAuthentication', - 'api_fhir_r4.views.CsrfExemptSessionAuthentication' - ], -} - -if os.environ.get("REMOTE_USER_AUTHENTICATION", "false").lower() == "true": - REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].insert( - 0, - "rest_framework.authentication.RemoteUserAuthentication", - ) - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.locale.LocaleMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", -] - -if DEBUG: - # Attach profiler middleware - MIDDLEWARE.append( - "django_cprofile_middleware.middleware.ProfilerMiddleware" - ) - DJANGO_CPROFILE_MIDDLEWARE_REQUIRE_STAFF = False - -if os.environ.get("REMOTE_USER_AUTHENTICATION", "false").lower() == "true": - MIDDLEWARE += ["core.security.RemoteUserMiddleware"] -MIDDLEWARE += [ - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "openIMIS.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "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", - ], - }, - }, -] - -WSGI_APPLICATION = "openIMIS.wsgi.application" - -GRAPHENE = { - "SCHEMA": "openIMIS.schema.schema", - "RELAY_CONNECTION_MAX_LIMIT": 100, - "GRAPHIQL_HEADER_EDITOR_ENABLED": True, - "MIDDLEWARE": [ - "openIMIS.tracer.TracerMiddleware", - "openIMIS.schema.GQLUserLanguageMiddleware", - "graphql_jwt.middleware.JSONWebTokenMiddleware", - "graphene_django.debug.DjangoDebugMiddleware", # adds a _debug query to graphQL with sql debug info - ], -} - -GRAPHQL_JWT = { - "JWT_VERIFY_EXPIRATION": True, - "JWT_LONG_RUNNING_REFRESH_TOKEN": True, - "JWT_EXPIRATION_DELTA": timedelta(days=1), - "JWT_REFRESH_EXPIRATION_DELTA": timedelta(days=30), - "JWT_AUTH_HEADER_PREFIX": "Bearer", - "JWT_ENCODE_HANDLER": "core.jwt.jwt_encode_user_key", - "JWT_DECODE_HANDLER": "core.jwt.jwt_decode_user_key", - # This can be used to expose some resources without authentication - "JWT_ALLOW_ANY_CLASSES": [ - "graphql_jwt.mutations.ObtainJSONWebToken", - "graphql_jwt.mutations.Verify", - "graphql_jwt.mutations.Refresh", - "graphql_jwt.mutations.Revoke", - "core.schema.ResetPasswordMutation", - "core.schema.SetPasswordMutation", - ], -} - -# Database -# https://docs.djangoproject.com/en/2.1/ref/settings/#databases - -DB_ENGINE = os.environ.get("DB_ENGINE", "mssql") # sql_server.pyodbc is deprecated for Django 3.1+ - -if "sql_server.pyodbc" in DB_ENGINE or "mssql" in DB_ENGINE: - MSSQL = True -else: - MSSQL = False - -if "DB_OPTIONS" in os.environ: - DATABASE_OPTIONS = json.loads(os.environ["DB_OPTIONS"]) -elif MSSQL: - if os.name == "nt": - DATABASE_OPTIONS = { - "driver": "ODBC Driver 17 for SQL Server", - "extra_params": "Persist Security Info=False;server=%s" - % os.environ.get("DB_HOST"), - "unicode_results": True, - } - else: - DATABASE_OPTIONS = { - "driver": "ODBC Driver 17 for SQL Server", - "unicode_results": True, - } -else: - DATABASE_OPTIONS = {} - -if not os.environ.get("NO_DATABASE_ENGINE", "False") == "True": - DATABASES = { - "default": { - "ENGINE": DB_ENGINE, - "NAME": os.environ.get("DB_NAME"), - "USER": os.environ.get("DB_USER"), - "PASSWORD": os.environ.get("DB_PASSWORD"), - "HOST": os.environ.get("DB_HOST"), - "PORT": os.environ.get("DB_PORT"), - "OPTIONS": DATABASE_OPTIONS, - } - } - -# Celery message broker configuration for RabbitMQ. One can also use Redis on AWS SQS -CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "amqp://127.0.0.1") - -# This scheduler config will: -# - Store jobs in the project database -# - Execute jobs in threads inside the application process, for production use, we could use a dedicated process -SCHEDULER_CONFIG = { - "apscheduler.jobstores.default": { - "class": "django_apscheduler.jobstores:DjangoJobStore" - }, - "apscheduler.executors.processpool": {"type": "threadpool"}, -} - -SCHEDULER_AUTOSTART = os.environ.get("SCHEDULER_AUTOSTART", False) - -# Normally, one creates a "scheduler" method that calls the appropriate scheduler.add_job but since we are in a -# modular architecture and calling only once from the core module, this has to be dynamic. -# This list will be called with scheduler.add_job() as specified: -# Note that the document implies that the time is local and follows DST but that seems false and in UTC regardless -SCHEDULER_JOBS = [ - { - "method": "core.tasks.openimis_test_batch", - "args": ["cron"], - "kwargs": {"id": "openimis_test_batch", "minute": 16, "replace_existing": True}, - }, - # { - # "method": "policy.tasks.get_policies_for_renewal", - # "args": ["cron"], - # "kwargs": {"id": "openimis_renewal_batch", "hour": 8, "minute": 30, "replace_existing": True}, - # }, - # { - # "method": "policy_notification.tasks.send_notification_messages", - # "args": ["cron"], - # "kwargs": {"id": "openimis_notification_batch", 'day_of_week': '*', - # "hour": "8,12,16,20", "replace_existing": True}, - # }, - # { - # "method": "claim_ai_quality.tasks.claim_ai_processing", - # "args": ["cron"], - # "kwargs": {"id": "claim_ai_processing", - # "hour": 0 - # "minute", 30 - # "replace_existing": True}, - # }, -] -# This one is called directly with the scheduler object as first parameter. The methods can schedule things on their own -SCHEDULER_CUSTOM = [ - { - "method": "core.tasks.sample_method", - "args": ["sample"], - "kwargs": {"sample_named": "param"}, - }, -] - - -AUTH_USER_MODEL = "core.User" - -# Password validation -# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/2.1/topics/i18n/ - -LANGUAGE_CODE = "en-GB" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_L10N = True - -USE_TZ = False - -# List of places to look for translations, this could include an external translation module -LOCALE_PATHS = get_locale_folders() + [ - os.path.join(BASE_DIR, "locale"), -] - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.1/howto/static-files/ - -STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" -STATIC_URL = "/%sstatic/" % SITE_ROOT() - - -ASGI_APPLICATION = "openIMIS.asgi.application" - -# Django channels require rabbitMQ server, by default it use 127.0.0.1, port 5672 -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels_rabbitmq.core.RabbitmqChannelLayer", - "CONFIG": { - "host": os.environ.get("CHANNELS_HOST", "amqp://guest:guest@127.0.0.1/"), - # "ssl_context": ... (optional) - }, - }, -} - -# Django email settings -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" - -EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost") -EMAIL_PORT = os.environ.get("EMAIL_PORT", "1025") -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", False) -EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", False) - -# By default, the maximum upload size is 2.5Mb, which is a bit short for base64 picture upload -DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get('DATA_UPLOAD_MAX_MEMORY_SIZE', 10*1024*1024)) - - -# Insuree number validation. One can use the validator function for specific processing or just specify the length -# and modulo for the typical use case. These two can be overridden from the environment but the validator being a -# function, it is not possible. -# -# def insuree_number_validator(x): -# if str(x)[0] != "x": -# return ["don't start with x"] -# else: -# return [] -# -# -# INSUREE_NUMBER_VALIDATOR = insuree_number_validator -INSUREE_NUMBER_LENGTH = os.environ.get("INSUREE_NUMBER_LENGTH", None) -INSUREE_NUMBER_MODULE_ROOT = os.environ.get("INSUREE_NUMBER_MODULE_ROOT", None) - - -# There used to be a default password for zip files but for security reasons, it was removed. Trying to export -# without a password defined is going to fail -MASTER_DATA_PASSWORD = os.environ.get("MASTER_DATA_PASSWORD", None) - -FRONTEND_URL = os.environ.get("FRONTEND_URL", "") +INSTALLED_APPS += ["signal_binding"] # Signal binding should be last installed module -USE_X_FORWARDED_HOST = True -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')