diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..a92b8eb --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Switch Python files to use double quotes +53f877d192a29572a4e187c07106efcf84dc7d18 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06d65f9..865b837 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,24 +31,34 @@ jobs: ports: - 9000:9000 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + with: + # Tags are needed to compute the current version number + fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install Python packages run: | pip install --upgrade pip - pip install cookiecutter tox - - name: Create from cookiecutter + pip install build cookiecutter tox + - name: Build django-resonant-settings + run: | + python -m build --sdist + working-directory: django-resonant-settings + - name: Eject from cookiecutter run: | - cookiecutter --no-input . ${{ matrix.cookiecutter-variables }} + cookiecutter --no-input . project_name=Resonant ${{ matrix.cookiecutter-variables }} - name: Run tox tests from new project run: | tox env: DJANGO_DATABASE_URL: postgres://postgres:postgres@localhost:5432/django + DJANGO_CELERY_BROKER_URL: amqp://localhost:5672/ DJANGO_MINIO_STORAGE_ENDPOINT: localhost:9000 DJANGO_MINIO_STORAGE_ACCESS_KEY: minioAccessKey DJANGO_MINIO_STORAGE_SECRET_KEY: minioSecretKey - working-directory: my-new-project + DJANGO_STORAGE_BUCKET_NAME: django-storage-testing + PIP_FIND_LINKS: file://${{ github.workspace }}/django-resonant-settings/dist + working-directory: resonant diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4c40b1d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: release +on: + release: + types: [published] +jobs: + publish: + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + with: + # Tags are needed to compute the current version number + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install Python build + run: | + pip install --upgrade pip + pip install build + - name: Build the Python distribution + run: | + python -m build + working-directory: django-resonant-settings + - name: Publish the Python distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: django-resonant-settings/dist diff --git a/django-resonant-settings/.editorconfig b/django-resonant-settings/.editorconfig new file mode 100644 index 0000000..eee34a9 --- /dev/null +++ b/django-resonant-settings/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.toml] +indent_size = 2 + +[*.ini] +indent_size = 4 + +[*.py] +indent_size = 4 +max_line_length = 100 + +[{*.yml,*.yaml}] +indent_size = 2 diff --git a/django-resonant-settings/.gitignore b/django-resonant-settings/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/django-resonant-settings/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/django-resonant-settings/LICENSE b/django-resonant-settings/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/django-resonant-settings/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/django-resonant-settings/README.md b/django-resonant-settings/README.md new file mode 100644 index 0000000..baf251b --- /dev/null +++ b/django-resonant-settings/README.md @@ -0,0 +1,9 @@ +# django-resonant-settings +[![PyPI](https://img.shields.io/pypi/v/django-resonant-settings)](https://pypi.org/project/django-resonant-settings/) + +Shared Django settings for Resonant applications. + +# Installation and Usage +This package is tightly coupled to +[`cookiecutter-resonant`](https://github.com/kitware-resonant/cookiecutter-resonant) and should +be used within a cookiecutter-derived Resonant application. diff --git a/django-resonant-settings/pyproject.toml b/django-resonant-settings/pyproject.toml new file mode 100644 index 0000000..20ce73e --- /dev/null +++ b/django-resonant-settings/pyproject.toml @@ -0,0 +1,79 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "django-resonant-settings" +description = "Shared Django settings for Resonant applications." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "Apache 2.0" } +maintainers = [{ name = "Kitware, Inc.", email = "kitware@kitware.com" }] +keywords = [ + "django", + "resonant", + "setting", + "settings", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "Framework :: Django :: 5", + "Framework :: Django :: 5.1", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python", +] +dependencies = [ + "django-environ", +] +dynamic = ["version"] + +[project.optional-dependencies] +allauth = [ + "django-allauth", +] + +[project.urls] +Repository = "https://github.com/kitware-resonant/cookiecutter-resonant" +"Bug Reports" = "https://github.com/kitware-resonant/cookiecutter-resonant/issues" + +[tool.hatch.build] +packages = [ + "resonant_settings", +] + +[tool.hatch.version] +source = "vcs" +raw-options = { root = ".." } + +[tool.black] +line-length = 100 +target-version = ["py310"] + +[tool.isort] +profile = "black" +line_length = 100 +# Sort by name, don't cluster "from" vs "import" +force_sort_within_sections = true +# Combines "as" imports on the same line +combine_as_imports = true + +[tool.mypy] +files = [ + "resonant_settings", +] +show_error_codes = true + +[[tool.mypy.overrides]] +module = [ + "allauth.*", + "environ.*", +] +ignore_missing_imports = true diff --git a/django-resonant-settings/resonant_settings/__init__.py b/django-resonant-settings/resonant_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-resonant-settings/resonant_settings/_env.py b/django-resonant-settings/resonant_settings/_env.py new file mode 100644 index 0000000..463cfb8 --- /dev/null +++ b/django-resonant-settings/resonant_settings/_env.py @@ -0,0 +1,3 @@ +import environ + +env = environ.Env() diff --git a/django-resonant-settings/resonant_settings/allauth.py b/django-resonant-settings/resonant_settings/allauth.py new file mode 100644 index 0000000..0172aa0 --- /dev/null +++ b/django-resonant-settings/resonant_settings/allauth.py @@ -0,0 +1,48 @@ +""" +Configure django-allauth with the following features: +* Disable usernames for end users, using exclusively email addresses for login +* Require email verification +* Quality of life improvements for users + +This requires the `django-allauth` package to be installed and requires +`resonant_settings.allauth_support` to be added to INSTALLED_APPS. +""" + +# The sites framework requires this to be set. +# In the unlikely case where a database's pk sequence for the django_site table is not reset, +# the default site object could have a different pk. Then this will need to be overridden +# downstream. +SITE_ID = 1 + +AUTHENTICATION_BACKENDS = [ + # Django's built-in ModelBackend is not necessary, since all users will be + # authenticated by their email address + "allauth.account.auth_backends.AuthenticationBackend", +] + +# see configuration documentation at +# https://django-allauth.readthedocs.io/en/latest/configuration.html + +# Require email verification, but this can be overridden +ACCOUNT_EMAIL_VERIFICATION = "mandatory" + +# Use email as the identifier for login +ACCOUNT_AUTHENTICATION_METHOD = "email" +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_USERNAME_REQUIRED = False + +# Set the username as the email +ACCOUNT_ADAPTER = "resonant_settings.allauth_support.adapter.EmailAsUsernameAccountAdapter" +ACCOUNT_USER_MODEL_USERNAME_FIELD = None + +# Quality of life improvements, but may not work if the browser is closed +ACCOUNT_SESSION_REMEMBER = True +ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True +ACCOUNT_LOGIN_ON_PASSWORD_RESET = True + +# These will permit GET requests to mutate the user state, but significantly improve usability +ACCOUNT_LOGOUT_ON_GET = True +ACCOUNT_CONFIRM_EMAIL_ON_GET = True + +# This will likely become the default in the future, but enable it now +ACCOUNT_PRESERVE_USERNAME_CASING = False diff --git a/django-resonant-settings/resonant_settings/allauth_support/__init__.py b/django-resonant-settings/resonant_settings/allauth_support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-resonant-settings/resonant_settings/allauth_support/adapter.py b/django-resonant-settings/resonant_settings/allauth_support/adapter.py new file mode 100644 index 0000000..38304da --- /dev/null +++ b/django-resonant-settings/resonant_settings/allauth_support/adapter.py @@ -0,0 +1,10 @@ +from allauth.account.adapter import DefaultAccountAdapter +from django.contrib.auth.models import AbstractUser +from django.http import HttpRequest + + +class EmailAsUsernameAccountAdapter(DefaultAccountAdapter): + """Automatically populate the username as the email address.""" + + def populate_username(self, request: HttpRequest, user: AbstractUser) -> None: + user.username = user.email diff --git a/django-resonant-settings/resonant_settings/allauth_support/apps.py b/django-resonant-settings/resonant_settings/allauth_support/apps.py new file mode 100644 index 0000000..e765f2b --- /dev/null +++ b/django-resonant-settings/resonant_settings/allauth_support/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AllauthSupportConfig(AppConfig): + name = "resonant_settings.allauth_support" + verbose_name = "Resonant settings django-allauth support" diff --git a/django-resonant-settings/resonant_settings/allauth_support/createsuperuser.py b/django-resonant-settings/resonant_settings/allauth_support/createsuperuser.py new file mode 100644 index 0000000..52de19f --- /dev/null +++ b/django-resonant-settings/resonant_settings/allauth_support/createsuperuser.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import ClassVar + +from django.contrib.auth.management.commands import createsuperuser +from django.contrib.auth.models import User, UserManager + +from resonant_settings.allauth_support.utils import temporarily_change_attributes + + +class Command(createsuperuser.Command): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.UserModel = EmailAsUsernameProxyUser + self.username_field = self.UserModel._meta.get_field(self.UserModel.USERNAME_FIELD) + + def _validate_username(self, username, verbose_field_name, database): + # Since "username" is actually unique, "email" (i.e. "self.username_field") is logically + # unique too. Explicitly setting the "_unique" attribute ensures that app-level duplicate + # checking is done by "_validate_username", which produces better, earlier error messages. + # Out of an abundance of caution, set the "_unique" attribute back to its original value + # when this is done. + with temporarily_change_attributes(self.username_field, _unique=True): + # Normalize (as it would be done before saving) for better duplicate detection + username = self.UserModel.normalize_username(username) + return super()._validate_username(username, verbose_field_name, database) + + +class EmailAsUsernameProxyUserManager(UserManager): + # This version of "create_superuser" makes the "username" argument optional + def create_superuser( + self, + username: str | None = None, + email: str | None = None, + password: str | None = None, + **extra_fields, + ) -> EmailAsUsernameProxyUser: + # Practically, email will always be provided + assert email + user = super().create_superuser( + username=email, email=email, password=password, **extra_fields + ) + return user + + +class EmailAsUsernameProxyUser(User): + # https://github.com/typeddjango/django-stubs/issues/2112 + class Meta(User.Meta): # type: ignore[name-defined] + proxy = True + + objects = EmailAsUsernameProxyUserManager() + + # "createsuperuser.Command" automatically includes the referent of "USERNAME_FIELD", and we want + # to apply username labeling, help text, and validation rules from the actual "email" field + USERNAME_FIELD = "email" + + # Don't include "email" in "REQUIRED_FIELDS", to prevent adding that field twice to the + # "createsuperuser.Command" argument parser + REQUIRED_FIELDS: ClassVar[list[str]] = [] + + @classmethod + def normalize_username(cls, username: str) -> str: + # This method is called from "UserManager._create_user" with the actual "username" field. + # To ensure that the saved value of the "username" field exactly matches the "email" field, + # apply the same normalization process. + return EmailAsUsernameProxyUserManager.normalize_email(username) diff --git a/django-resonant-settings/resonant_settings/allauth_support/management/commands/createsuperuser.py b/django-resonant-settings/resonant_settings/allauth_support/management/commands/createsuperuser.py new file mode 100644 index 0000000..11e9aa9 --- /dev/null +++ b/django-resonant-settings/resonant_settings/allauth_support/management/commands/createsuperuser.py @@ -0,0 +1,30 @@ +from typing import cast + +from allauth.account import app_settings as allauth_settings +from django.contrib.auth import get_user_model +from django.contrib.auth.management.commands import createsuperuser as django_createsuperuser +from django.contrib.auth.models import AbstractUser +from django.core.management import BaseCommand +from django.db.models.signals import post_save + +from resonant_settings.allauth_support import createsuperuser as allauth_support_createsuperuser +from resonant_settings.allauth_support.receiver import verify_email_address_on_user_post_save + +""" +When Allauth is configured to use a User's `email` as the `username`, override the `createsuperuser` +management command to only prompt for an email address. +""" + +# If using email as username +if not allauth_settings.USERNAME_REQUIRED: + # Expose the modified command + Command: type[BaseCommand] = allauth_support_createsuperuser.Command + user_model: type[AbstractUser] = allauth_support_createsuperuser.EmailAsUsernameProxyUser + +else: + # Expose the pristine upstream version of the command + Command = django_createsuperuser.Command + user_model = cast(type[AbstractUser], get_user_model()) + +# Always automatically verify email addresses of newly created superusers +post_save.connect(verify_email_address_on_user_post_save, sender=user_model) diff --git a/django-resonant-settings/resonant_settings/allauth_support/receiver.py b/django-resonant-settings/resonant_settings/allauth_support/receiver.py new file mode 100644 index 0000000..9b739b2 --- /dev/null +++ b/django-resonant-settings/resonant_settings/allauth_support/receiver.py @@ -0,0 +1,37 @@ +import logging + +from allauth.account.models import EmailAddress +from django.contrib.auth.models import AbstractUser + +logger = logging.getLogger(__file__) + + +def verify_email_address_on_user_post_save( + sender: type[AbstractUser], + instance: AbstractUser, + created: bool, + **kwargs, +): + """Automatically verify email addresses of newly created superusers.""" + # These should always be true, but it's a final sanity check + if created and instance.is_superuser: + # Django is less strict than Allauth about duplicate email addresses (Django lowercases + # the domain portion or an address, Allauth lowercases the whole address). It's possible + # an acceptable email address via createsuperuser would be refused as a duplicate via + # Allauth. + # It's also possible that in an mature database where users have multiple non-primary + # emails, the new user's email matches an existing user's non-primary email, violating + # uniqueness. + # So, make a conservative effort at setting the email address as verified, since failure is + # not critical and can be resolved by the user. + email_address, created = EmailAddress.objects.get_or_create( + email__iexact=instance.email, + defaults={ + "user": instance, + "email": instance.email, + "verified": True, + "primary": True, + }, + ) + if not created: + logger.warning(f'Could not automatically verify email address "{instance.email}".') diff --git a/django-resonant-settings/resonant_settings/allauth_support/utils.py b/django-resonant-settings/resonant_settings/allauth_support/utils.py new file mode 100644 index 0000000..e3a1ae3 --- /dev/null +++ b/django-resonant-settings/resonant_settings/allauth_support/utils.py @@ -0,0 +1,14 @@ +from contextlib import contextmanager + + +# From https://stackoverflow.com/a/38532086 +@contextmanager +def temporarily_change_attributes(something, **kwargs): + previous_values = {k: getattr(something, k) for k in kwargs} + for k, v in kwargs.items(): + setattr(something, k, v) + try: + yield + finally: + for k, v in previous_values.items(): + setattr(something, k, v) diff --git a/django-resonant-settings/resonant_settings/celery.py b/django-resonant-settings/resonant_settings/celery.py new file mode 100644 index 0000000..75a0475 --- /dev/null +++ b/django-resonant-settings/resonant_settings/celery.py @@ -0,0 +1,64 @@ +""" +Configure Celery with the following features: +* Disable the results backend +* Ensure that tasks will never be lost, but tasks themselves must be idempotent +* Optimize the network connection to CloudAMQP + +This requires the `celery` package to be installed. +""" + +from resonant_settings._env import env + +# Assume AMQP. +CELERY_BROKER_URL: str = env.str("DJANGO_CELERY_BROKER_URL") + +# Disable results backend, as this feature has too many weaknesses. +# The database should be used to communicate results of completed tasks. +CELERY_RESULT_BACKEND = None + +# Only acknowledge a task being done after the function finishes. +# This provides safety against worker crashes, but adds the requirement +# that tasks must be idempotent (which is a best practice anyway). +# See: https://docs.celeryproject.org/en/stable/faq.html#should-i-use-retry-or-acks-late +CELERY_TASK_ACKS_LATE = True + +# When a worker subprocess abruptly exists, assume it was is killed by the operating system for +# a cause which is intrinsic (e.g. a segfault or OOM) to the task it was running, so do not +# requeue. It's expected that the task wouldn't succeed if run again. +# This should not impact cases where the task fails due to extrinsic causes (e.g. the process +# supervisor sends a SIGKILL or the machine loses power), as we assume that the parent worker +# process will immediately die too (and not have a chance to requeue the task). +# See: https://docs.celeryproject.org/en/stable/userguide/tasks.html#tasks for more explanation +# of these tradeoffs. +# None of this affects warm shutdowns from a SIGTERM (which the process supervisor ought to +# send), as this just allows Celery to complete running tasks; see: +# https://docs.celeryproject.org/en/stable/userguide/workers.html#process-signals for reference. +# This is Celery's default. +CELERY_TASK_REJECT_ON_WORKER_LOST = False + +# When a task fails due to an internally-raised exception or due to a timeout, do not requeue. +# It's expected that the task wouldn't succeed if run again. +# This is Celery's default. +CELERY_TASK_ACKS_ON_FAILURE_OR_TIMEOUT = True + +# This is sensible behavior with TASKS_ACKS_LATE, this must be enabled to prevent warnings, +# and this will be Celery's default in 6.0. +CELERY_WORKER_CANCEL_LONG_RUNNING_TASKS_ON_CONNECTION_LOSS = True + +# CloudAMQP-suggested settings +# https://www.cloudamqp.com/docs/celery.html +CELERY_BROKER_POOL_LIMIT = 1 +CELERY_BROKER_HEARTBEAT = None +CELERY_BROKER_CONNECTION_TIMEOUT = 30 +CELERY_EVENT_QUEUE_EXPIRES = 60 + +# Note, CELERY_WORKER settings could be different on each running worker. + +# Do not prefetch, as the speed benefit for fast-running tasks may not be +# worth a potentially unfair allocation with slow-running tasks and +# multiple workers. +CELERY_WORKER_PREFETCH_MULTIPLIER = 1 + +# Accept the default of the number of CPU cores. +# Workers running memory-intensive tasks may need to decrease this. +CELERY_WORKER_CONCURRENCY: int | None = None diff --git a/django-resonant-settings/resonant_settings/debug_toolbar.py b/django-resonant-settings/resonant_settings/debug_toolbar.py new file mode 100644 index 0000000..261bac9 --- /dev/null +++ b/django-resonant-settings/resonant_settings/debug_toolbar.py @@ -0,0 +1,13 @@ +""" +Configure Django Debug Toolbar with the following features: +* Improve performance with large queries + +This requires the `django-debug-toolbar` package to be installed. +""" + +DEBUG_TOOLBAR_CONFIG = { + # The default size often is too small, causing an inability to view queries + "RESULTS_CACHE_SIZE": 250, + # If this setting is True, large sql queries can cause the page to render slowly + "PRETTIFY_SQL": False, +} diff --git a/django-resonant-settings/resonant_settings/development/__init__.py b/django-resonant-settings/resonant_settings/development/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-resonant-settings/resonant_settings/development/celery.py b/django-resonant-settings/resonant_settings/development/celery.py new file mode 100644 index 0000000..ce3cc08 --- /dev/null +++ b/django-resonant-settings/resonant_settings/development/celery.py @@ -0,0 +1,7 @@ +# Acknowledge early in development, which will help prevent failing or +# long-running tasks from being started automatically every time the worker +# process restarts; this more aggressively flushes the task queue. +CELERY_TASK_ACKS_LATE = False + +# In development, run without concurrency. +CELERY_WORKER_CONCURRENCY: int | None = 1 diff --git a/django-resonant-settings/resonant_settings/development/extensions.py b/django-resonant-settings/resonant_settings/development/extensions.py new file mode 100644 index 0000000..401ce78 --- /dev/null +++ b/django-resonant-settings/resonant_settings/development/extensions.py @@ -0,0 +1,9 @@ +""" +Configure Django Extensions. + +This requires the `django-extensions` package to be installed. +""" + +SHELL_PLUS_PRINT_SQL = True +SHELL_PLUS_PRINT_SQL_TRUNCATE = None +RUNSERVER_PLUS_PRINT_SQL_TRUNCATE = None diff --git a/django-resonant-settings/resonant_settings/django.py b/django-resonant-settings/resonant_settings/django.py new file mode 100644 index 0000000..3c13589 --- /dev/null +++ b/django-resonant-settings/resonant_settings/django.py @@ -0,0 +1,37 @@ +""" +Configure a basic Django project. +""" + +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", + ], + }, + }, +] + +PASSWORD_HASHERS = [ + # Argon2 is recommended by OWASP, so make it the default for new passwords + # https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + "django.contrib.auth.hashers.Argon2PasswordHasher", + # scrypt was the default hasher in older versions of Resonant, + # so it must be enabled to read old passwords + "django.contrib.auth.hashers.ScryptPasswordHasher", + # Support for other hashers isn't needed, + # since databases shouldn't have entries with other algorithms +] +# https://docs.djangoproject.com/en/3.0/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"}, +] diff --git a/django-resonant-settings/resonant_settings/logging.py b/django-resonant-settings/resonant_settings/logging.py new file mode 100644 index 0000000..6becf78 --- /dev/null +++ b/django-resonant-settings/resonant_settings/logging.py @@ -0,0 +1,89 @@ +""" +Configure Django logging with the following features: +* Emit all logs to stdout +* Exclude favicons and static files from request and server logs +* Improve log formatting and add colorization + +This requires the `rich` package to be installed. +""" + +import logging + +from django.http import HttpRequest + + +def _filter_favicon_requests(record: logging.LogRecord) -> bool: + if record.name == "django.request": + request: HttpRequest | None = getattr(record, "request", None) + if request and request.path == "/favicon.ico": + return False + + if ( + record.name == "django.server" + and isinstance(record.args, tuple) + and len(record.args) >= 1 + and str(record.args[0]).startswith("GET /favicon.ico ") + ): + return False + + return True + + +def _filter_static_requests(record: logging.LogRecord) -> bool: + if ( + record.name == "django.server" + and isinstance(record.args, tuple) + and len(record.args) >= 1 + and str(record.args[0]).startswith("GET /static/") + ): + return False + + return True + + +LOGGING = { + "version": 1, + # Replace existing logging configuration + "incremental": False, + # This redefines all of Django's declared loggers, but most loggers are implicitly + # declared on usage, and should not be disabled. They often propagate their output + # to the root anyway. + "disable_existing_loggers": False, + "formatters": {"rich": {"datefmt": "[%X]"}}, + "filters": { + "filter_favicon_requests": { + "()": "django.utils.log.CallbackFilter", + "callback": _filter_favicon_requests, + }, + "filter_static_requests": { + "()": "django.utils.log.CallbackFilter", + "callback": _filter_static_requests, + }, + }, + "handlers": { + "console": { + "class": "rich.logging.RichHandler", + "formatter": "rich", + "filters": ["filter_favicon_requests", "filter_static_requests"], + }, + }, + # Existing loggers actually contain direct (non-string) references to existing handlers, + # so after redefining handlers, all existing loggers must be redefined too + "loggers": { + # Configure the root logger to output to the console + "": {"level": "INFO", "handlers": ["console"], "propagate": False}, + # Django defines special configurations for the "django" and "django.server" loggers, + # but we will manage all content at the root logger instead, so reset those + # configurations. + "django": { + "handlers": [], + "level": "NOTSET", + "propagate": True, + }, + "django.server": { + "handlers": [], + "level": "NOTSET", + "propagate": True, + }, + }, +} diff --git a/django-resonant-settings/resonant_settings/oauth_toolkit.py b/django-resonant-settings/resonant_settings/oauth_toolkit.py new file mode 100644 index 0000000..1418b43 --- /dev/null +++ b/django-resonant-settings/resonant_settings/oauth_toolkit.py @@ -0,0 +1,33 @@ +""" +Configure Django OAuth Toolkit with the following features: +* Harden security +* Improve usability of token scopes +* Improve quality of live for out of band flows and non-refreshing clients + +This requires the `django-oauth-toolkit` package to be installed. +""" + +from datetime import timedelta + +OAUTH2_PROVIDER = { + "PKCE_REQUIRED": True, + "ALLOWED_REDIRECT_URI_SCHEMES": ["https"], + # Don't require users to re-approve scopes each time + "REQUEST_APPROVAL_PROMPT": "auto", + # ERROR_RESPONSE_WITH_SCOPES is only used with the "permission_classes" helpers for scopes. + # If the scope itself is confidential, this could leak information. but the usability + # benefit is probably worth it. + "ERROR_RESPONSE_WITH_SCOPES": True, + # Allow 5 minutes for a flow to exchange an auth code for a token. This is typically + # 60 seconds but out-of-band flows may take a bit longer. A maximum of 10 minutes is + # recommended: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2. + "AUTHORIZATION_CODE_EXPIRE_SECONDS": timedelta(minutes=5).total_seconds(), + # Django can persist logins for longer than this via cookies, + # but non-refreshing clients will need to redirect to Django's auth every 24 hours. + "ACCESS_TOKEN_EXPIRE_SECONDS": timedelta(days=1).total_seconds(), + # This allows refresh tokens to eventually be removed from the database by + # "manage.py cleartokens". This value is not actually enforced when refresh tokens are + # checked, but it can be assumed that all clients will need to redirect to Django's auth + # every 30 days. + "REFRESH_TOKEN_EXPIRE_SECONDS": timedelta(days=30).total_seconds(), +} diff --git a/django-resonant-settings/resonant_settings/production/__init__.py b/django-resonant-settings/resonant_settings/production/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-resonant-settings/resonant_settings/production/email.py b/django-resonant-settings/resonant_settings/production/email.py new file mode 100644 index 0000000..86779a6 --- /dev/null +++ b/django-resonant-settings/resonant_settings/production/email.py @@ -0,0 +1,16 @@ +""" +Configure Django's email sending. + +The following environment variables must be externally set: +* `DJANGO_EMAIL_URL`, as a URL for login to an STMP server, as parsed by `dj-email-url`. This + typically will start with `submission:`. Special characters in passwords must be URL-encoded. + See https://pypi.org/project/dj-email-url/ for full details. +* `DJANGO_DEFAULT_FROM_EMAIL`, as the default From address for outgoing email. +""" + +from resonant_settings._env import env + +vars().update(env.email_url("DJANGO_EMAIL_URL")) + +DEFAULT_FROM_EMAIL: str = env.str("DJANGO_DEFAULT_FROM_EMAIL") +SERVER_EMAIL = DEFAULT_FROM_EMAIL diff --git a/django-resonant-settings/resonant_settings/production/https.py b/django-resonant-settings/resonant_settings/production/https.py new file mode 100644 index 0000000..40b8969 --- /dev/null +++ b/django-resonant-settings/resonant_settings/production/https.py @@ -0,0 +1,22 @@ +"""Configure Django's security middleware to use and require HTTPS.""" + +from datetime import timedelta + +SECURE_SSL_REDIRECT = True + +# This needs to be set by the HTTPS terminating reverse proxy. +# Heroku and Render automatically set this. +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# Enable HSTS +SECURE_HSTS_SECONDS = timedelta(days=365).total_seconds() +# This is already False by default, but it's important to ensure HSTS is not forced on other +# subdomains which may have different HTTPS practices. +SECURE_HSTS_INCLUDE_SUBDOMAINS = False +# This is already False by default, but per https://hstspreload.org/#opt-in, projects should +# opt-in to preload by overriding this setting. Additionally, all subdomains must have HSTS to +# register for preloading. +SECURE_HSTS_PRELOAD = False diff --git a/django-resonant-settings/resonant_settings/production/s3_storage.py b/django-resonant-settings/resonant_settings/production/s3_storage.py new file mode 100644 index 0000000..26814cf --- /dev/null +++ b/django-resonant-settings/resonant_settings/production/s3_storage.py @@ -0,0 +1,31 @@ +""" +Configure S3Storage. + +The following environment variables must be externally set: +* AWS_DEFAULT_REGION +* AWS_ACCESS_KEY_ID +* AWS_SECRET_ACCESS_KEY +* DJANGO_STORAGE_BUCKET_NAME + +This requires the `django-storages[s3]` package to be installed. +""" + +from datetime import timedelta + +from resonant_settings._env import env + +# These exact environment variable names are important, +# as direct instantiations of Boto will also respect them. +AWS_S3_REGION_NAME: str = env.str("AWS_DEFAULT_REGION") +AWS_S3_ACCESS_KEY_ID: str = env.str("AWS_ACCESS_KEY_ID") +AWS_S3_SECRET_ACCESS_KEY: str = env.str("AWS_SECRET_ACCESS_KEY") + +AWS_STORAGE_BUCKET_NAME: str = env.str("DJANGO_STORAGE_BUCKET_NAME") + +# It's critical to use the v4 signature; +# it isn't the upstream default only for backwards compatability reasons. +AWS_S3_SIGNATURE_VERSION = "s3v4" + +AWS_S3_MAX_MEMORY_SIZE = 5 * 1024 * 1024 +AWS_S3_FILE_OVERWRITE = False +AWS_QUERYSTRING_EXPIRE = timedelta(hours=6).total_seconds() diff --git a/django-resonant-settings/resonant_settings/py.typed b/django-resonant-settings/resonant_settings/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/django-resonant-settings/resonant_settings/rest_framework.py b/django-resonant-settings/resonant_settings/rest_framework.py new file mode 100644 index 0000000..e7aacf1 --- /dev/null +++ b/django-resonant-settings/resonant_settings/rest_framework.py @@ -0,0 +1,75 @@ +""" +Configure Django REST framework and drf-yasg. + +This requires the `django-oauth-toolkit` and `drf-yasg` packages to be installed. +""" + +from typing import Any + +# When SessionAuthentication is allowed, it's critical that the following settings +# (respectively part of Django and django-cors-headers) are set to these values (although those +# are the also the default values). +SESSION_COOKIE_SAMESITE = "Lax" +CORS_ALLOW_CREDENTIALS = False + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "oauth2_provider.contrib.rest_framework.OAuth2Authentication", + # Allow SessionAuthentication, as this is much more convenient for Ajax requests + # from server-rendered pages, including: + # * YASG (Swagger / ReDoc) + # * The Admin interface, when using interactive fields like S3-file-field + # * Augmentation of server-rendered views with background Javascript + # (see https://docs.djangoproject.com/en/3.1/ref/csrf/#ajax ) + # It's important that true SPAs and other clients be forced to go though + # OAuth2Authentication instead, as this is the only supported auth mechanism which + # robustly works across origins; however, it turns out that this can only be enforced + # partially. + # To understand why, first read https://web.dev/same-site-same-origin/ to understand + # that even with "SameSite=Lax" (or "SameSite=Strict"), cookies are only technically + # limited to same-site requests, and do not have the stronger same-origin limitation. + # If a naive SPA developer configures their client to include credentials + # ("{withCredentials: true}" in XHR, jQuery, and Axios, or "{credentials: 'include'}" + # in Fetch; this configuration is often suggested as an "easy" fix for authentication + # problems by StackOverflow), then the session cookie will be sent with any cross-site + # requests where the user has logged into the Django server. Note, cross-site requests + # include origins with a different port (typical in local development) and origins with + # a different subdomain (common in many deployments). From DRF's perspective, as long + # as the request uses a safe verb (more on this below), the request will be + # authenticated transparently. However, since Django is configured to not set + # "Access-Control-Allow-Credentials", the SPA client will not be able to read the + # response and get a CORS error; this is the right outcome (client cannot effectively + # make the request), but with a confusing and hard-to-debug reason (CORS error, + # instead of a 401/403) and developers may be tempted to "fix" it by enabling + # "Access-Control-Allow-Credentials" instead of fixing their client to use OAuth + # correctly, which will likely lead to further bugs when the SPA is deployed to a + # non-same-site environment. Alternatively, if the request does not use a safe verb + # (and the request is not preflighted by the browser, which is permitted in some + # cases), DRF will enforce CSRF protection and the request will 403 fail with a + # "CSRF Failed: CSRF token missing or incorrect" message, which is also confusing and + # may lead developers to incorrect fixes. + # TL;DR: Developers of SPAs may encounter misleading error messages when making Ajax + # requests "withCredentials", but security is still maintained. + "rest_framework.authentication.SessionAuthentication", + ], + # This is a much more sensible degree of basic security + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticatedOrReadOnly"], + # BoundedLimitOffsetPagination provides LimitOffsetPagination with a maximum page size + "DEFAULT_PAGINATION_CLASS": "resonant_utils.rest_framework.BoundedLimitOffsetPagination", + # This provides a sane default for requests that do not specify a page size. + # This also ensures that endpoints with pagination will always return a + # pagination-structured response. + "PAGE_SIZE": 100, + # Real clients typically JSON-encode their request bodies, so the test client should too + "TEST_REQUEST_DEFAULT_FORMAT": "json", +} + +SWAGGER_SETTINGS: dict[str, Any] = { + # The default security definition ("basic") is not supported by this DRF configuration, + # so expect all logins to come via the Django session, which there's no OpenAPI + # security definition for. + "SECURITY_DEFINITIONS": None, + "USE_SESSION_AUTH": True, +} + +REDOC_SETTINGS: dict[str, Any] = {} diff --git a/django-resonant-settings/resonant_settings/testing/__init__.py b/django-resonant-settings/resonant_settings/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-resonant-settings/resonant_settings/testing/minio_storage.py b/django-resonant-settings/resonant_settings/testing/minio_storage.py new file mode 100644 index 0000000..a2f1f09 --- /dev/null +++ b/django-resonant-settings/resonant_settings/testing/minio_storage.py @@ -0,0 +1,26 @@ +""" +Configure MinioMediaStorage. + +The following environment variables must be externally set: +* DJANGO_MINIO_STORAGE_ENDPOINT +* DJANGO_MINIO_STORAGE_ACCESS_KEY +* DJANGO_MINIO_STORAGE_SECRET_KEY +* DJANGO_STORAGE_BUCKET_NAME + +This requires the `django-minio-storage` package to be installed. +""" + +from resonant_settings._env import env + +MINIO_STORAGE_ENDPOINT: str = env.str("DJANGO_MINIO_STORAGE_ENDPOINT") +MINIO_STORAGE_USE_HTTPS: bool = env.bool("DJANGO_MINIO_STORAGE_USE_HTTPS", default=False) +MINIO_STORAGE_ACCESS_KEY: str = env.str("DJANGO_MINIO_STORAGE_ACCESS_KEY") +MINIO_STORAGE_SECRET_KEY: str = env.str("DJANGO_MINIO_STORAGE_SECRET_KEY") +# Use this name for unity with the S3 configuration +MINIO_STORAGE_MEDIA_BUCKET_NAME: str = env.str("DJANGO_STORAGE_BUCKET_NAME") +# Setting this allows MinIO to work through network namespace partitions +# (e.g. when running within Docker Compose) +MINIO_STORAGE_MEDIA_URL: str | None = env.str("DJANGO_MINIO_STORAGE_MEDIA_URL", default=None) +MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = True +MINIO_STORAGE_AUTO_CREATE_MEDIA_POLICY = "READ_WRITE" +MINIO_STORAGE_MEDIA_USE_PRESIGNED = True diff --git a/django-resonant-settings/tox.ini b/django-resonant-settings/tox.ini new file mode 100644 index 0000000..14ebd1c --- /dev/null +++ b/django-resonant-settings/tox.ini @@ -0,0 +1,55 @@ +[tox] +# Don't use "min_version", to ensure Tox 3 respects this +minversion = 4 +env_list = + lint, + type, + +[testenv] +# Building and installing wheels is significantly faster +package = wheel +extras = + allauth + +[testenv:lint] +package = skip +deps = + flake8 + flake8-black + flake8-bugbear + flake8-docstrings + flake8-isort + pep8-naming +commands = + flake8 . + +[testenv:format] +package = skip +deps = + black + isort +commands = + isort . + black . + +[testenv:type] +# Editable ensures dependencies are installed, but full packaging isn't necessary +package = editable +deps = + django-stubs + mypy +commands = + mypy {posargs} + +[flake8] +max-line-length = 100 +show-source = True +ignore = + # closing bracket does not match indentation of opening bracket’s line + E123, + # whitespace before ':' + E203, + # line break before binary operator + W503, + # Missing docstring in * + D10, diff --git a/{{ cookiecutter.project_slug }}/.github/workflows/ci.yml b/{{ cookiecutter.project_slug }}/.github/workflows/ci.yml index 42b5777..adab0d9 100644 --- a/{{ cookiecutter.project_slug }}/.github/workflows/ci.yml +++ b/{{ cookiecutter.project_slug }}/.github/workflows/ci.yml @@ -28,9 +28,9 @@ jobs: ports: - 9000:9000 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install tox @@ -42,6 +42,8 @@ jobs: tox env: DJANGO_DATABASE_URL: postgres://postgres:postgres@localhost:5432/django + DJANGO_CELERY_BROKER_URL: amqp://localhost:5672/ DJANGO_MINIO_STORAGE_ENDPOINT: localhost:9000 DJANGO_MINIO_STORAGE_ACCESS_KEY: minioAccessKey DJANGO_MINIO_STORAGE_SECRET_KEY: minioSecretKey + DJANGO_STORAGE_BUCKET_NAME: django-storage-testing diff --git a/{{ cookiecutter.project_slug }}/dev/.env.docker-compose b/{{ cookiecutter.project_slug }}/dev/.env.docker-compose index 2eddc36..e458b84 100644 --- a/{{ cookiecutter.project_slug }}/dev/.env.docker-compose +++ b/{{ cookiecutter.project_slug }}/dev/.env.docker-compose @@ -1,4 +1,4 @@ -DJANGO_CONFIGURATION=DevelopmentConfiguration +DJANGO_SETTINGS_MODULE={{ cookiecutter.pkg_name }}.settings.development DJANGO_DATABASE_URL=postgres://postgres:postgres@postgres:5432/django DJANGO_CELERY_BROKER_URL=amqp://rabbitmq:5672/ DJANGO_MINIO_STORAGE_ENDPOINT=minio:9000 diff --git a/{{ cookiecutter.project_slug }}/dev/.env.docker-compose-native b/{{ cookiecutter.project_slug }}/dev/.env.docker-compose-native index 7c57700..a92d635 100644 --- a/{{ cookiecutter.project_slug }}/dev/.env.docker-compose-native +++ b/{{ cookiecutter.project_slug }}/dev/.env.docker-compose-native @@ -1,4 +1,4 @@ -DJANGO_CONFIGURATION=DevelopmentConfiguration +DJANGO_SETTINGS_MODULE={{ cookiecutter.pkg_name }}.settings.development DJANGO_DATABASE_URL=postgres://postgres:postgres@localhost:5432/django DJANGO_CELERY_BROKER_URL=amqp://localhost:5672/ DJANGO_MINIO_STORAGE_ENDPOINT=localhost:9000 diff --git a/{{ cookiecutter.project_slug }}/manage.py b/{{ cookiecutter.project_slug }}/manage.py index eccd9f2..9dfb0df 100755 --- a/{{ cookiecutter.project_slug }}/manage.py +++ b/{{ cookiecutter.project_slug }}/manage.py @@ -2,16 +2,13 @@ import os import sys -import configurations.importer from django.core.management import execute_from_command_line def main() -> None: - os.environ['DJANGO_SETTINGS_MODULE'] = '{{ cookiecutter.pkg_name }}.settings' # Production usage runs manage.py for tasks like collectstatic, - # so DJANGO_CONFIGURATION should always be explicitly set in production - os.environ.setdefault('DJANGO_CONFIGURATION', 'DevelopmentConfiguration') - configurations.importer.install(check_options=True) + # so DJANGO_SETTINGS_MODULE should always be explicitly set in production + os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{{ cookiecutter.pkg_name }}.settings.development') execute_from_command_line(sys.argv) diff --git a/{{ cookiecutter.project_slug }}/setup.py b/{{ cookiecutter.project_slug }}/setup.py index 2303c32..5c3392f 100644 --- a/{{ cookiecutter.project_slug }}/setup.py +++ b/{{ cookiecutter.project_slug }}/setup.py @@ -23,7 +23,8 @@ classifiers=[ 'Development Status :: 3 - Alpha', 'Environment :: Web Environment', - 'Framework :: Django :: 3.0', + 'Framework :: Django :: 5', + 'Framework :: Django :: 5.1', 'Framework :: Django', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', @@ -37,23 +38,31 @@ include_package_data=True, install_requires=[ 'celery', - 'django', + 'django[argon2]', 'django-allauth', - 'django-configurations[database,email]', + 'django-auth-style', + 'django-cors-headers', + 'django-environ', 'django-extensions', 'django-filter', 'django-oauth-toolkit', + 'django-resonant-settings[allauth]', + 'django-resonant-utils', 'djangorestframework', 'drf-yasg', + 'psycopg[binary]', + 'rich', + 'whitenoise[brotli]', # Production-only - 'django-composed-configuration[prod]>=0.20', 'django-s3-file-field[s3]', + 'django-storages[s3]', 'gunicorn', + 'sentry-sdk', ], extras_require={ 'dev': [ - 'django-composed-configuration[dev]>=0.18', 'django-debug-toolbar', + 'django-minio-storage', 'django-s3-file-field[minio]', 'ipython', 'tox', diff --git a/{{ cookiecutter.project_slug }}/tox.ini b/{{ cookiecutter.project_slug }}/tox.ini index bc6c5b5..f29600e 100644 --- a/{{ cookiecutter.project_slug }}/tox.ini +++ b/{{ cookiecutter.project_slug }}/tox.ini @@ -4,6 +4,12 @@ envlist = {% if cookiecutter.include_example_code != 'yes' %}# {% endif -%}test, check-migrations, +[testenv] +passenv = + DJANGO_* +extras = + dev + [testenv:lint] skipsdist = true skip_install = true @@ -39,14 +45,6 @@ commands = black {posargs:.} [testenv:test] -passenv = - DJANGO_CELERY_BROKER_URL - DJANGO_DATABASE_URL - DJANGO_MINIO_STORAGE_ACCESS_KEY - DJANGO_MINIO_STORAGE_ENDPOINT - DJANGO_MINIO_STORAGE_SECRET_KEY -extras = - dev deps = factory-boy pytest @@ -58,15 +56,7 @@ commands = [testenv:check-migrations] setenv = - DJANGO_CONFIGURATION = TestingConfiguration -passenv = - DJANGO_CELERY_BROKER_URL - DJANGO_DATABASE_URL - DJANGO_MINIO_STORAGE_ACCESS_KEY - DJANGO_MINIO_STORAGE_ENDPOINT - DJANGO_MINIO_STORAGE_SECRET_KEY -extras = - dev + DJANGO_SETTINGS_MODULE = {{ cookiecutter.pkg_name }}.settings.testing commands = {envpython} ./manage.py makemigrations --check --dry-run @@ -82,10 +72,11 @@ ignore = W503, # Missing docstring in * D10, +per-file-ignores = + {{ cookiecutter.pkg_name }}/settings/*:E402,F401,F403,F405 [pytest] -DJANGO_SETTINGS_MODULE = {{ cookiecutter.pkg_name }}.settings -DJANGO_CONFIGURATION = TestingConfiguration +DJANGO_SETTINGS_MODULE = {{ cookiecutter.pkg_name }}.settings.testing addopts = --strict-markers --showlocals --verbose filterwarnings = # https://github.com/jazzband/django-configurations/issues/190 diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/asgi.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/asgi.py index 1c61193..5aaddc0 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/asgi.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/asgi.py @@ -1,11 +1,3 @@ -import os - -import configurations.importer from django.core.asgi import get_asgi_application -os.environ['DJANGO_SETTINGS_MODULE'] = '{{ cookiecutter.pkg_name }}.settings' -if not os.environ.get('DJANGO_CONFIGURATION'): - raise ValueError('The environment variable "DJANGO_CONFIGURATION" must be set.') -configurations.importer.install() - application = get_asgi_application() diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/celery.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/celery.py index 4ed0f79..81b917e 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/celery.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/celery.py @@ -1,12 +1,4 @@ -import os - from celery import Celery -import configurations.importer - -os.environ['DJANGO_SETTINGS_MODULE'] = '{{ cookiecutter.pkg_name }}.settings' -if not os.environ.get('DJANGO_CONFIGURATION'): - raise ValueError('The environment variable "DJANGO_CONFIGURATION" must be set.') -configurations.importer.install() # Using a string config_source means the worker doesn't have to serialize # the configuration object to child processes. diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings.py deleted file mode 100644 index 14ca782..0000000 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from composed_configuration import ( - ComposedConfiguration, - ConfigMixin, - DevelopmentBaseConfiguration, - HerokuProductionBaseConfiguration, - ProductionBaseConfiguration, - TestingBaseConfiguration, -) - - -class {{ cookiecutter.pkg_name.split('_')|map('capitalize')|join('') }}Mixin(ConfigMixin): - WSGI_APPLICATION = '{{ cookiecutter.pkg_name }}.wsgi.application' - ROOT_URLCONF = '{{ cookiecutter.pkg_name }}.urls' - - BASE_DIR = Path(__file__).resolve(strict=True).parent.parent - - @staticmethod - def mutate_configuration(configuration: ComposedConfiguration) -> None: - # Install local apps first, to ensure any overridden resources are found first - configuration.INSTALLED_APPS = [ - '{{ cookiecutter.pkg_name }}.{{ cookiecutter.first_app_name }}.apps.{{ cookiecutter.first_app_name.split('_')|map('capitalize')|join('') }}Config', - ] + configuration.INSTALLED_APPS - - # Install additional apps - configuration.INSTALLED_APPS += [ - 's3_file_field', - ] - - -class DevelopmentConfiguration({{ cookiecutter.pkg_name.split('_')|map('capitalize')|join('') }}Mixin, DevelopmentBaseConfiguration): - SHELL_PLUS_IMPORTS = [ - 'from {{ cookiecutter.pkg_name }}.{{ cookiecutter.first_app_name }} import tasks', - ] - - -class TestingConfiguration({{ cookiecutter.pkg_name.split('_')|map('capitalize')|join('') }}Mixin, TestingBaseConfiguration): - pass - - -class ProductionConfiguration({{ cookiecutter.pkg_name.split('_')|map('capitalize')|join('') }}Mixin, ProductionBaseConfiguration): - pass - - -class HerokuProductionConfiguration({{ cookiecutter.pkg_name.split('_')|map('capitalize')|join('') }}Mixin, HerokuProductionBaseConfiguration): - pass diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/__init__.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/base.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/base.py new file mode 100644 index 0000000..1812e99 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/base.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from datetime import timedelta +from pathlib import Path + +from environ import Env +from resonant_settings.allauth import * +from resonant_settings.celery import * +from resonant_settings.debug_toolbar import * +from resonant_settings.django import * +from resonant_settings.logging import * +from resonant_settings.oauth_toolkit import * +from resonant_settings.rest_framework import * + +env = Env() + +BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent + +WSGI_APPLICATION = '{{ cookiecutter.pkg_name }}.wsgi.application' +ROOT_URLCONF = '{{ cookiecutter.pkg_name }}.urls' + +INSTALLED_APPS = [ + # Install local apps first, to ensure any overridden resources are found first + '{{ cookiecutter.pkg_name }}.{{ cookiecutter.first_app_name }}.apps.{{ cookiecutter.first_app_name.split('_')|map('capitalize')|join('') }}Config', + # Apps with overrides + 'auth_style', + 'resonant_settings.allauth_support', + # Everything else + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'corsheaders', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.humanize', + 'django.contrib.messages', + 'django.contrib.postgres', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.staticfiles', + 'django_filters', + 'drf_yasg', + 'oauth2_provider', + 'resonant_utils', + 'rest_framework', + 'rest_framework.authtoken', + 's3_file_field', +] + +MIDDLEWARE = [ + # CorsMiddleware must be added before other response-generating middleware, + # so it can potentially add CORS headers to those responses too. + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + # WhiteNoiseMiddleware must be directly after SecurityMiddleware + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'allauth.account.middleware.AccountMiddleware', +] + +# Internal datetimes are timezone-aware, so this only affects rendering and form input +TIME_ZONE = 'UTC' + +DATABASES = { + 'default': { + **env.db_url('DJANGO_DATABASE_URL', engine='django.db.backends.postgresql'), + 'CONN_MAX_AGE': timedelta(minutes=10).total_seconds(), + } +} +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +STORAGES = { + # Inject the default storage in particular run configurations + 'default': None, + 'staticfiles': { + # CompressedManifestStaticFilesStorage does not work properly with drf- + # https://github.com/axnsan12/drf-yasg/issues/761 + 'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage', + }, +} + +STATIC_ROOT = BASE_DIR / 'staticfiles' +# Django staticfiles auto-creates any intermediate directories, but do so here to prevent warnings. +STATIC_ROOT.mkdir(exist_ok=True) + +# Django's docs suggest that STATIC_URL should be a relative path, +# for convenience serving a site on a subpath. +STATIC_URL = 'static/' + +# Make Django and Allauth redirects consistent, but both may be changed. +LOGIN_REDIRECT_URL = '/' +ACCOUNT_LOGOUT_REDIRECT_URL = '/' + +CORS_ORIGIN_WHITELIST: list[str] = env.list('DJANGO_CORS_ORIGIN_WHITELIST', cast=str, default=[]) +CORS_ORIGIN_REGEX_WHITELIST: list[str] = env.list( + 'DJANGO_CORS_ORIGIN_REGEX_WHITELIST', cast=str, default=[] +) diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/development.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/development.py new file mode 100644 index 0000000..9e2e03f --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/development.py @@ -0,0 +1,39 @@ +from .testing import * + +# Import these afterwards, to override +from resonant_settings.development.celery import * # isort: skip +from resonant_settings.development.extensions import * # isort: skip + +INSTALLED_APPS += [ + 'debug_toolbar', + 'django_extensions', +] +# Force WhiteNoice to serve static files, even when using 'manage.py runserver' +staticfiles_index = INSTALLED_APPS.index('django.contrib.staticfiles') +INSTALLED_APPS.insert(staticfiles_index, 'whitenoise.runserver_nostatic') + +# Include Debug Toolbar middleware as early as possible in the list. +# However, it must come after any other middleware that encodes the response’s content, +# such as GZipMiddleware. +MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware') + +# DEBUG is not enabled for testing, to maintain parity with production. +# Also, do not directly reference DEBUG when toggling application features; it's more sustainable +# to add new settings as individual feature flags. +DEBUG = True + +CORS_ORIGIN_REGEX_WHITELIST: list[str] = env.list( + 'DJANGO_CORS_ORIGIN_REGEX_WHITELIST', + cast=str, + default=[r'^http://localhost:\d+$', r'^http://127\.0\.0\.1:\d+$'], +) + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +OAUTH2_PROVIDER['ALLOWED_REDIRECT_URI_SCHEMES'] = ['http', 'https'] +# In development, always present the approval dialog +OAUTH2_PROVIDER['REQUEST_APPROVAL_PROMPT'] = 'force' + +SHELL_PLUS_IMPORTS = [ + 'from {{ cookiecutter.pkg_name }}.{{ cookiecutter.first_app_name }} import tasks', +] diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/heroku_production.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/heroku_production.py new file mode 100644 index 0000000..8c76b44 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/heroku_production.py @@ -0,0 +1,9 @@ +import os + +# Redefine these before importing the rest of the settings +os.environ['DJANGO_DATABASE_URL'] = os.environ['DATABASE_URL'] +os.environ['DJANGO_CELERY_BROKER_URL'] = os.environ['CLOUDAMQP_URL'] +# Provided by https://github.com/ianpurvis/heroku-buildpack-version +os.environ['DJANGO_SENTRY_RELEASE'] = os.environ['SOURCE_VERSION'] + +from .production import * # isort: skip diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/production.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/production.py new file mode 100644 index 0000000..dec95ce --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/production.py @@ -0,0 +1,43 @@ +import logging + +import sentry_sdk +import sentry_sdk.integrations.celery +import sentry_sdk.integrations.django +import sentry_sdk.integrations.logging + +from .base import * + +# Import these afterwards, to override +from resonant_settings.production.email import * # isort: skip +from resonant_settings.production.https import * # isort: skip + +SECRET_KEY: str = env.str('DJANGO_SECRET_KEY') + +# This only needs to be defined in production. Testing will add 'testserver'. In development +# (specifically when DEBUG is True), 'localhost' and '127.0.0.1' will be added. +ALLOWED_HOSTS: list[str] = env.list('DJANGO_ALLOWED_HOSTS', cast=str) + +STORAGES['default'] = { + 'BACKEND': 'storages.backends.s3.S3Storage', +} +from resonant_settings.production.s3_storage import * # isort: skip + +# sentry_sdk is able to directly use environment variables like 'SENTRY_DSN', but prefix them +# with 'DJANGO_' to avoid avoiding conflicts with other Sentry-using services. +sentry_sdk.init( + dsn=env.str('DJANGO_SENTRY_DSN', default=None), + environment=env.str('DJANGO_SENTRY_ENVIRONMENT', default=None), + release=env.str('DJANGO_SENTRY_RELEASE', default=None), + integrations=[ + sentry_sdk.integrations.logging.LoggingIntegration( + level=logging.INFO, + event_level=logging.WARNING, + ), + sentry_sdk.integrations.django.DjangoIntegration(), + sentry_sdk.integrations.celery.CeleryIntegration(), + ], + # Send traces for non-exception events too + attach_stacktrace=True, + # Submit request User info from Django + send_default_pii=True, +) diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/testing.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/testing.py new file mode 100644 index 0000000..de45bff --- /dev/null +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/settings/testing.py @@ -0,0 +1,12 @@ +from .base import * + +SECRET_KEY = 'insecure-secret' + +STORAGES['default'] = { + 'BACKEND': 'minio_storage.storage.MinioMediaStorage', +} +from resonant_settings.testing.minio_storage import * # isort: skip + +# Testing will set EMAIL_BACKEND to use the memory backend + +MINIO_STORAGE_MEDIA_BUCKET_NAME = 'test-django-storage' diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/urls.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/urls.py index 8af9780..4f7a8a9 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/urls.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/urls.py @@ -39,6 +39,6 @@ ] if settings.DEBUG: - import debug_toolbar + import debug_toolbar.toolbar - urlpatterns = [path('__debug__/', include(debug_toolbar.urls))] + urlpatterns + urlpatterns += debug_toolbar.toolbar.debug_toolbar_urls() diff --git a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/wsgi.py b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/wsgi.py index 5d91ee0..8435af2 100644 --- a/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/wsgi.py +++ b/{{ cookiecutter.project_slug }}/{{ cookiecutter.pkg_name }}/wsgi.py @@ -1,11 +1,3 @@ -import os - -import configurations.importer from django.core.wsgi import get_wsgi_application -os.environ['DJANGO_SETTINGS_MODULE'] = '{{ cookiecutter.pkg_name }}.settings' -if not os.environ.get('DJANGO_CONFIGURATION'): - raise ValueError('The environment variable "DJANGO_CONFIGURATION" must be set.') -configurations.importer.install() - application = get_wsgi_application()