diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1f803cf..3d27711d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,9 +49,11 @@ jobs: PGHOST: localhost PGUSER: postgres PGPASSWORD: password - TOX_OVERRIDE: "testenv.pass_env=TOX_OVERRIDE,PG*" + RUFF_OUTPUT_FORMAT: github + TOX_OVERRIDE: "testenv.pass_env=TOX_OVERRIDE,PG*,RUFF_OUTPUT_FORMAT" run: | pip install $(grep -E "^(tox|tox-uv)==" requirements/local.txt) + echo "::add-matcher::.github/workflows/matchers/ruff.json" tox -e ${{ matrix.testenv }} services: postgres: diff --git a/{{cookiecutter.project_slug}}/.github/workflows/matchers/ruff.json b/{{cookiecutter.project_slug}}/.github/workflows/matchers/ruff.json new file mode 100644 index 00000000..a9a59172 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/matchers/ruff.json @@ -0,0 +1,14 @@ +{ + "problemMatcher": [ + { + "owner": "ruff", + "pattern": [ + { + "regexp": "^(Would reformat): (.+)$", + "message": 1, + "file": 2 + } + ] + } + ] +} diff --git a/{{cookiecutter.project_slug}}/Makefile b/{{cookiecutter.project_slug}}/Makefile index 59e5f8c6..15094219 100644 --- a/{{cookiecutter.project_slug}}/Makefile +++ b/{{cookiecutter.project_slug}}/Makefile @@ -36,16 +36,16 @@ nuke: ## Full wipe of the local environment, uncommitted files, and database. nuke: venv-check venv-wipe git-full-clean database-drop reset: ## Reset your local environment. Useful after switching branches, etc. -reset: venv-check venv-wipe install-local fab-get-backup django-migrate django-user-passwords django-dev-createsuperuser +reset: venv-check venv-wipe install-local fab-get-backup django-migrate django-user-passwords django-dev-createsuperuser django-configure-local-sites full-reset: ## Reset your local environment and download all media files. -full-reset: venv-check venv-wipe install-local fab-get-data django-migrate django-user-passwords django-dev-createsuperuser +full-reset: venv-check venv-wipe install-local fab-get-data django-migrate django-user-passwords django-dev-createsuperuser django-configure-local-sites clear: ## Like reset but without the wiping of the installs. -clear: fab-get-backup django-migrate django-user-passwords django-dev-createsuperuser +clear: fab-get-backup django-migrate django-user-passwords django-dev-createsuperuser django-configure-local-sites full-clear: ## Fresh download of remotely stored data including media files. -full-clear: fab-get-data django-migrate django-user-passwords django-dev-createsuperuser +full-clear: fab-get-data django-migrate django-user-passwords django-dev-createsuperuser django-configure-local-sites check: ## Check for any obvious errors in the project's setup. check: pipdeptree-check npm-check django-check @@ -108,13 +108,13 @@ pip-install-local: venv-check fab-get-data: fab-get-backup fab-get-media fab-get-backup: - fab get_backup + fab $${site:-live} get_backup fab-get-media: - fab get_media + fab $${site:-live} get_media fab-deploy: - fab deploy + fab $${site:-live} deploy # Coverage @@ -169,6 +169,9 @@ django-user-passwords: django-migrate: ./manage.py migrate +django-configure-local-sites: + ./manage.py configure_local_sites + # NPM npm-check: npm-install npm-run-production diff --git a/{{cookiecutter.project_slug}}/apps/accounts/migrations/0001_initial.py b/{{cookiecutter.project_slug}}/apps/accounts/migrations/0001_initial.py index 03f49dfd..2d02ced1 100644 --- a/{{cookiecutter.project_slug}}/apps/accounts/migrations/0001_initial.py +++ b/{{cookiecutter.project_slug}}/apps/accounts/migrations/0001_initial.py @@ -1,9 +1,9 @@ -# Generated by Django 3.2.18 on 2023-04-24 13:16 +# Generated by Django 5.2.3 on 2025-07-12 14:09 import django.contrib.auth.models import django.contrib.auth.validators -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): @@ -11,46 +11,118 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='Group', - fields=[ - ], + name="Group", + fields=[], options={ - 'proxy': True, - 'indexes': [], - 'constraints': [], + "proxy": True, + "indexes": [], + "constraints": [], }, - bases=('auth.group',), + bases=("auth.group",), managers=[ - ('objects', django.contrib.auth.models.GroupManager()), + ("objects", django.contrib.auth.models.GroupManager()), ], ), migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField(blank=True, null=True, verbose_name="last login"), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField(blank=True, max_length=150, verbose_name="first name"), + ), + ( + "last_name", + models.CharField(blank=True, max_length=150, verbose_name="last name"), + ), + ( + "email", + models.EmailField(blank=True, max_length=254, verbose_name="email address"), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), ], options={ - 'db_table': 'auth_user', + "db_table": "auth_user", }, managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ("objects", django.contrib.auth.models.UserManager()), ], ), ] diff --git a/{{cookiecutter.project_slug}}/apps/core/context_processors.py b/{{cookiecutter.project_slug}}/apps/core/context_processors.py index 4d246ca4..01839913 100644 --- a/{{cookiecutter.project_slug}}/apps/core/context_processors.py +++ b/{{cookiecutter.project_slug}}/apps/core/context_processors.py @@ -41,5 +41,23 @@ def browsersync(request): return {"BROWSERSYNC_URL": url} +def sentry_config(request): + """ + Add Sentry config to the global template context. + """ + + # Only for dynamic configuration - use sentry_config.js for anything static! + config = {} + if settings.SENTRY_ENVIRONMENT: + config["environment"] = settings.SENTRY_ENVIRONMENT + if settings.SENTRY_RELEASE: + config["release"] = settings.SENTRY_RELEASE + + return { + "SENTRY_JS_URL": settings.SENTRY_JS_URL, + "SENTRY_JS_CONFIG": config, + } + + def demo(request): return {"DEMO_SITE": settings.DEMO_SITE} diff --git a/{{cookiecutter.project_slug}}/apps/core/management/commands/configure_local_sites.py b/{{cookiecutter.project_slug}}/apps/core/management/commands/configure_local_sites.py new file mode 100644 index 00000000..58e73033 --- /dev/null +++ b/{{cookiecutter.project_slug}}/apps/core/management/commands/configure_local_sites.py @@ -0,0 +1,28 @@ +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = "Updates Django sites hostnames/ports for local development" + + def handle(self, *args, **options): + if settings.DEBUG is False: + raise CommandError( + "Command only to be used for local development (DEBUG must be True)" + ) + + self.stdout.write(self.style.NOTICE("Django sites for this project:\n")) + + port = 8000 + for site in Site.objects.order_by("id"): + site.domain = f"127.0.0.1:{port}" + site.save() + self.stdout.write(f" {site.id:2d} - {site.name}") + port += 1000 + + self.stdout.write( + self.style.NOTICE( + "\nTo run a specific site: run SITE_ID= ./manage.py runserver" + ) + ) diff --git a/{{cookiecutter.project_slug}}/fabfile.py b/{{cookiecutter.project_slug}}/fabfile.py index 83e7fab3..b2e37286 100644 --- a/{{cookiecutter.project_slug}}/fabfile.py +++ b/{{cookiecutter.project_slug}}/fabfile.py @@ -37,6 +37,12 @@ GIT_REMOTE = "git@github.com:developersociety/{env.repo}.git" +@task +def live(): + # Intentional no-op for make commands, this is the default + pass + + @task def demo(): env.roledefs["web"] = env.roledefs["demo"] @@ -184,7 +190,35 @@ def static(): run("npm run production --silent") # Collect static files - run("python manage.py collectstatic --verbosity=0 --noinput") + run("python manage.py collectstatic --verbosity=0 --noinput --clear") + + # Add Sentry debug IDs + run("sentry-cli sourcemaps inject htdocs/static/dist/js") + + +@task +@roles("web") +@runs_once +def static_upload(): + """Upload static file sourcemaps to Sentry.""" + with cd(env.home): + version = run("sentry-cli releases propose-version") + run( + "sentry-cli sourcemaps upload " + "--project {project} --release {version} " + "--quiet htdocs/static/dist/js".format(project=env.repo, version=version) + ) + + +@task +@roles("web") +@parallel +def static_clean(): + """Clean sourcemaps from static files.""" + with cd(env.home): + # Remove source maps + run("find htdocs/static/dist/js -name '*.map' -delete") + {%- if cookiecutter.multilingual == 'y' %} @@ -219,12 +253,26 @@ def reload_uwsgi(force_reload=None): @task @roles("web") @runs_once -def sentry_release(): +def sentry_start_release(): """Register new release with Sentry.""" with cd(env.home): version = run("sentry-cli releases propose-version") run( - "sentry-cli releases new --finalize --project {project} {version}".format( + "sentry-cli releases new --project {project} {version}".format( + project=env.repo, version=version + ) + ) + + +@task +@roles("web") +@runs_once +def sentry_finish_release(): + """Finalise new release with Sentry.""" + with cd(env.home): + version = run("sentry-cli releases propose-version") + run( + "sentry-cli releases finalize --project {project} {version}".format( project=env.repo, version=version ) ) @@ -248,14 +296,17 @@ def deploy(force_reload=None): fab deploy:force_reload=True """ execute(update) + execute(sentry_start_release) execute(migrate) execute(static) + execute(static_upload) + execute(static_clean) {%- if cookiecutter.multilingual == 'y' %} execute(translations) {%- endif %} execute(reload_uwsgi, force_reload=force_reload) execute(cron) - execute(sentry_release) + execute(sentry_finish_release) @task @@ -304,22 +355,20 @@ def get_backup(hostname=None, replace_hostname="127.0.0.1", replace_port=8000): @task -def get_media(directory="", filetype=None): +def get_media(directory=""): """ Download remote media files. It uses credentials from ~/.aws/config. fab get_media fab get_media:assets - fab get_media:filetype=jpg """ - params = '--exclude "*" --include "*.{ext}"'.format(ext=filetype) if filetype else "" # Sync files from our S3 bucket/directory local( "aws-vault exec devsoc-contentfiles-download -- " "aws s3 sync " "s3://{media_bucket}/{media}/{directory} " - "htdocs/media/{directory} {params}".format( - media_bucket=env.media_bucket, media=env.media, directory=directory, params=params + "htdocs/media/{directory}".format( + media_bucket=env.media_bucket, media=env.media, directory=directory ) ) diff --git a/{{cookiecutter.project_slug}}/project/settings/base.py b/{{cookiecutter.project_slug}}/project/settings/base.py index 3f6ec3de..fce2ae39 100644 --- a/{{cookiecutter.project_slug}}/project/settings/base.py +++ b/{{cookiecutter.project_slug}}/project/settings/base.py @@ -60,9 +60,15 @@ "django.contrib.gis.apps.GISConfig", {%- endif %} ] -THIRD_PARTY_APPS = ["axes", "crispy_forms", "maskpostgresdata"] +THIRD_PARTY_APPS = [ + "axes", + "maskpostgresdata", + "watchman", +] -PROJECT_APPS = ["accounts.apps.AccountsConfig"] +PROJECT_APPS = [ + "accounts.apps.AccountsConfig", +] INSTALLED_APPS = DEFAULT_APPS + THIRD_PARTY_APPS + PROJECT_APPS @@ -180,6 +186,7 @@ "django.template.context_processors.request", "django.contrib.messages.context_processors.messages", "core.context_processors.demo", + "core.context_processors.sentry_config", ] }, } @@ -258,4 +265,16 @@ DEMO_SITE = False +# Health checks +WATCHMAN_CHECKS = [ + "watchman.checks.caches", + "watchman.checks.databases", +] +WATCHMAN_CACHES = ["default"] # Avoid additional cache backend hits + +# Sentry frontend tracking +SENTRY_JS_URL = os.environ.get("SENTRY_JS_URL") +SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT") +SENTRY_RELEASE = None + AUTH_USER_MODEL = "accounts.User" diff --git a/{{cookiecutter.project_slug}}/project/settings/demo.py b/{{cookiecutter.project_slug}}/project/settings/demo.py index 7a4216b9..0589ebe6 100644 --- a/{{cookiecutter.project_slug}}/project/settings/demo.py +++ b/{{cookiecutter.project_slug}}/project/settings/demo.py @@ -1,5 +1,15 @@ -from .production import * # noqa:F403 +# ruff: noqa:F405 +import os -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +from .production import * # noqa:F403 DEMO_SITE = True + +# Intercept outgoing emails +INSTALLED_APPS = [*INSTALLED_APPS, "bandit"] +BANDIT_EMAIL = os.environ.get("BANDIT_EMAIL", "").split(" ") +EMAIL_SUBJECT_PREFIX = f"[{PROJECT_SLUG} demo] " +EMAIL_BACKEND = "bandit.backends.smtp.HijackSMTPBackend" +BANDIT_REGEX_WHITELIST = [ + r"(?i)^.+@dev\.ngo$", +] diff --git a/{{cookiecutter.project_slug}}/project/urls.py b/{{cookiecutter.project_slug}}/project/urls.py index 52c8985e..8fb76340 100644 --- a/{{cookiecutter.project_slug}}/project/urls.py +++ b/{{cookiecutter.project_slug}}/project/urls.py @@ -25,6 +25,8 @@ path( "robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain") ), + # Health checks + path("_health/", include("watchman.urls")), ] urlpatterns = i18n_patterns(path("admin/", admin.site.urls)) @@ -37,6 +39,8 @@ path( "robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain") ), + # Health checks + path("_health/", include("watchman.urls")), ] {%- endif %} diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index 494b7a58..39ab9d88 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -11,8 +11,6 @@ close_void_tags = true max_length = 120 # T002 - Double quotes should be used in tags ignore = "T002" -# Bootsrtap file from crispy forms - too many issues with it -extend_exclude="field.html" [tool.djlint.per-file-ignores] # Disable: diff --git a/{{cookiecutter.project_slug}}/requirements/base.txt b/{{cookiecutter.project_slug}}/requirements/base.txt index 9793f90f..d6a47d41 100644 --- a/{{cookiecutter.project_slug}}/requirements/base.txt +++ b/{{cookiecutter.project_slug}}/requirements/base.txt @@ -35,8 +35,11 @@ wrapt==1.17.2 # Axes django-axes==8.0.0 -# Form styling -django-crispy-forms==1.14.0 +# Email interception +django-email-bandit==2.0 + +# Watchman health checks +django-watchman==1.3.0 {%- if cookiecutter.multilingual == 'y' %} # Translations diff --git a/{{cookiecutter.project_slug}}/static/src/js/base.js b/{{cookiecutter.project_slug}}/static/src/js/base.js index 643923c7..5c992491 100644 --- a/{{cookiecutter.project_slug}}/static/src/js/base.js +++ b/{{cookiecutter.project_slug}}/static/src/js/base.js @@ -2,8 +2,19 @@ const main = document.querySelector('.main'); // Stretch main height to fill the screen if (main) { - const footer_height = document.querySelector('.footer').offsetHeight; - const header_height = document.querySelector('.header').offsetHeight; + let footer_height = ''; + let header_height = ''; + if (document.querySelector('.footer')) { + footer_height = document.querySelector('.footer').offsetHeight; + } else { + footer_height = '0'; + } + + if (document.querySelector('.header')) { + header_height = document.querySelector('.header').offsetHeight; + } else { + header_height = '0'; + } const total_height = header_height + footer_height; main.style.minHeight = `${window.innerHeight - total_height}px`; } diff --git a/{{cookiecutter.project_slug}}/static/src/js/sentry_config.js b/{{cookiecutter.project_slug}}/static/src/js/sentry_config.js new file mode 100644 index 00000000..8fd30f96 --- /dev/null +++ b/{{cookiecutter.project_slug}}/static/src/js/sentry_config.js @@ -0,0 +1,17 @@ +/* global Sentry */ + +window.sentryOnLoad = () => { + let sentryConfig = {}; + const sentryConfigElement = document.getElementById('sentry-config'); + if (sentryConfigElement) { + sentryConfig = JSON.parse(sentryConfigElement.textContent); + } + + Sentry.init({ + ...sentryConfig, + ignoreErrors: [ + // Excessive network issues + 'TypeError: Failed to fetch', + ], + }); +}; diff --git a/{{cookiecutter.project_slug}}/templates/base.html b/{{cookiecutter.project_slug}}/templates/base.html index 121c7f4a..cd2ac8ba 100644 --- a/{{cookiecutter.project_slug}}/templates/base.html +++ b/{{cookiecutter.project_slug}}/templates/base.html @@ -15,6 +15,13 @@ {% if BROWSERSYNC_URL %} {% endif %} + {% if SENTRY_JS_URL %} + {% if SENTRY_JS_CONFIG %} + {{ SENTRY_JS_CONFIG|json_script:"sentry-config" }} + {% endif %} + + + {% endif %} {% if DEMO_SITE %} diff --git a/{{cookiecutter.project_slug}}/templates/bootstrap/field.html b/{{cookiecutter.project_slug}}/templates/bootstrap/field.html deleted file mode 100644 index a92f3cf1..00000000 --- a/{{cookiecutter.project_slug}}/templates/bootstrap/field.html +++ /dev/null @@ -1,30 +0,0 @@ -{% load crispy_forms_field %} - -{% if field.is_hidden %} - {{ field }} -{% else %} - <{% if tag %}{{ tag }}{% else %}div{% endif %} id="field_{{ field.auto_id }}" class="field{% if wrapper_class %} {{ wrapper_class }}{% endif %}{% if form_show_errors %}{% if field.errors %} field--error{% endif %}{% endif %}{% if field.css_classes %} {{ field.css_classes }}{% endif %}{% if field|is_checkbox %} field--checkbox{% endif %}{% if field|is_radioselect %} field--radio{% endif %}"> - - {% if field.label and not field|is_checkbox and form_show_labels %} - - {% endif %} - {% if field|is_checkboxselectmultiple %} - {% include 'bootstrap/layout/checkboxselectmultiple.html' %} - {% elif field|is_radioselect %} - {% include 'bootstrap/layout/radioselect.html' %} - {% elif field|is_checkbox %} -
- {% crispy_field field %} - -
- {% include 'bootstrap/layout/help_text_and_errors.html' %} - {% else %} - {% crispy_field field %} - {% include 'bootstrap/layout/help_text_and_errors.html' %} - {% endif %} - -{% endif %} diff --git a/{{cookiecutter.project_slug}}/templates/bootstrap/layout/checkboxselectmultiple.html b/{{cookiecutter.project_slug}}/templates/bootstrap/layout/checkboxselectmultiple.html deleted file mode 100644 index 224aed3d..00000000 --- a/{{cookiecutter.project_slug}}/templates/bootstrap/layout/checkboxselectmultiple.html +++ /dev/null @@ -1,24 +0,0 @@ -{% load crispy_forms_filters %} -{% load l10n %} - -{% include 'bootstrap/layout/field_errors_block.html' %} -
- {% for choice in field.field.choices %} -
- {# djlint:off #} - - {# djlint:on #} - -
- {% endfor %} -
-{% include 'bootstrap/layout/help_text_and_errors.html' %} diff --git a/{{cookiecutter.project_slug}}/templates/bootstrap/layout/radioselect.html b/{{cookiecutter.project_slug}}/templates/bootstrap/layout/radioselect.html deleted file mode 100644 index 27ad9945..00000000 --- a/{{cookiecutter.project_slug}}/templates/bootstrap/layout/radioselect.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load crispy_forms_filters %} -{% load l10n %} - -
- {% for choice in field.field.choices %} -
- {# djlint:off #} - - {# djlint:on #} - -
- {% endfor %} -
diff --git a/{{cookiecutter.project_slug}}/webpack.config.js b/{{cookiecutter.project_slug}}/webpack.config.js index 0a95c737..d551d97a 100644 --- a/{{cookiecutter.project_slug}}/webpack.config.js +++ b/{{cookiecutter.project_slug}}/webpack.config.js @@ -19,6 +19,7 @@ const config = { entry: { base: ['./static/src/js/base.js'], app: ['./static/src/js/app.js'], + sentry_config: ['./static/src/js/sentry_config.js'], styles: ['./static/src/scss/styles.scss'], }, output: { @@ -245,5 +246,8 @@ module.exports = [ }, ], }, + + // Create Sourcemaps for the files + devtool: 'source-map', }, ];