diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..caa9115ee --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# --- Core settings --- +DEBUG='True' +SECRET_KEY='INSECURE_SECRET' +# This must be a JSON-encoded array of strings +ALLOWED_HOSTS='["*"]' + +# --- Databases --- +# This is relative to the `REPO_DIR` setting - except if it starts with a `/`, of course +SQLITE_DB_PATH='db.sqlite3' + +# --- `django-hosts` --- +PARENT_HOST='makentnu.localhost:8000' + +# --- Cookies --- +COOKIE_DOMAIN='.makentnu.localhost' +COOKIE_SECURE='False' + +# --- Static and media files --- +# This is relative to the `REPO_DIR` setting - except if it starts with a `/`, of course +STATIC_AND_MEDIA_FILES__PARENT_DIR='../' + +# --- Emailing --- +EMAIL_HOST='localhost' +EMAIL_HOST_USER='test' +EMAIL_PORT='25' +EMAIL_USE_TLS='False' +DEFAULT_FROM_EMAIL='test@localhost' +SERVER_EMAIL='server@localhost' +EMAIL_SUBJECT_PREFIX='[Django] ' # Note the trailing space +# This must be a JSON-encoded array of 2-element arrays [, ] +# (see https://docs.djangoproject.com/en/stable/ref/settings/#admins) +ADMINS='[]' + +# --- Dataporten/Feide --- +USE_DATAPORTEN_AUTH='False' +SOCIAL_AUTH_DATAPORTEN_KEY='KEY' +SOCIAL_AUTH_DATAPORTEN_SECRET='SECRET' + +# --- Checkin --- +CHECKIN_KEY='KEY' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e3a6f274..b47081d8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,10 @@ jobs: - name: Install Python dependencies run: uv sync --locked --group ci + - name: Create `.env` + shell: bash + run: cp .env.example .env + - name: Run Django Tests run: | uv run manage.py collectstatic --no-input diff --git a/.gitignore b/.gitignore index 950d66680..e2eb6e6d7 100644 --- a/.gitignore +++ b/.gitignore @@ -473,6 +473,4 @@ pip-selfcheck.json # End of https://www.toptal.com/developers/gitignore/api/django,pycharm+all,venv,visualstudiocode,macos,node - !src/web/static/lib/ -local_settings_post.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 991f01542..dfc44d3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ Lastly, a new [release](https://github.com/MAKENTNU/web/releases) must be create - Prevented `compilemessages` from processing files outside the `src` folder (MAKENTNU/web#766) - Migrated project to use the more modern `pyproject.toml` and [uv](https://docs.astral.sh/uv/) instead of `requirements.txt` and pip (MAKENTNU/web#766) +- Replaced `local_settings.py` with `.env` (MAKENTNU/web#767) + - Developers must create a `.env` file locally - see the "Setup" section of the README ## 2025-05-03 (MAKENTNU/web#757) diff --git a/README.md b/README.md index c66a78910..af919e192 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ ```shell uv sync --group dev ``` +1. Create an empty `.env` file directly inside the repository folder, and fill it by + copying the contents of [`.env.example`](.env.example) #### PyCharm diff --git a/docker-compose.yml b/docker-compose.yml index 3f0513f23..cd7438f47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,3 +10,5 @@ services: - ./:/web ports: - "8000:8000" + env_file: + - .env diff --git a/env.py b/env.py new file mode 100644 index 000000000..8475eeb78 --- /dev/null +++ b/env.py @@ -0,0 +1,97 @@ +import json +import os +from enum import StrEnum +from typing import Final + +from dotenv import load_dotenv + +# Load the environment variables in the `.env` file +load_dotenv() + +_NOT_PROVIDED: Final = object() + + +class DatabaseSystem(StrEnum): + POSTGRESQL = "postgres" + SQLITE = "sqlite" + + +def get_envvar(name: str, *, default: str = _NOT_PROVIDED) -> str: + """Returns the environment variable with name ``name``. + If it doesn't exist and ``default`` has been provided, ``default`` is returned.""" + value = os.environ.get(name) + if value is None or value == "": + if default is _NOT_PROVIDED: + raise KeyError(f"Missing environment variable `{name}`.") + value = default + return value + + +def get_bool_envvar(name: str, *, default: bool = _NOT_PROVIDED) -> bool: + """Returns the same as ``get_envvar()``, but with the value interpreted as a + ``bool``: ``True`` if the value equals ``"true"`` (case-insensitive), ``False`` + otherwise.""" + value = get_envvar( + name, + default=str(default) if type(default) is bool else default, + ) + return value.lower() == "true" + + +# DEV: If a setting *should* be specified in prod, it should *not* have a `default`! + +# --- Core settings --- +DEBUG: Final = get_bool_envvar("DEBUG") +SECRET_KEY: Final = get_envvar("SECRET_KEY") +ALLOWED_HOSTS: Final = list(json.loads(get_envvar("ALLOWED_HOSTS"))) +INTERNAL_IPS: Final = list( + json.loads(get_envvar("INTERNAL_IPS", default='["127.0.0.1"]')) +) + +# --- Databases --- +DATABASE_SYSTEM: Final = DatabaseSystem( + get_envvar("DATABASE_SYSTEM", default=DatabaseSystem.SQLITE) +) +SQLITE_DB_PATH: Final = get_envvar("SQLITE_DB_PATH", default="db.sqlite3") +POSTGRES_HOST: Final = get_envvar("POSTGRES_HOST", default="localhost") +POSTGRES_DB_NAME: Final = get_envvar("POSTGRES_DB_NAME", default="make_web") +POSTGRES_DB_USER: Final = get_envvar("POSTGRES_DB_USER", default="devuser") +POSTGRES_DB_PASSWORD: Final = get_envvar("POSTGRES_DB_PASSWORD", default="devpassword") + +# --- `django-hosts` --- +PARENT_HOST: Final = get_envvar("PARENT_HOST") + +# --- Cookies --- +COOKIE_DOMAIN: Final = get_envvar("COOKIE_DOMAIN") +COOKIE_SECURE: Final = get_bool_envvar("COOKIE_SECURE") + +# --- Static and media files --- +STATIC_AND_MEDIA_FILES__PARENT_DIR: Final = get_envvar( + "STATIC_AND_MEDIA_FILES__PARENT_DIR" +) +# The max size in prod is 50 MiB (through Nginx) +MEDIA_FILE_MAX_SIZE__MB: Final = int(get_envvar("MEDIA_FILE_MAX_SIZE__MB", default="25")) + +# --- `channels` --- +REDIS_HOST: Final = get_envvar("REDIS_HOST", default="localhost") +REDIS_PORT: Final = int(get_envvar("REDIS_PORT", default="6379")) + +# --- Emailing --- +EMAIL_HOST: Final = get_envvar("EMAIL_HOST") +EMAIL_HOST_USER: Final = get_envvar("EMAIL_HOST_USER") +EMAIL_PORT: Final = int(get_envvar("EMAIL_PORT")) +EMAIL_USE_TLS: Final = get_bool_envvar("EMAIL_USE_TLS") +DEFAULT_FROM_EMAIL: Final = get_envvar("DEFAULT_FROM_EMAIL") +SERVER_EMAIL: Final = get_envvar("SERVER_EMAIL") +EMAIL_SUBJECT_PREFIX: Final = get_envvar("EMAIL_SUBJECT_PREFIX") +ADMINS: Final = [ + (full_name, email) for full_name, email in json.loads(get_envvar("ADMINS")) +] + +# --- Dataporten/Feide --- +USE_DATAPORTEN_AUTH: Final = get_bool_envvar("USE_DATAPORTEN_AUTH") +SOCIAL_AUTH_DATAPORTEN_KEY: Final = get_envvar("SOCIAL_AUTH_DATAPORTEN_KEY") +SOCIAL_AUTH_DATAPORTEN_SECRET: Final = get_envvar("SOCIAL_AUTH_DATAPORTEN_SECRET") + +# --- Checkin --- +CHECKIN_KEY: Final = get_envvar("CHECKIN_KEY") diff --git a/manage.py b/manage.py index 99bc407fb..b492b017a 100755 --- a/manage.py +++ b/manage.py @@ -8,6 +8,9 @@ def main(): """Run administrative tasks.""" + # IMPORTANT: Ensure this import is kept here, as it loads the envvars + import env # noqa: F401 + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'web.settings') try: from django.core.management import execute_from_command_line diff --git a/pyproject.toml b/pyproject.toml index 56b003b8f..7c2f09dae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ dependencies = [ "channels[daphne]==4.1.0", "channels-redis==4.2.0", + # Environment variables + "python-dotenv==1.1.1", + # Misc. packages "bleach[css]==6.1.0", "Pillow==11.3.0", diff --git a/src/util/url_utils.py b/src/util/url_utils.py index 967bb2b85..49622a7aa 100644 --- a/src/util/url_utils.py +++ b/src/util/url_utils.py @@ -117,7 +117,7 @@ def logout_urls(): from dataporten import views as dataporten_views # Avoids circular importing # Both of these views log the user out, then redirects to the value of the `LOGOUT_REDIRECT_URL` setting - if settings.USES_DATAPORTEN_AUTH: + if settings.USE_DATAPORTEN_AUTH: logout_view = dataporten_views.Logout.as_view() else: logout_view = auth_views.LogoutView.as_view() diff --git a/src/web/admin_urls.py b/src/web/admin_urls.py index df07b43a6..3cc2695f7 100644 --- a/src/web/admin_urls.py +++ b/src/web/admin_urls.py @@ -32,7 +32,7 @@ # Disable admin page login if Dataporten is configured, # as in that case, all users would log in through Dataporten anyways -if settings.SOCIAL_AUTH_DATAPORTEN_SECRET: +if settings.USE_DATAPORTEN_AUTH: urlpatterns.insert(0, path("login/", RedirectView.as_view( url=f"{reverse('login', host='main')}?next=//admin.{settings.PARENT_HOST}" ))) diff --git a/src/web/settings.py b/src/web/settings.py index 3e65a1fe0..91a720ba8 100644 --- a/src/web/settings.py +++ b/src/web/settings.py @@ -10,6 +10,8 @@ from django.utils.translation import gettext_lazy as _ from django_hosts import reverse_lazy +import env +from env import DatabaseSystem from .static import serve_interpolated @@ -32,21 +34,24 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Default values -DATABASE = 'sqlite' # (custom setting; used below for selecting database configuration) DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -# NOTE: These settings must be changed in production! -SECRET_KEY = ' ' -DEBUG = True -ALLOWED_HOSTS = ['*'] -INTERNAL_IPS = ['127.0.0.1'] +DEBUG = env.DEBUG +SECRET_KEY = env.SECRET_KEY +ALLOWED_HOSTS = env.ALLOWED_HOSTS +INTERNAL_IPS = env.INTERNAL_IPS -MEDIA_ROOT = REPO_DIR.parent / 'media' +STATIC_AND_MEDIA_FILES__PARENT_DIR = ( # (custom setting) + REPO_DIR / env.STATIC_AND_MEDIA_FILES__PARENT_DIR +).resolve() +MEDIA_ROOT = STATIC_AND_MEDIA_FILES__PARENT_DIR / 'media' MEDIA_URL = '/media/' # Based on https://github.com/Uninett/python-dataporten-auth/blob/bad1b95483c5da7d279df4a8d542a3c24c928095/src/demosite/settings.py#L120-L121 -SOCIAL_AUTH_DATAPORTEN_KEY = '' # "Client ID" in the OpenID Connect configuration in Feide's customer portal -SOCIAL_AUTH_DATAPORTEN_SECRET = '' # "Client Secret" in the same configuration +# "Client ID" in the OpenID Connect configuration in Feide's customer portal +SOCIAL_AUTH_DATAPORTEN_KEY = env.SOCIAL_AUTH_DATAPORTEN_KEY +# "Client Secret" in the same configuration +SOCIAL_AUTH_DATAPORTEN_SECRET = env.SOCIAL_AUTH_DATAPORTEN_SECRET # These will be internationalized since `reverse_lazy()` is used # (i.e. these will be English URLs when the user is on the English version of the website, and vice versa for Norwegian) @@ -54,33 +59,21 @@ LOGIN_REDIRECT_URL = reverse_lazy('index_page') LOGOUT_REDIRECT_URL = reverse_lazy('index_page') -# NOTE: This must be changed in production! -CHECKIN_KEY = '' # (custom setting) +CHECKIN_KEY = env.CHECKIN_KEY # (custom setting) -REDIS_IP = '127.0.0.1' # (custom setting) -REDIS_PORT = 6379 # (custom setting) - -FILE_MAX_SIZE = 25 * 2 ** 20 # 25 MiB (custom setting; the max on the server is 50 MiB) +# Converting MiB to bytes +FILE_MAX_SIZE = env.MEDIA_FILE_MAX_SIZE__MB * 2 ** 20 # (custom setting) # The `SESSION_COOKIE_DOMAIN`, `CSRF_COOKIE_DOMAIN` and `LANGUAGE_COOKIE_DOMAIN` will be set to this value -# NOTE: This must be changed in production! -COOKIE_DOMAIN = '.makentnu.localhost' # (custom setting) +COOKIE_DOMAIN = env.COOKIE_DOMAIN # (custom setting) # The `SESSION_COOKIE_SECURE`, `CSRF_COOKIE_SECURE` and `LANGUAGE_COOKIE_SECURE` will be set to this value -# NOTE: This should be set to `True` in production! -COOKIE_SECURE = False # (custom setting) +COOKIE_SECURE = env.COOKIE_SECURE # (custom setting) # For `django-hosts` to redirect correctly across subdomains, we have to specify the host we are running on. -# NOTE: This must be changed in production! -PARENT_HOST = 'makentnu.localhost:8000' +PARENT_HOST = env.PARENT_HOST EVENT_TICKET_EMAIL = 'ticket@makentnu.no' # (custom setting) -# Set local settings -try: - from .local_settings import * -except ImportError: - pass - # When using more than one subdomain, the session cookie domain has to be set so that the subdomains can use the same session # (Cannot use only ".localhost", as domains for cookies are required to have two dots in them) @@ -238,7 +231,7 @@ def generate_all_hosts(subdomains): 'default': { 'BACKEND': 'channels_redis.core.RedisChannelLayer', 'CONFIG': { - 'hosts': [(REDIS_IP, REDIS_PORT)], + 'hosts': [(env.REDIS_HOST, env.REDIS_PORT)], # The maximum resend time of a message in seconds 'expiry': 30, # The number of seconds before a connection expires @@ -250,24 +243,26 @@ def generate_all_hosts(subdomains): # Database # https://docs.djangoproject.com/en/stable/ref/settings/#databases -if DATABASE == 'postgres': - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': DATABASE_NAME, - 'USER': DATABASE_USER, - 'PASSWORD': DATABASE_PASSWORD, - 'HOST': 'localhost', - 'PORT': '', - }, - } -else: - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': str(REPO_DIR / 'db.sqlite3'), - }, - } +match env.DATABASE_SYSTEM: + case DatabaseSystem.POSTGRESQL: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'HOST': env.POSTGRES_HOST, + 'NAME': env.POSTGRES_DB_NAME, + 'USER': env.POSTGRES_DB_USER, + 'PASSWORD': env.POSTGRES_DB_PASSWORD, + }, + } + case DatabaseSystem.SQLITE: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': str((REPO_DIR / env.SQLITE_DB_PATH).resolve()), + }, + } + case _: + raise NotImplementedError # Password validation # https://docs.djangoproject.com/en/stable/ref/settings/#auth-password-validators @@ -336,7 +331,7 @@ def generate_all_hosts(subdomains): # Dataporten -USES_DATAPORTEN_AUTH = SOCIAL_AUTH_DATAPORTEN_KEY and SOCIAL_AUTH_DATAPORTEN_SECRET # (custom setting) +USE_DATAPORTEN_AUTH = env.USE_DATAPORTEN_AUTH # (custom setting) SOCIAL_AUTH_DATAPORTEN_FEIDE_SSL_PROTOCOL = True SOCIAL_AUTH_LOGIN_REDIRECT_URL = reverse_lazy('index_page') @@ -398,7 +393,7 @@ def generate_all_hosts(subdomains): # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/stable/howto/static-files/ -STATIC_ROOT = REPO_DIR.parent / 'static' +STATIC_ROOT = STATIC_AND_MEDIA_FILES__PARENT_DIR / 'static' STATIC_URL = '/static/' # This is based on Django's ManifestStaticFilesStorage, which appends every static file's MD5 hash to its filename, @@ -509,7 +504,16 @@ def static_lazy(path): } +# Emailing PRINT_EMAILS_TO_CONSOLE = DEBUG or is_testing # (custom setting) +EMAIL_HOST = env.EMAIL_HOST +EMAIL_HOST_USER = env.EMAIL_HOST_USER +EMAIL_PORT = env.EMAIL_PORT +EMAIL_USE_TLS = env.EMAIL_USE_TLS +DEFAULT_FROM_EMAIL = env.DEFAULT_FROM_EMAIL +SERVER_EMAIL = env.SERVER_EMAIL +EMAIL_SUBJECT_PREFIX = env.EMAIL_SUBJECT_PREFIX +ADMINS = env.ADMINS # See https://docs.djangoproject.com/en/stable/topics/logging/ for # more details on how to customize your logging configuration. @@ -561,6 +565,11 @@ def static_lazy(path): f'django.{disabled_logger_name}': {'propagate': False} for disabled_logger_name in ['db', 'template', 'utils'] }, + # Prevent deluge of "X of X channels over capacity in group stream_XXX" INFO messages from `channels_redis` + 'channels_redis': { + 'handlers': ['console'], + 'level': 'WARNING', + }, }, } @@ -572,10 +581,3 @@ def static_lazy(path): # 'level': 'DEBUG', # 'propagate': True, # } - - -# [SHOULD BE KEPT LAST IN THIS FILE] Override the settings above -try: - from .local_settings_post import * -except ImportError: - pass diff --git a/src/web/urls.py b/src/web/urls.py index c5c15ef6a..bf0b00e58 100644 --- a/src/web/urls.py +++ b/src/web/urls.py @@ -108,7 +108,7 @@ ) # Configure login based on if we have configured Dataporten or not. -if settings.USES_DATAPORTEN_AUTH: +if settings.USE_DATAPORTEN_AUTH: urlpatterns += i18n_patterns( path("login/", RedirectView.as_view(url="/login/dataporten/", query_string=True), name='login'), diff --git a/uv.lock b/uv.lock index 5677d869f..bb4a7d0ec 100644 --- a/uv.lock +++ b/uv.lock @@ -630,6 +630,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + [[package]] name = "python-jose" version = "3.5.0" @@ -927,6 +936,7 @@ dependencies = [ { name = "django-phonenumber-field", extra = ["phonenumbers"] }, { name = "django-simple-history" }, { name = "pillow" }, + { name = "python-dotenv" }, { name = "python-ldap", version = "3.4.4", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'win32'" }, { name = "python-ldap", version = "3.4.4", source = { url = "https://github.com/cgohlke/python-ldap-build/releases/download/v3.4.4-2/python_ldap-3.4.4-cp311-cp311-win_amd64.whl" }, marker = "sys_platform == 'win32'" }, { name = "social-auth-app-django" }, @@ -961,6 +971,7 @@ requires-dist = [ { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = "==7.3.0" }, { name = "django-simple-history", specifier = "==3.5.0" }, { name = "pillow", specifier = "==11.3.0" }, + { name = "python-dotenv", specifier = "==1.1.1" }, { name = "python-ldap", marker = "sys_platform != 'win32'", specifier = "==3.4.4" }, { name = "python-ldap", marker = "sys_platform == 'win32'", url = "https://github.com/cgohlke/python-ldap-build/releases/download/v3.4.4-2/python_ldap-3.4.4-cp311-cp311-win_amd64.whl" }, { name = "social-auth-app-django", specifier = "==5.4.1" },