├── {{cookiecutter.project_slug}} ├── .nvmrc ├── assets │ ├── .gitkeep │ ├── icons │ │ └── .gitkeep │ ├── js │ │ └── common.js │ └── css │ │ ├── theme.css │ │ └── common.css ├── envdir │ └── .gitkeep ├── locale │ └── .gitkeep ├── static │ └── dist │ │ └── .gitkeep ├── {{cookiecutter.project_slug}} │ ├── core │ │ ├── __init__.py │ │ ├── management │ │ │ ├── __init__.py │ │ │ └── commands │ │ │ │ ├── __init__.py │ │ │ │ └── fixturize.py │ │ └── apps.py │ ├── templates │ │ ├── .gitkeep │ │ └── base.html │ ├── accounts │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── models.py │ │ ├── admin.py │ │ └── apps.py │ └── config │ │ ├── settings │ │ ├── test.py │ │ ├── __init__.py │ │ ├── dev.py │ │ └── base.py │ │ ├── wsgi.py │ │ ├── __init__.py │ │ └── urls.py ├── prettier.config.js ├── entrypoint-frontend.sh ├── requirements │ ├── base.in │ ├── test.in │ └── dev.in ├── jsconfig.json ├── .vscode │ └── extensions.json ├── .coveragerc ├── scripts │ ├── run_tests.sh │ └── check_migrations.sh ├── eslint.config.js ├── Dockerfile-frontend ├── setup.cfg ├── .gitignore ├── .editorconfig ├── manage.py ├── .gitlab-ci.yml ├── entrypoint.sh ├── package.json ├── docker-compose.yml ├── Dockerfile ├── README.md ├── vite.config.js ├── Makefile ├── pyproject.toml ├── justfile ├── docker-compose.override.example.yml └── fabfile.py ├── .gitlab-ci.yml ├── .gitignore ├── cookiecutter.json ├── justfile ├── LICENSE ├── hooks └── post_gen_project.py └── README.md /{{cookiecutter.project_slug}}/.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/envdir/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/locale/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/assets/icons/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/static/dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | tests: 2 | script: "./bin/gitlabci.sh" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /envdir/* 3 | .pycharm_helpers 4 | /my_project -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/templates/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/assets/js/common.js: -------------------------------------------------------------------------------- 1 | import '../css/common.css'; 2 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/core/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/assets/css/theme.css: -------------------------------------------------------------------------------- 1 | @theme { 2 | } 3 | 4 | :root { 5 | } 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/core/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | singleQuote: true, 3 | trailingComma: 'es5', 4 | }; 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/entrypoint-frontend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$INITIAL" = "1" ]; then 4 | npm install 5 | fi 6 | 7 | exec "${@}" 8 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/requirements/base.in: -------------------------------------------------------------------------------- 1 | argon2-cffi 2 | django>=4.2,<4.3 # Latest LTS 3 | dj-database-url 4 | dj-email-url 5 | psycopg2-binary 6 | django-vite 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/requirements/test.in: -------------------------------------------------------------------------------- 1 | -r base.in 2 | 3 | factory_boy 4 | ipdb 5 | pytest 6 | pytest-cov 7 | pytest-django 8 | pytest-xdist 9 | ruff 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | 3 | 4 | class User(AbstractUser): 5 | pass 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = "{{ cookiecutter.project_slug }}.core" 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/requirements/dev.in: -------------------------------------------------------------------------------- 1 | -r test.in 2 | 3 | django-debug-toolbar 4 | django-extensions 5 | ipython 6 | werkzeug 7 | 8 | # Deployment requirements 9 | fabric 10 | dulwich 11 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/assets/css/common.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @import './theme.css'; 4 | 5 | @layer base { 6 | } 7 | 8 | @layer components { 9 | } 10 | 11 | @layer utilities { 12 | } 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./assets/*"] 6 | } 7 | }, 8 | "include": ["assets/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-containers", 4 | "ms-python.python", 5 | "ms-python.debugpy", 6 | "charliermarsh.ruff" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = {{ cookiecutter.project_slug }} 3 | omit = 4 | */migrations/* 5 | */tests/* 6 | */test_*.py 7 | */tests.py 8 | {{ cookiecutter.project_slug }}/config/* 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | 4 | from .models import User 5 | 6 | admin.site.register(User, UserAdmin) 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ./scripts/check_migrations.sh 6 | 7 | pytest "${@:-{{ cookiecutter.project_slug }}}" 8 | 9 | ruff check {{ cookiecutter.project_slug }} fabfile.py 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "{{ cookiecutter.project_slug }}.accounts" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 2 | import eslintPluginLiipLausanne from '@liip-lausanne/eslint-config'; 3 | 4 | export default [eslintPluginLiipLausanne, eslintPluginPrettierRecommended]; 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/scripts/check_migrations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Checking for missing migrations" 4 | export DATABASE_URL=sqlite:// # remove requirement for existing database 5 | ./manage.py makemigrations --dry-run --check --no-input -v1 || { echo "Missing migrations detected!"; exit 1; } 6 | exit 0 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/Dockerfile-frontend: -------------------------------------------------------------------------------- 1 | FROM node:20-bookworm 2 | 3 | ARG USER_ID=1000 4 | ARG GROUP_ID=1000 5 | 6 | RUN usermod -u $USER_ID node && \ 7 | groupmod -g $GROUP_ID node 8 | 9 | USER node 10 | WORKDIR /code 11 | 12 | COPY entrypoint-frontend.sh / 13 | ENTRYPOINT [ "/entrypoint-frontend.sh" ] 14 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/setup.cfg: -------------------------------------------------------------------------------- 1 | [pep8] 2 | max-line-length = 88 3 | 4 | [tool:pytest] 5 | DJANGO_SETTINGS_MODULE = {{cookiecutter.project_slug}}.config.settings.test 6 | python_files = test*.py tests.py tests/*.py 7 | log_level = INFO 8 | norecursedirs = node_modules .git 9 | addopts = --cov-report=term --cov={{cookiecutter.project_slug}} --no-cov-on-fail 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | /.coverage 4 | /.pytest_cache 5 | 6 | /envdir/* 7 | !/envdir/.gitkeep 8 | /.tox 9 | 10 | /node_modules 11 | /static/dist/* 12 | !/static/dist/.gitkeep 13 | 14 | {%- if cookiecutter.virtualization_tool == 'docker' %} 15 | /docker-compose.override.yml 16 | /override.justfile 17 | /venv 18 | /.env 19 | {%- endif %} 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 88 11 | 12 | [*.{py,php,md}] 13 | indent_size = 4 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | 18 | [Makefile] 19 | indent_style = tab 20 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "My Project", 3 | "project_slug": "{{ cookiecutter.project_name|lower|replace(' ', '_')|replace('-', '_') }}", 4 | "__network_name": "{{ cookiecutter.project_slug|replace('_', '') }}", 5 | "virtualization_tool": ["docker", "nothing"], 6 | "override_user_model": "y", 7 | "language_list": "de,en,fr,it", 8 | "default_language": "en", 9 | "_copy_without_render": [ 10 | "*.html" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/config/settings/test.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | 3 | SECRET_KEY = "test" 4 | 5 | DEBUG = False 6 | 7 | # Always use local memory cache, don't bother trying memcached or similar 8 | CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} 9 | 10 | # use basic password hashing for tests for better performance 11 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load django_vite %} 2 | {% get_current_language as LANGUAGE_CODE %} 3 | 4 | 5 | 6 | 7 | {{ cookiecutter.project_name }} 8 | 9 | {% vite_hmr_client %} 10 | {% vite_asset 'assets/js/common.js' %} 11 | 12 | 13 | 14 | {% block body %} 15 | It works! 16 | {% endblock %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from {{ cookiecutter.project_slug }}.config import get_project_root_path, import_env_vars 6 | 7 | if __name__ == "__main__": 8 | import_env_vars(os.path.join(get_project_root_path(), "envdir")) 9 | 10 | os.environ.setdefault( 11 | "DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.config.settings.dev" 12 | ) 13 | 14 | from django.core.management import execute_from_command_line 15 | 16 | execute_from_command_line(sys.argv) 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/config/settings/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | 6 | def get_env_variable(var_name, default=None): 7 | """ 8 | Get the environment variable or return exception 9 | """ 10 | try: 11 | return os.environ[var_name] 12 | except KeyError: 13 | if default is not None: 14 | return default 15 | else: 16 | error_msg = "Set the %s environment variable" % var_name 17 | raise ImproperlyConfigured(error_msg) 18 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | alias g := generate 2 | # Run cookiecutter with local template 3 | generate: 4 | rm -rf \{\{cookiecutter.project_slug\}\}/node_modules 5 | rm -rf my_project 6 | cookiecutter . --no-input 7 | 8 | alias s := start 9 | # Setup and start the playground project using Pontsun 10 | start: 11 | cp my_project/docker-compose.override.example.yml my_project/docker-compose.override.yml 12 | sed -i '' -e '35,93 s/^# //' -e '35,93 s/^#//' my_project/docker-compose.override.yml 13 | cd my_project && docker-compose down 14 | cd my_project && INITIAL=1 docker-compose up --build --force-recreate 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/config/settings/dev.py: -------------------------------------------------------------------------------- 1 | from . import get_env_variable 2 | from .base import * # noqa 3 | 4 | DEBUG = bool(get_env_variable("DEBUG", True)) 5 | DEBUG_TOOLBAR_CONFIG = { 6 | "INTERCEPT_REDIRECTS": False, 7 | "SHOW_COLLAPSED": True, 8 | "SHOW_TOOLBAR_CALLBACK": lambda request: True, 9 | } 10 | MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa 11 | 12 | SECRET_KEY = "notsosecret" 13 | INTERNAL_IPS = ("127.0.0.1",) 14 | 15 | INSTALLED_APPS += ("debug_toolbar", "django_extensions") # noqa 16 | 17 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" 18 | 19 | DJANGO_VITE_DEV_MODE = bool(int(get_env_variable("DJANGO_VITE_DEV_MODE", True))) 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for {{ cookiecutter.project_slug }} project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | from {{ cookiecutter.project_slug }}.config import get_project_root_path, import_env_vars 15 | 16 | import_env_vars(os.path.join(get_project_root_path(), "envdir")) 17 | 18 | os.environ.setdefault( 19 | "DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.config.settings.base" 20 | ) 21 | 22 | application = get_wsgi_application() 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/config/__init__.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | 4 | 5 | def import_env_vars(directory): 6 | """ 7 | List the files present in the given directory and for each of them create an environment variable named after the 8 | file, and which value is the contents of the file. 9 | """ 10 | env_vars = glob.glob(os.path.join(directory, "*")) 11 | 12 | for env_var in env_vars: 13 | with open(env_var, "r") as env_var_file: 14 | os.environ.setdefault( 15 | env_var.split(os.sep)[-1], env_var_file.read().strip() 16 | ) 17 | 18 | 19 | def get_project_root_path(): 20 | """ 21 | Return the absolute path to the root of the project. 22 | """ 23 | return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) 24 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | 4 | test: 5 | stage: test 6 | tags: 7 | - docker-nonswiss-ok 8 | image: &test_image 'python:3.11-bookworm' 9 | services: 10 | - name: postgres:15 11 | alias: db 12 | variables: 13 | DJANGO_SETTINGS_MODULE: "{{ cookiecutter.project_slug }}.config.settings.test" 14 | DATABASE_URL: postgresql://postgres:postgres@db/postgres 15 | POSTGRES_PASSWORD: postgres 16 | PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" 17 | cache: 18 | key: *test_image 19 | paths: 20 | - .cache/pip 21 | script: 22 | - pip install -r requirements/test.txt 23 | - | 24 | bash -c " 25 | echo -n 'Wait until the PostgreSQL server is ready .' 26 | while ! (echo > /dev/tcp/db/5432) >/dev/null 2>&1; do 27 | echo -n '.' 28 | sleep 1 29 | done 30 | echo ' Ready!' 31 | " 32 | - ./scripts/run_tests.sh 33 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/config/urls.py: -------------------------------------------------------------------------------- 1 | import django.views.static 2 | from django.conf import settings 3 | from django.contrib import admin 4 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 5 | from django.urls import include, path 6 | from django.views.generic import TemplateView 7 | 8 | admin.autodiscover() 9 | 10 | urlpatterns = [ 11 | path("", TemplateView.as_view(template_name="base.html")), 12 | path("admin/", admin.site.urls), 13 | ] 14 | 15 | if settings.DEBUG: 16 | import debug_toolbar 17 | 18 | urlpatterns = ( 19 | [ 20 | path( 21 | "media//", 22 | django.views.static.serve, 23 | {"document_root": settings.MEDIA_ROOT, "show_indexes": True}, 24 | ), 25 | path("__debug__/", include(debug_toolbar.urls)), 26 | ] 27 | + staticfiles_urlpatterns() 28 | + urlpatterns 29 | ) 30 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # We need to use bash because of the use of the /dev/tcp device 3 | 4 | if [ ! -e "$VIRTUAL_ENV/bin" ]; then 5 | # Note that the venv exists in the base image, 6 | # so this is only useful when bind-mounting /venv 7 | echo "Creating virtualenv at \"$VIRTUAL_ENV\"" 8 | python -m venv "$VIRTUAL_ENV" 9 | pip install wheel 10 | pip install pip-tools 11 | fi 12 | 13 | if [ "$INITIAL" = "1" ]; then 14 | if [ ! -e "requirements/dev.txt" ]; then 15 | pip-compile requirements/dev.in 16 | fi 17 | 18 | pip install -r requirements/dev.txt 19 | 20 | if [ ! -e "requirements/test.txt" ]; then 21 | pip-compile requirements/test.in 22 | fi 23 | 24 | # Wait for the db server to be ready, then run the fixturize command 25 | while ! (echo > /dev/tcp/db/5432) >/dev/null 2>&1; do echo -n '.'; sleep 1; done; 26 | echo "Running fixturize..." 27 | ./manage.py fixturize -y 28 | fi 29 | 30 | exec "${@}" 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Liip AG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@liip/{{ cookiecutter.project_slug }}", 3 | "version": "1.0.0", 4 | "description": "{{ cookiecutter.project_slug }} website", 5 | "author": "Liip AG", 6 | "license": "private", 7 | "private": true, 8 | "type": "module", 9 | "scripts": { 10 | "start": "vite", 11 | "build": "vite build", 12 | "eslint": "eslint 'assets/js/**/*.js'", 13 | "eslint:fix": "npm run eslint -- --fix", 14 | "prettier": "prettier 'assets/js/**/*.js' --list-different", 15 | "prettier:fix": "npm run prettier -- --write", 16 | "validate": "npm run eslint && npm run prettier", 17 | "format": "npm run eslint:fix && npm run prettier:fix" 18 | }, 19 | "dependencies": { 20 | "tailwindcss": "^4.1.10" 21 | }, 22 | "devDependencies": { 23 | "@liip-lausanne/eslint-config": "^2.0.0", 24 | "@tailwindcss/vite": "^4.1.10", 25 | "eslint": "^9.29.0", 26 | "eslint-config-prettier": "^10.1.5", 27 | "eslint-plugin-prettier": "^5.5.0", 28 | "prettier": "^3.5.3", 29 | "vite": "^6.4.1", 30 | "vite-plugin-live-reload": "^3.0.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.yml: -------------------------------------------------------------------------------- 1 | {% set network_name = cookiecutter.project_slug|replace("_", "") -%} 2 | x-environment: 3 | &x-environment 4 | INITIAL: "${INITIAL:-0}" 5 | 6 | services: 7 | db: 8 | image: postgres:15 9 | environment: 10 | POSTGRES_PASSWORD: {{ cookiecutter.project_slug }} 11 | POSTGRES_DB: {{ cookiecutter.project_slug }} 12 | volumes: 13 | - pgdata:/var/lib/postgresql/data 14 | backend: 15 | build: 16 | context: . 17 | command: sh -c 'while true; do ./manage.py runserver 0.0.0.0:8000; sleep 1; done' 18 | init: true 19 | environment: 20 | <<: *x-environment 21 | ALLOWED_HOSTS: >- 22 | localhost 23 | DATABASE_URL: postgresql://postgres:{{ cookiecutter.project_slug }}@db.{{ cookiecutter.__network_name }}/{{ cookiecutter.project_slug }} 24 | EMAIL_URL: smtp://mailpit.{{ cookiecutter.__network_name }}:1025 25 | MEDIA_ROOT: /opt/media 26 | volumes: 27 | - media:/opt/media 28 | - venv:/venv 29 | - .:/code 30 | depends_on: 31 | - db 32 | stdin_open: true 33 | tty: true 34 | frontend: 35 | build: 36 | context: . 37 | dockerfile: Dockerfile-frontend 38 | command: npm start 39 | init: true 40 | volumes: 41 | - .:/code 42 | environment: 43 | <<: *x-environment 44 | mailpit: 45 | image: axllent/mailpit:latest 46 | networks: 47 | default: 48 | name: {{ cookiecutter.__network_name }} 49 | 50 | volumes: 51 | media: 52 | pgdata: 53 | venv: 54 | -------------------------------------------------------------------------------- /hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | import os 5 | import shutil 6 | 7 | 8 | def uninstall_docker(): 9 | files_to_remove = { 10 | "entrypoint.sh", 11 | "entrypoint-frontend.sh", 12 | "docker-compose.yml", 13 | "docker-compose.override.example.yml", 14 | "Dockerfile", 15 | "Dockerfile-frontend", 16 | ".gitlab-ci.yml", 17 | } 18 | 19 | for file_ in files_to_remove: 20 | os.remove(file_) 21 | 22 | 23 | def generate_blank_locale_files(): 24 | for lang in "{{ cookiecutter.language_list }}".split(","): 25 | try: 26 | os.mkdir("locale/{}".format(lang)) 27 | os.mkdir("locale/{}/LC_MESSAGES".format(lang)) 28 | open("locale/{}/LC_MESSAGES/django.po".format(lang), "w").close() 29 | except FileExistsError: 30 | pass 31 | 32 | 33 | if __name__ == "__main__": 34 | use_docker = "{{ cookiecutter.virtualization_tool }}" == "docker" 35 | 36 | if not use_docker: 37 | uninstall_docker() 38 | 39 | if "{{ cookiecutter.override_user_model }}" == "n": 40 | shutil.rmtree("{{ cookiecutter.project_slug }}/accounts") 41 | 42 | generate_blank_locale_files() 43 | 44 | print( 45 | "\n(~˘▾˘)~ Your project `{{ cookiecutter.project_slug }}` is ready, have a nice day! ~(˘▾˘~)" 46 | ) 47 | if use_docker: 48 | print( 49 | "Please follow the instructions in the docker-compose.override.example.yml file to get started." 50 | ) 51 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-bookworm 2 | 3 | RUN set -x; \ 4 | export DEBIAN_FRONTEND=noninteractive \ 5 | && apt-get update -qq \ 6 | && apt-get install -yq \ 7 | gettext \ 8 | # Needed to build and deploy frontend assets from fabfile 9 | rsync \ 10 | # Not required. Only for development QoL. 11 | bash-completion postgresql-client \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | ENV NODE_VERSION="20.18.0" 15 | ENV NVM_DIR="/opt/nvm" 16 | RUN mkdir "$NVM_DIR" \ 17 | && curl https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash \ 18 | && . $NVM_DIR/nvm.sh \ 19 | && nvm install $NODE_VERSION 20 | 21 | ENV NODE_PATH="$NVM_DIR/versions/node/v$NODE_VERSION/lib/node_modules" 22 | ENV PATH="$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH" 23 | 24 | ARG USER_ID=1000 25 | ARG GROUP_ID=1000 26 | 27 | ENV PYTHONUNBUFFERED 1 28 | ENV PYTHONPATH "${PYTHONPATH}:/code" 29 | ENV VIRTUAL_ENV="/venv" 30 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 31 | 32 | RUN set -x; \ 33 | groupadd -g $GROUP_ID app && \ 34 | useradd --create-home -u $USER_ID -g app -s /bin/bash app && \ 35 | install -o app -g app -d /code "$VIRTUAL_ENV" 36 | 37 | RUN mkdir /opt/media 38 | RUN chown -R $USER_ID:$USER_ID /opt/media 39 | 40 | USER app 41 | RUN set -x; \ 42 | python -m venv "$VIRTUAL_ENV" && \ 43 | "$VIRTUAL_ENV/bin/pip" install wheel && \ 44 | "$VIRTUAL_ENV/bin/pip" install pip-tools 45 | WORKDIR /code 46 | 47 | COPY entrypoint.sh / 48 | ENTRYPOINT [ "/entrypoint.sh" ] 49 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/README.md: -------------------------------------------------------------------------------- 1 | # {{ cookiecutter.project_name }} 2 | 3 | {% if cookiecutter.virtualization_tool == 'docker' %} 4 | 5 | ### Build 6 | 7 | You can build a static version of your assets inside the box: 8 | 9 | ```bash 10 | npm run build 11 | ``` 12 | 13 | ### Formatting and Linting 14 | 15 | It’s recommended to have Prettier and EsLint enabled in your Editor. 16 | 17 | You can manually check that the code matches with the guidelines by running: 18 | 19 | ```bash 20 | npm run validate 21 | ``` 22 | 23 | You can automatically fix all the offenses tools are capable of by running: 24 | 25 | ```bash 26 | npm run format 27 | ``` 28 | 29 | ## Dev setup 30 | 31 | 1. Open the file `docker-compose.override.example.yml` and follow the instructions in it 32 | 33 | 2. Run the command `INITIAL=1 docker-compose up` 34 | 35 | This will start the containers and set up the initial data. To access the site, 36 | follow the instructions in the `docker-compose.override.example.yml` file. 37 | 38 | Note the `INITIAL` flag should not be set for subsequent container starts unless 39 | you want to reset the database. 40 | 41 | ## Automated tests 42 | 43 | To run backend tests and lint checks, run `scripts/run_tests.sh` in the `backend` container: 44 | - `docker-compose exec backend scripts/run_tests.sh` 45 | - or `docker-compose run --rm backend scripts/run_tests.sh` if the `backend` service is not already running 46 | 47 | CLI arguments are forwarded to `pytest`. 48 | For example, running tests with `scripts/run_tests.sh {{ cookiecutter.project_slug }} --reuse-db` avoids 49 | re-creating the database from scratch on each run. 50 | 51 | {%- endif %} 52 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import tailwindcss from '@tailwindcss/vite'; 4 | import liveReload from 'vite-plugin-live-reload'; 5 | 6 | export default defineConfig(() => ({ 7 | base: '/static/dist/', 8 | 9 | resolve: { 10 | alias: { 11 | '@': resolve(import.meta.dirname, './assets/js/'), 12 | }, 13 | }, 14 | 15 | build: { 16 | outDir: './static/dist', 17 | emptyOutDir: true, 18 | manifest: true, 19 | rollupOptions: { 20 | input: { 21 | common: 'assets/js/common.js', 22 | }, 23 | }, 24 | }, 25 | 26 | plugins: [tailwindcss(), liveReload(['**/templates/**/*.html'])], 27 | 28 | server: { 29 | port: 3000, 30 | // Do not use another port if 3000 is busy, its hardcoded elsewhere and required to work properly 31 | strictPort: true, 32 | host: true, 33 | allowedHosts: ['{{ cookiecutter.project_slug|replace("_", "-") }}.docker.test'], 34 | proxy: { 35 | '^(?!/static/dist/)': { 36 | target: 'http://backend.{{ cookiecutter.__network_name }}:8000', 37 | }, 38 | }, 39 | watch: { 40 | awaitWriteFinish: { 41 | stabilityThreshold: 500, 42 | }, 43 | ignored: [ 44 | '**/.vite/**', 45 | '**/__pycache__/**', 46 | '**/*.py', 47 | '**/*.pyc', 48 | '**/.venv/**', 49 | '**/.direnv/**', 50 | '**/.devenv/**', 51 | '**/.mypy_cache/**', 52 | '**/media/**', 53 | '**/static/**', 54 | '**/node_modules/**', 55 | '**/tests/**', 56 | ], 57 | }, 58 | }, 59 | })); 60 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | REQUIREMENTS_DIR = requirements 4 | objects = $(REQUIREMENTS_DIR)/base.txt $(REQUIREMENTS_DIR)/dev.txt $(REQUIREMENTS_DIR)/test.txt 5 | 6 | requirements/%.txt: $(REQUIREMENTS_DIR)/%.in $(REQUIREMENTS_DIR)/base.in 7 | pip-compile $< 8 | 9 | .PHONY: requirements 10 | requirements: $(objects) ## Compile requirements with pip-compile 11 | 12 | .PHONY: upgrade-requirements 13 | upgrade-requirements: ## Upgrade and compile requirements with pip-compile --upgrade 14 | for file in $(REQUIREMENTS_DIR)/*.in; do \ 15 | pip-compile --upgrade $$file; \ 16 | done 17 | 18 | .PHONY: sync-requirements 19 | sync-requirements: 20 | pip-sync $(objects) 21 | 22 | .PHONY: translations 23 | translations: ## Regenerate .po files with ./manage.py makemessages 24 | ./manage.py makemessages -a -i "deployment/*" -i "requirements/*" -i "virtualization/*" -i "node_modules/*" 25 | 26 | .PHONY: compile-translations 27 | compile-translations: ## Compile .po files with ./manage.py compilemessages 28 | ./manage.py compilemessages 29 | 30 | format: # Fix some linting issues in the project 31 | ruff format {{ cookiecutter.project_slug }} fabfile.py 32 | ruff check --select I --fix {{ cookiecutter.project_slug }} fabfile.py 33 | 34 | lint: # Show linting issues in the project 35 | ruff check {{ cookiecutter.project_slug }} fabfile.py 36 | 37 | lint-full: # Show all linting issues, code smells and bad practices in the project 38 | ruff check --select ALL {{ cookiecutter.project_slug }} fabfile.py 39 | 40 | .PHONY: help 41 | help: ## Display this help 42 | @grep -E '^[.a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 43 | 44 | clean: 45 | rm $(objects) 46 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | # Exclude a variety of commonly ignored directories. 3 | exclude = [ 4 | ".bzr", 5 | ".direnv", 6 | ".eggs", 7 | ".git", 8 | ".git-rewrite", 9 | ".hg", 10 | ".ipynb_checkpoints", 11 | ".mypy_cache", 12 | ".nox", 13 | ".pants.d", 14 | ".pyenv", 15 | ".pytest_cache", 16 | ".pytype", 17 | ".ruff_cache", 18 | ".svn", 19 | ".tox", 20 | ".venv", 21 | ".vscode", 22 | "__pypackages__", 23 | "_build", 24 | "buck-out", 25 | "build", 26 | "dist", 27 | "node_modules", 28 | "site-packages", 29 | "venv", 30 | "migrations", 31 | "emails", 32 | ] 33 | 34 | line-length = 88 35 | indent-width = 4 36 | 37 | [tool.ruff.lint] 38 | select = ["F", "I", "T10"] 39 | ignore = [] 40 | 41 | # Allow fix for all enabled rules (when `--fix`) is provided. 42 | fixable = ["ALL"] 43 | unfixable = [] 44 | 45 | # Allow unused variables when underscore-prefixed. 46 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 47 | 48 | [tool.ruff.lint.per-file-ignores] 49 | "{{cookiecutter.project_slug}}/config/settings/*.py" = ["F405", "F403"] 50 | 51 | [tool.ruff.lint.isort.sections] 52 | "django" = ["django"] 53 | 54 | [tool.ruff.lint.isort] 55 | force-wrap-aliases = false 56 | known-first-party = ["{{cookiecutter.project_slug}}"] 57 | section-order = [ 58 | "future", 59 | "standard-library", 60 | "django", 61 | "third-party", 62 | "first-party", 63 | "local-folder", 64 | ] 65 | 66 | [tool.ruff.format] 67 | # Like Black, use double quotes for strings. 68 | quote-style = "double" 69 | 70 | # Like Black, indent with spaces, rather than tabs. 71 | indent-style = "space" 72 | 73 | # Like Black, respect magic trailing commas. 74 | skip-magic-trailing-comma = false 75 | 76 | # Like Black, automatically detect the appropriate line ending. 77 | line-ending = "auto" 78 | 79 | # Enable auto-formatting of code examples in docstrings. Markdown, 80 | # reStructuredText code/literal blocks and doctests are all supported. 81 | # 82 | # This is currently disabled by default, but it is planned for this 83 | # to be opt-out in the future. 84 | docstring-code-format = false 85 | 86 | # Set the line length limit used when formatting code snippets in 87 | # docstrings. 88 | # 89 | # This only has an effect when the `docstring-code-format` setting is 90 | # enabled. 91 | docstring-code-line-length = "dynamic" 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django project template 2 | 3 | To create a new project using this template, do the following: 4 | 5 | pip install cookiecutter 6 | cookiecutter gh:liip/django-template 7 | 8 | Go to http://my-project.docker.test/ and start hacking! 9 | 10 | ## Troubleshooting 11 | 12 | If you get the following error when running `cookiecutter`: 13 | 14 | ``` 15 | Traceback (most recent call last): 16 | File "/home/me/.virtualenvs/tmp-1081dbf5557421f7/bin/pip-compile", line 7, in 17 | from piptools.scripts.compile import cli 18 | File "/home/me/.virtualenvs/tmp-1081dbf5557421f7/local/lib/python2.7/site-packages/piptools/scripts/compile.py", line 16, in 19 | from ..repositories import PyPIRepository 20 | File "/home/me/.virtualenvs/tmp-1081dbf5557421f7/local/lib/python2.7/site-packages/piptools/repositories/__init__.py", line 2, in 21 | from .pypi import PyPIRepository 22 | File "/home/me/.virtualenvs/tmp-1081dbf5557421f7/local/lib/python2.7/site-packages/piptools/repositories/pypi.py", line 10, in 23 | from pip.req.req_set import RequirementSet 24 | ImportError: No module named req_set 25 | ``` 26 | 27 | That's because your `pip` version is too old. Upgrade it either with your 28 | package manager, or by running `pip install --upgrade pip`. 29 | 30 | ## Deploying your site 31 | 32 | You can use the provided `fabfile` to deploy your site. Before using it, check 33 | the `fabfile.py` file and adjust the settings at the top of the file. 34 | 35 | Once the settings are adjusted and the dependencies installed, you can use the 36 | `bootstrap` command that will create the directory structure, push the code to 37 | the server and deploy your project. For example, the following command will 38 | bootstrap your dev environment: 39 | 40 | fab dev bootstrap 41 | 42 | You'll have to run the bootstrap command for each of the environments you want 43 | to bootstrap. After the site is bootstrapped, subsequent deployments can be 44 | done with the `deploy` command: 45 | 46 | fab dev deploy 47 | 48 | ## Contributing 49 | 50 | If you don’t happen to have [just](https://github.com/casey/just) installed, take a look at the `justfile` and run the commands manually. 51 | 52 | You can easily generate a new project using this template by running: 53 | 54 | ```bash 55 | just generate 56 | ``` 57 | 58 | After that, quickly configure and start Docker with Pontsun by running: 59 | 60 | ```bash 61 | just start 62 | ``` 63 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/core/management/commands/fixturize.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from io import StringIO 3 | 4 | from django.conf import settings 5 | from django.core import management 6 | from django.core.management.base import BaseCommand 7 | from django.db import connection 8 | 9 | 10 | def reset_db(): 11 | """ 12 | Reset database to a blank state by removing all the tables and recreating them. 13 | """ 14 | with connection.cursor() as cursor: 15 | cursor.execute("select tablename from pg_tables where schemaname = 'public'") 16 | tables = [row[0] for row in cursor.fetchall()] 17 | 18 | # Can't use query parameters here as they'll add single quotes which are not 19 | # supported by postgres 20 | for table in tables: 21 | cursor.execute('drop table "' + table + '" cascade') 22 | 23 | # Call migrate so that post-migrate hooks such as generating a default Site object 24 | # are run 25 | management.call_command("migrate", "--noinput", stdout=StringIO()) 26 | 27 | 28 | class Command(BaseCommand): 29 | help = "Reset the database and load test data" 30 | 31 | def add_arguments(self, parser): 32 | parser.add_argument( 33 | "-y", 34 | "--yes", 35 | action="store_true", 36 | dest="force_yes", 37 | default=False, 38 | help="Don't ask for confirmation.", 39 | ) 40 | 41 | @contextmanager 42 | def print_step(self, message): 43 | self.stdout.write(message, ending=" ") 44 | self.stdout.flush() 45 | yield 46 | self.stdout.write(self.style.SUCCESS("OK")) 47 | 48 | def handle(self, *args, **options): 49 | # Do not send emails when performing fake tasks 50 | settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" 51 | 52 | if not options["force_yes"]: 53 | self.stdout.write( 54 | self.style.WARNING("WARNING") 55 | + "\nThis will REMOVE ALL EXISTING DATA from the database.\n" 56 | "Are you SURE you want to do that? (y/N) ", 57 | ending="", 58 | ) 59 | 60 | try: 61 | result = input() 62 | except KeyboardInterrupt: 63 | self.stdout.write("Aborting.") 64 | return 65 | 66 | if result.lower() != "y": 67 | self.stdout.write("Aborting.") 68 | return 69 | 70 | with self.print_step("Resetting the database..."): 71 | reset_db() 72 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-22 15:17 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0012_alter_user_first_name_max_length'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='User', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('password', models.CharField(max_length=128, verbose_name='password')), 23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 25 | ('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')), 26 | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), 27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 28 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 29 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 30 | ('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')), 31 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 32 | ('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')), 33 | ('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')), 34 | ], 35 | options={ 36 | 'verbose_name': 'user', 37 | 'verbose_name_plural': 'users', 38 | 'abstract': False, 39 | }, 40 | managers=[ 41 | ('objects', django.contrib.auth.models.UserManager()), 42 | ], 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/justfile: -------------------------------------------------------------------------------- 1 | export BACKEND_CONTAINER := "backend" 2 | export FRONTEND_CONTAINER := "frontend" 3 | 4 | set allow-duplicate-recipes 5 | set positional-arguments 6 | 7 | default: 8 | just --list 9 | 10 | # Run the development server 11 | start *args: 12 | docker compose up "$@" 13 | 14 | # Run bash in backend container 15 | bash: 16 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} bash 17 | 18 | alias django := manage 19 | alias dj := manage 20 | # Run a Django manage.py command 21 | manage *args: 22 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} python manage.py "$@" 23 | 24 | # Run manage.py shell_plus 25 | alias shell := shell_plus 26 | alias sp := shell_plus 27 | shell_plus *args: 28 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} python manage.py shell_plus "$@" 29 | 30 | alias t := test 31 | # Run the tests suite 32 | test *args: 33 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} pytest "$@" 34 | 35 | alias validate := lint 36 | alias l := lint 37 | # Lint the code 38 | lint: 39 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} ruff check {{ cookiecutter.project_slug }} fabfile.py 40 | docker compose exec {{ '{{' }} FRONTEND_CONTAINER }} npm run validate 41 | 42 | alias fix := format 43 | # Fix styling offenses and format code 44 | format: 45 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} ruff format {{ cookiecutter.project_slug }} fabfile.py 46 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} ruff check --fix {{ cookiecutter.project_slug }} fabfile.py 47 | docker compose exec {{ '{{' }} FRONTEND_CONTAINER }} npm run format 48 | 49 | alias c := compile 50 | # Compile pip requirements files 51 | compile: 52 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} pip-compile requirements/base.in 53 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} pip-compile requirements/test.in 54 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} pip-compile requirements/dev.in 55 | 56 | alias cu := compile-upgrade 57 | # Update pip requirements files 58 | compile-upgrade file='requirements/base.in': 59 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} pip-compile --upgrade {{ '{{' }} file }} 60 | 61 | alias i := install 62 | # Install pip and npm dependencies 63 | install file='requirements/dev.txt': 64 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} pip install -r {{ '{{' }} file }} 65 | docker compose exec {{ '{{' }} FRONTEND_CONTAINER }} npm install 66 | 67 | alias mm := makemigrations 68 | # Generate database migrations 69 | makemigrations *args: 70 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} python manage.py makemigrations "$@" 71 | 72 | alias m := migrate 73 | # Migrate the database 74 | migrate *args: 75 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} python manage.py migrate "$@" 76 | 77 | alias f := fixturize 78 | # Reset the database and load the fixtures 79 | fixturize *args: 80 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} python manage.py fixturize --yes "$@" 81 | 82 | alias messages := translate 83 | # Make messages and compile them 84 | translate: 85 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} python manage.py makemessages -a -i "requirements/*" -i "node_modules/*" 86 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} python manage.py makemessages -a -d djangojs -i "node_modules/*" -i "static/*" 87 | docker compose exec {{ '{{' }} BACKEND_CONTAINER }} python manage.py compilemessages 88 | 89 | # Run npm command 90 | npm *args: 91 | docker compose exec {{ '{{' }} FRONTEND_CONTAINER }} npm "$@" 92 | 93 | import? 'override.justfile' 94 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/docker-compose.override.example.yml: -------------------------------------------------------------------------------- 1 | {% set subdomain = cookiecutter.project_slug|replace("_", "-") -%} 2 | # Docker-compose override examples 3 | # ================================ 4 | # 5 | # Make a copy of this file and name it `docker-compose.override.yml`, then 6 | # choose one among the following configurations and uncomment the lines. 7 | # Finally cross your fingers and run `INITIAL=1 docker-compose up`. 8 | 9 | # MINIMAL CONFIGURATION 10 | # ~~~~~~~~~~~~~~~~~~~~~ 11 | # 12 | # Visit http://localhost:8000/ to access the Django dev server and 13 | # http://localhost:3000/ to access the Webpack dev server. 14 | # 15 | # Visit http://localhost:8025 to access the project's fake mail box. 16 | # 17 | # services: 18 | # backend: 19 | # ports: 20 | # - 127.0.0.1:8000:8000 21 | # frontend: 22 | # ports: 23 | # - 127.0.0.1:3000:3000 24 | # mailpit: 25 | # ports: 26 | # - 127.0.0.1:8025:8025 27 | 28 | # PONTSUN CONFIGURATION 29 | # ~~~~~~~~~~~~~~~~~~~~~ 30 | # 31 | # Set up pontsun (https://github.com/liip/pontsun) and start it. Then visit 32 | # https://{{ subdomain }}.docker.test/ to access the Webpack dev server. 33 | # 34 | # Visit https://{{ subdomain }}-mail.docker.test/ to access the project's fake mail box. 35 | # 36 | # x-environment: 37 | # &x-environment 38 | # ALLOWED_HOSTS: |- 39 | # {{ subdomain }}.docker.test 40 | # 41 | # x-build-args: &x-build-args 42 | # USER_ID: 1000 # Change this if your host user is not 1000 43 | # GROUP_ID: 1000 # Change this if your host group is not 1000 44 | # 45 | # services: 46 | # backend: 47 | # environment: 48 | # <<: *x-environment 49 | # SSH_AUTH_SOCK: /run/host-services/ssh-auth.sock 50 | # DJANGO_VITE_DEV_SERVER_PROTOCOL: 'https' 51 | # DJANGO_VITE_DEV_SERVER_HOST: '{{ subdomain }}.docker.test' 52 | # DJANGO_VITE_DEV_SERVER_PORT: '' 53 | # 54 | # ## To forward SSH agent to the container, read https://wiki.liip.ch/x/FyDVJw 55 | # ## and uncomment one of the following volumes: 56 | # ## on macOS: 57 | # # volumes: 58 | # # - /run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock 59 | # ## on Linux: 60 | # # volumes: 61 | # # - ${SSH_AUTH_SOCK:-/dev/null}:/run/host-services/ssh-auth.sock 62 | # 63 | # build: 64 | # args: 65 | # <<: *x-build-args 66 | # frontend: 67 | # networks: 68 | # - default 69 | # - pontsun 70 | # labels: 71 | # - 'traefik.enable=true' 72 | # - 'traefik.http.routers.{{ cookiecutter.project_slug }}_frontend.entrypoints=http,https' 73 | # - 'traefik.http.routers.{{ cookiecutter.project_slug }}_frontend.middlewares=https_redirect@file' 74 | # - 'traefik.http.routers.{{ cookiecutter.project_slug }}_frontend.rule=Host(`{{ subdomain }}.docker.test`)' 75 | # - 'traefik.http.services.{{ cookiecutter.project_slug }}_frontend.loadbalancer.server.port=3000' 76 | # environment: 77 | # <<: *x-environment 78 | # build: 79 | # args: 80 | # <<: *x-build-args 81 | # mailpit: 82 | # networks: 83 | # - default 84 | # - pontsun 85 | # labels: 86 | # - 'traefik.enable=true' 87 | # - 'traefik.http.routers.{{ cookiecutter.project_slug }}_mailpit.entrypoints=http,https' 88 | # - 'traefik.http.routers.{{ cookiecutter.project_slug }}_mailpit.middlewares=https_redirect@file' 89 | # - 'traefik.http.routers.{{ cookiecutter.project_slug }}_mailpit.rule=Host(`{{ subdomain }}-mail.docker.test`)' 90 | # - 'traefik.http.services.{{ cookiecutter.project_slug }}_mailpit.loadbalancer.server.port=8025' 91 | # 92 | # networks: 93 | # pontsun: 94 | # external: true 95 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/config/settings/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | import dj_database_url 6 | import dj_email_url 7 | 8 | from .. import get_project_root_path 9 | from . import get_env_variable 10 | 11 | gettext = lambda s: s 12 | 13 | # Full filesystem path to the project. 14 | BASE_DIR = get_project_root_path() 15 | 16 | # Internationalization 17 | LANGUAGE_CODE = "{{ cookiecutter.default_language }}" 18 | TIME_ZONE = "Europe/Zurich" 19 | USE_I18N = True 20 | USE_L10N = True 21 | USE_TZ = True 22 | 23 | LANGUAGES = ( 24 | {%- for lang in cookiecutter.language_list.split(',') %} 25 | ("{{ lang }}", _("{{ lang }}")), 26 | {%- endfor %} 27 | ) 28 | 29 | LOCALE_PATHS = ("locale/",) 30 | 31 | # A boolean that turns on/off debug mode. When set to ``True``, stack traces 32 | # are displayed for error pages. Should always be set to ``False`` in 33 | # production. Best set to ``True`` in dev.py 34 | DEBUG = False 35 | 36 | # Whether a user's session cookie expires when the Web browser is closed. 37 | SESSION_EXPIRE_AT_BROWSER_CLOSE = True 38 | 39 | # Tuple of IP addresses, as strings, that: 40 | # * See debug comments, when DEBUG is true 41 | # * Receive x-headers 42 | INTERNAL_IPS = ("127.0.0.1",) 43 | 44 | # List of finder classes that know how to find static files in 45 | # various locations. 46 | STATICFILES_FINDERS = ( 47 | "django.contrib.staticfiles.finders.FileSystemFinder", 48 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 49 | ) 50 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 51 | 52 | # The numeric mode to set newly-uploaded files to. The value should be 53 | # a mode you'd pass directly to os.chmod. 54 | FILE_UPLOAD_PERMISSIONS = 0o644 55 | 56 | ALLOWED_HOSTS = tuple(get_env_variable("ALLOWED_HOSTS", "").splitlines()) 57 | 58 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 59 | 60 | USE_X_FORWARDED_HOST = get_env_variable("USE_X_FORWARDED_HOST", False) 61 | 62 | SECRET_KEY = get_env_variable("SECRET_KEY", "") 63 | 64 | PASSWORD_HASHERS = [ 65 | "django.contrib.auth.hashers.Argon2PasswordHasher", 66 | "django.contrib.auth.hashers.PBKDF2PasswordHasher" 67 | ] 68 | 69 | 70 | ############# 71 | # DATABASES # 72 | ############# 73 | 74 | DATABASES = {"default": dj_database_url.parse(get_env_variable("DATABASE_URL"))} 75 | 76 | # Allow using a separate password, from a k8s secret, for example 77 | DATABASE_PASSWORD = get_env_variable('DATABASE_PASSWORD', False) 78 | if DATABASE_PASSWORD: 79 | DATABASES['default']['PASSWORD'] = DATABASE_PASSWORD 80 | 81 | ######### 82 | # PATHS # 83 | ######### 84 | 85 | # Name of the directory for the project. 86 | PROJECT_DIRNAME = "{{ cookiecutter.project_slug }}" 87 | 88 | # Every cache key will get prefixed with this value - here we set it to 89 | # the name of the directory the project is in to try and use something 90 | # project specific. 91 | CACHE_MIDDLEWARE_KEY_PREFIX = PROJECT_DIRNAME 92 | 93 | # URL prefix for static files. 94 | # Example: "http://media.lawrence.com/static/" 95 | STATIC_URL = get_env_variable("STATIC_URL", "/static/") 96 | 97 | # Absolute path to the directory static files should be collected to. 98 | # Don't put anything in this directory yourself; store your static files 99 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 100 | # This is usually not used in a dev env, hence the default value 101 | # Example: "/home/media/media.lawrence.com/static/" 102 | STATIC_ROOT = get_env_variable("STATIC_ROOT", "/tmp/static") 103 | 104 | STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) 105 | 106 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 107 | # trailing slash. 108 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 109 | MEDIA_URL = get_env_variable("MEDIA_URL", "/media/") 110 | 111 | # Absolute filesystem path to the directory that will hold user-uploaded files. 112 | # Example: "/home/media/media.lawrence.com/media/" 113 | MEDIA_ROOT = get_env_variable("MEDIA_ROOT", "/tmp/static/media") 114 | 115 | # Package/module name to import the root urlpatterns from for the project. 116 | ROOT_URLCONF = "%s.config.urls" % PROJECT_DIRNAME 117 | WSGI_APPLICATION = "{{ cookiecutter.project_slug }}.config.wsgi.application" 118 | 119 | TEMPLATES = [ 120 | { 121 | "BACKEND": "django.template.backends.django.DjangoTemplates", 122 | "DIRS": [ 123 | os.path.join(BASE_DIR, "{{ cookiecutter.project_slug }}", "templates"), 124 | ], 125 | "APP_DIRS": True, 126 | "OPTIONS": { 127 | "context_processors": [ 128 | "django.contrib.auth.context_processors.auth", 129 | "django.contrib.messages.context_processors.messages", 130 | "django.template.context_processors.i18n", 131 | "django.template.context_processors.debug", 132 | "django.template.context_processors.request", 133 | "django.template.context_processors.media", 134 | "django.template.context_processors.csrf", 135 | "django.template.context_processors.tz", 136 | "django.template.context_processors.static", 137 | ], 138 | "builtins": ["django.templatetags.i18n", "django.templatetags.static"], 139 | }, 140 | } 141 | ] 142 | 143 | 144 | ################ 145 | # APPLICATIONS # 146 | ################ 147 | 148 | INSTALLED_APPS = ( 149 | "{{ cookiecutter.project_slug }}.core.apps.CoreConfig", 150 | {% if cookiecutter.override_user_model == 'y' -%} 151 | "{{ cookiecutter.project_slug }}.accounts.apps.AccountsConfig", 152 | {% endif -%} 153 | "django.contrib.auth", 154 | "django.contrib.contenttypes", 155 | "django.contrib.sessions", 156 | "django.contrib.admin", 157 | "django.contrib.staticfiles", 158 | "django.contrib.messages", 159 | "django_vite", 160 | ) 161 | 162 | # List of middleware classes to use. Order is important; in the request phase, 163 | # these middleware classes will be applied in the order given, and in the 164 | # response phase the middleware will be applied in reverse order. 165 | MIDDLEWARE = ( 166 | "django.contrib.sessions.middleware.SessionMiddleware", 167 | "django.middleware.csrf.CsrfViewMiddleware", 168 | "django.contrib.auth.middleware.AuthenticationMiddleware", 169 | "django.contrib.messages.middleware.MessageMiddleware", 170 | "django.middleware.locale.LocaleMiddleware", 171 | "django.middleware.common.CommonMiddleware", 172 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 173 | ) 174 | 175 | 176 | {% if cookiecutter.override_user_model == 'y' -%} 177 | ################## 178 | # AUTHENTICATION # 179 | ################## 180 | 181 | AUTH_USER_MODEL = "accounts.User" 182 | 183 | 184 | {% endif -%} 185 | 186 | 187 | ########### 188 | # LOGGING # 189 | ########### 190 | 191 | # Django first applies its default configuration (`django.utils.log.DEFAULT_LOGGING`), 192 | # then it applies this one (it calls logging.config.dictConfig twice). 193 | LOGGING = { 194 | "version": 1, 195 | "disable_existing_loggers": False, 196 | # This config will not affect existing handlers/filters/formatters that were 197 | # declared with the same name, and loggers of this config can only reference these 198 | # handlers/filters/formatters. 199 | "formatters": { 200 | "simple": {"format": "{name}:{levelname[0]} {message}", "style": "{"}, 201 | }, 202 | "handlers": { 203 | "console": { 204 | "level": "INFO", 205 | "formatter": "simple", 206 | "class": "logging.StreamHandler", 207 | }, 208 | }, 209 | # If loggers with the same name already exist, dictConfig will only apply explicitly 210 | # specified settings to the existing loggers, except for "handlers" and "filters" 211 | # that are always reset (see `logging.config.DictConfigurator.configure_logger`). 212 | # 213 | # On the other hand, for child loggers (e.g. "django.server" when overriding "django"), 214 | # dictConfig will reset "handlers", "level" and "propagate" to their default values, 215 | # but not "filters" (see `logging.config._handle_existing_loggers`). 216 | "loggers": { 217 | "": { 218 | "handlers": ["console"], 219 | "level": "NOTSET", 220 | }, 221 | "django": { 222 | # Delegate to root logger 223 | "handlers": (), 224 | "level": "NOTSET", 225 | "propagate": True, 226 | }, 227 | }, 228 | } 229 | 230 | 231 | ############# 232 | # E-Mailing # 233 | ############# 234 | 235 | EMAIL_URL = get_env_variable("EMAIL_URL", "console://") 236 | email_config = dj_email_url.parse(EMAIL_URL) 237 | EMAIL_FILE_PATH = email_config["EMAIL_FILE_PATH"] 238 | EMAIL_HOST_USER = email_config["EMAIL_HOST_USER"] 239 | EMAIL_HOST_PASSWORD = email_config["EMAIL_HOST_PASSWORD"] 240 | EMAIL_HOST = email_config["EMAIL_HOST"] 241 | EMAIL_PORT = email_config["EMAIL_PORT"] 242 | EMAIL_BACKEND = email_config["EMAIL_BACKEND"] 243 | EMAIL_USE_TLS = email_config["EMAIL_USE_TLS"] 244 | EMAIL_USE_SSL = email_config["EMAIL_USE_SSL"] 245 | DEFAULT_FROM_EMAIL = get_env_variable("EMAIL_FROM", "webmaster@localhost") 246 | 247 | 248 | ######## 249 | # VITE # 250 | ######## 251 | 252 | DJANGO_VITE_DEV_MODE = False 253 | DJANGO_VITE_DEV_SERVER_PROTOCOL = get_env_variable( 254 | "DJANGO_VITE_DEV_SERVER_PROTOCOL", "http" 255 | ) 256 | DJANGO_VITE_DEV_SERVER_HOST = get_env_variable( 257 | "DJANGO_VITE_DEV_SERVER_HOST", "localhost" 258 | ) 259 | DJANGO_VITE_DEV_SERVER_PORT = get_env_variable("DJANGO_VITE_DEV_SERVER_PORT", "3000") 260 | DJANGO_VITE_ASSETS_PATH = os.path.join("static", "dist") 261 | # Already relative to static files storage URL 262 | DJANGO_VITE_STATIC_URL_PREFIX = "dist" 263 | DJANGO_VITE_MANIFEST_PATH = os.path.join("static", "dist", ".vite", "manifest.json") 264 | -------------------------------------------------------------------------------- /{{cookiecutter.project_slug}}/fabfile.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | import os 4 | import random 5 | import subprocess 6 | from datetime import datetime 7 | from io import StringIO 8 | from pathlib import Path 9 | 10 | import dj_database_url 11 | from dulwich import porcelain 12 | from fabric import task 13 | from fabric.connection import Connection 14 | from invoke import Exit 15 | from invoke.exceptions import UnexpectedExit 16 | 17 | ENVIRONMENTS = { 18 | "prod": { 19 | "root": "/var/www/{{ cookiecutter.project_slug }}/prod/", 20 | "host": "root@myhost", 21 | "pid": "/path/to/uwsgi/pid", 22 | # You can set settings that will be automatically deployed when running 23 | # the `bootstrap` command 24 | "settings": { 25 | # 'ALLOWED_HOSTS': 'www.myhost.com', 26 | }, 27 | }, 28 | "dev": { 29 | "root": "/var/www/{{ cookiecutter.project_slug }}/dev/", 30 | "host": "root@myhost", 31 | "pid": "/path/to/uwsgi/pid", 32 | # You can set settings that will be automatically deployed when running 33 | # the `bootstrap` command 34 | "settings": { 35 | # 'ALLOWED_HOSTS': 'www.myhost.com', 36 | }, 37 | }, 38 | } 39 | 40 | local_project_root = os.path.dirname(__file__) 41 | project_name = "{{ cookiecutter.project_slug }}" 42 | 43 | 44 | class MissingEnvVariable(Exception): 45 | pass 46 | 47 | 48 | def get_local_env_variable(var_name, allow_empty=False): 49 | try: 50 | env_value = os.environ[var_name] 51 | except KeyError: 52 | env_path = Path("envdir").joinpath(var_name) 53 | if not env_path.is_file(): 54 | raise MissingEnvVariable( 55 | f"The local {var_name} environment variable is not set. " 56 | "Is it correctly set in your docker configuration? " 57 | ) 58 | env_value = env_path.read_text() 59 | 60 | if not allow_empty and not env_value.strip(): 61 | raise MissingEnvVariable( 62 | f"The local {var_name} environment variable is empty. " 63 | "Is it correctly set in your docker configuration? " 64 | ) 65 | return env_value 66 | 67 | 68 | def remote(task_func): 69 | """ 70 | Decorate task functions to check for presence of a Connection instance in their context. 71 | Also pass the Connection instance in argument for convenience. 72 | """ 73 | 74 | @functools.wraps(task_func) 75 | def call_task_with_connection(ctx, *args, **kwargs): 76 | if not hasattr(ctx, "conn"): 77 | raise RuntimeError("Trying to run a remote task with no environment loaded") 78 | return task_func(ctx, *args, **kwargs) 79 | 80 | call_task_with_connection.__signature__ = inspect.signature(task_func) 81 | return call_task_with_connection 82 | 83 | 84 | def ensure_absolute_path(path): 85 | if not os.path.isabs(path): 86 | raise ValueError("{!r} is not an absolute path.".format(path)) 87 | 88 | 89 | class CustomConnection(Connection): 90 | """ 91 | Add helpers function on Connection 92 | """ 93 | 94 | @property 95 | def site_root(self): 96 | return self.config.root 97 | 98 | @property 99 | def project_root(self): 100 | """ 101 | Return the path to the root of the project on the remote server. 102 | """ 103 | return os.path.join(self.site_root, project_name) 104 | 105 | @property 106 | def venv_path(self): 107 | return os.path.join(self.site_root, "venv") 108 | 109 | @property 110 | def envdir_path(self): 111 | return os.path.join(self.project_root, "envdir") 112 | 113 | @property 114 | def backups_root(self): 115 | """ 116 | Return the path to the backups directory on the remote server. 117 | """ 118 | return os.path.join(self.site_root, "backups") 119 | 120 | @property 121 | def media_root(self): 122 | """ 123 | Return the path to the media directory on the remote server. 124 | """ 125 | try: 126 | path = self.config.settings["MEDIA_ROOT"] 127 | except KeyError: 128 | return os.path.join(self.site_root, "media") 129 | else: 130 | ensure_absolute_path(path) 131 | return path 132 | 133 | @property 134 | def static_root(self): 135 | """ 136 | Return the path to the static directory on the remote server. 137 | """ 138 | try: 139 | path = self.config.settings["STATIC_ROOT"] 140 | except KeyError: 141 | return os.path.join(self.site_root, "static") 142 | else: 143 | ensure_absolute_path(path) 144 | return path 145 | 146 | def run_in_project_root(self, cmd, **kwargs): 147 | """ 148 | Run command after a cd to the project_root 149 | """ 150 | with self.cd(self.project_root): 151 | return self.run(cmd, **kwargs) 152 | 153 | def git(self, gitcmd, **kwargs): 154 | """ 155 | git from the project_root 156 | """ 157 | return self.run_in_project_root("git {}".format(gitcmd), **kwargs) 158 | 159 | def run_in_venv(self, cmd, args, **run_kwargs): 160 | """ 161 | Binaries from the venv 162 | """ 163 | return self.run_in_project_root( 164 | "{} {}".format(os.path.join(self.venv_path, "bin", cmd), args), **run_kwargs 165 | ) 166 | 167 | def mk_venv(self, **run_kwargs): 168 | """ 169 | Create the venv 170 | """ 171 | 172 | with self.cd(self.site_root): 173 | self.run("python3 -m venv venv", **run_kwargs) 174 | self.pip("install wheel") 175 | 176 | def pip(self, args, **run_kwargs): 177 | """ 178 | pip from the venv, in the project_root 179 | """ 180 | return self.run_in_venv("pip", args, **run_kwargs) 181 | 182 | def python(self, args, **run_kwargs): 183 | """ 184 | python from the venv, in the project_root 185 | """ 186 | return self.run_in_venv("python", args, **run_kwargs) 187 | 188 | def manage_py(self, args, **run_kwargs): 189 | """ 190 | manage.py with the python from the venv, in the project_root 191 | """ 192 | try: 193 | env = { 194 | "DJANGO_SETTINGS_MODULE": self.config.settings["DJANGO_SETTINGS_MODULE"] 195 | } 196 | except KeyError: 197 | env = {} 198 | return self.python("./manage.py {}".format(args), env=env, **run_kwargs) 199 | 200 | def set_setting(self, name, value=None, force: bool = True): 201 | """ 202 | Set a setting in the environment directory, for use by Django 203 | """ 204 | envfile_path = os.path.join(self.envdir_path, name) 205 | 206 | will_write = force 207 | if not force: 208 | try: 209 | # Test that it does exist 210 | self.run_in_project_root("test -r {}".format(envfile_path), hide=True) 211 | except UnexpectedExit: 212 | will_write = True 213 | 214 | if will_write: 215 | if value is None: 216 | value = input("Value for {}: ".format(name)) 217 | 218 | # Convert booleans into values understood as such by Django 219 | if isinstance(value, bool): 220 | value = "1" if value else "" 221 | self.put(StringIO("{}\n".format(value)), envfile_path) 222 | 223 | def dump_db(self, destination): 224 | """ 225 | Dump the database to the given directory and return the path to the file created. 226 | This creates a gzipped SQL file. 227 | """ 228 | with self.cd(self.project_root): 229 | env_file = os.path.join(self.envdir_path, "DATABASE_URL") 230 | db_credentials = self.run("cat " + env_file, hide=True).stdout.strip() 231 | 232 | db_credentials_dict = dj_database_url.parse(db_credentials) 233 | 234 | if not is_supported_db_engine(db_credentials_dict["ENGINE"]): 235 | raise NotImplementedError( 236 | "The dump_db task doesn't support the remote database engine" 237 | ) 238 | 239 | outfile = os.path.join( 240 | destination, datetime.now().strftime("%Y-%m-%d_%H%M%S.sql.gz") 241 | ) 242 | 243 | self.run( 244 | "pg_dump -O -x -h '{host}' -U '{user}' '{db}'|gzip > {outfile}".format( 245 | host=db_credentials_dict["HOST"], 246 | user=db_credentials_dict["USER"], 247 | db=db_credentials_dict["NAME"], 248 | outfile=outfile, 249 | ), 250 | env={"PGPASSWORD": db_credentials_dict["PASSWORD"].replace("$", "\$")}, 251 | ) 252 | 253 | return outfile 254 | 255 | def create_structure(self): 256 | """ 257 | Create the basic directory structure on the remote server. 258 | """ 259 | command = " ".join( 260 | [ 261 | "mkdir -p", 262 | self.project_root, 263 | self.backups_root, 264 | self.static_root, 265 | self.media_root, 266 | ] 267 | ) 268 | self.run(command) 269 | 270 | # Initialize empty git repository for project 271 | with self.cd(self.project_root): 272 | try: 273 | self.git("--version", hide=True) 274 | except UnexpectedExit: 275 | raise Exit("Provisioning not finished, git not available!") 276 | 277 | try: 278 | self.git("rev-parse --git-dir", hide=True) 279 | except UnexpectedExit: 280 | self.git("init") 281 | self.git("commit --allow-empty -m empty-commit") 282 | self.git("branch -f last_master master") 283 | 284 | def clean_old_database_backups(self, nb_backups_to_keep): 285 | """ 286 | Remove old database backups from the system and keep `nb_backups_to_keep`. 287 | """ 288 | backups = self.ls(self.backups_root) 289 | backups = sorted(backups, reverse=True) 290 | 291 | if len(backups) > nb_backups_to_keep: 292 | backups_to_delete = backups[nb_backups_to_keep:] 293 | file_to_remove = [ 294 | os.path.join(self.backups_root, backup_to_delete) 295 | for backup_to_delete in backups_to_delete 296 | ] 297 | self.run('rm "%s"' % '" "'.join(file_to_remove)) 298 | print("%d backups deleted." % len(backups_to_delete)) 299 | else: 300 | print("No backups to delete.") 301 | 302 | def ls(self, path): 303 | """ 304 | Return the list of the files in the given directory, omitting . and ... 305 | """ 306 | with self.cd(path): 307 | files = self.run("for i in *; do echo $i; done", hide=True).stdout.strip() 308 | files_list = files.replace("\r", "").split("\n") 309 | 310 | return files_list 311 | 312 | 313 | def generate_secret_key(): 314 | """ 315 | Generate a random secret key, suitable to be used as a SECRET_KEY setting. 316 | """ 317 | return "".join( 318 | [ 319 | random.SystemRandom().choice( 320 | "abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)" 321 | ) 322 | for i in range(50) 323 | ] 324 | ) 325 | 326 | 327 | def is_supported_db_engine(engine): 328 | return engine in [ 329 | "django.db.backends.postgresql_psycopg2", 330 | "django.db.backends.postgresql", 331 | "django.contrib.gis.db.backends.postgis", 332 | ] 333 | 334 | 335 | def print_commits(commits): 336 | for rev, message in commits: 337 | print(f"{rev} {message}") 338 | 339 | 340 | def get_outgoing_commits(c): 341 | 342 | with c.conn.cd(c.conn.project_root): 343 | remote_tip = c.conn.git("rev-parse HEAD", hide=True, pty=False).stdout.strip() 344 | commits = subprocess.run( 345 | f"git log --no-color --oneline {remote_tip}..".split(" "), 346 | text=True, 347 | capture_output=True, 348 | cwd=local_project_root, 349 | ).stdout.strip() 350 | outgoing = to_commits_list(commits) 351 | 352 | return outgoing 353 | 354 | 355 | @task 356 | @remote 357 | def outgoing_commits(c): 358 | print("The following commits are not on the remote branch:\n") 359 | print_commits(get_outgoing_commits(c)) 360 | 361 | 362 | def get_local_modifications_count(): 363 | return len( 364 | [ 365 | line 366 | for line in subprocess.run( 367 | "git status -s".split(" "), text=True, capture_output=True 368 | ).stdout.splitlines() 369 | if line.strip() 370 | ] 371 | ) 372 | 373 | 374 | @task 375 | @remote 376 | def log(c): 377 | with c.conn.cd(c.conn.project_root): 378 | commits = c.conn.git( 379 | "log --no-color --oneline -n 20", hide=True, pty=False 380 | ).stdout.strip() 381 | 382 | print_commits(to_commits_list(commits)) 383 | 384 | 385 | def to_commits_list(log_str): 386 | return [tuple(log_line.split(maxsplit=1)) for log_line in log_str.splitlines()] 387 | 388 | 389 | @task 390 | @remote 391 | def fetch_db(c, destination="."): 392 | """ 393 | Dump the database on the remote host and retrieve it locally. 394 | 395 | The destination parameter controls where the dump should be stored locally. 396 | """ 397 | dump_path = c.conn.dump_db("~") 398 | filename = os.path.basename(dump_path) 399 | 400 | subprocess.run( 401 | [ 402 | "scp", 403 | "-P", 404 | str(c.conn.port), 405 | "{user}@{host}:{directory}".format( 406 | user=c.conn.user, host=c.conn.host, directory=dump_path 407 | ), 408 | destination, 409 | ] 410 | ) 411 | c.conn.run("rm %s" % dump_path) 412 | 413 | return os.path.join(destination, filename) 414 | 415 | 416 | @task 417 | @remote 418 | def import_media(c): 419 | """ 420 | Rsync the distant media folder content, to the local media folder (identified by 421 | the MEDIA_ROOT environment variable). 422 | """ 423 | local_media_root = get_local_env_variable("MEDIA_ROOT") 424 | subprocess.run( 425 | [ 426 | "rsync", 427 | "--info=progress2", 428 | "-v", 429 | "-r", 430 | "--delete-before", 431 | "-e", 432 | "ssh -p {port}".format(port=c.conn.port), 433 | "{user}@{host}:{path}".format( 434 | host=c.conn.host, 435 | user=c.conn.user, 436 | path=os.path.join(c.conn.media_root, "*"), 437 | ), 438 | local_media_root, 439 | ] 440 | ) 441 | 442 | 443 | @task 444 | def import_db(c, dump_file=None, with_media=False): 445 | """ 446 | Restore the given database dump. 447 | 448 | The dump must be a gzipped SQL dump. If the dump_file parameter is not set, 449 | the database will be dumped and retrieved from the remote host. 450 | 451 | :param c: The connection wrapper to the server 452 | :param dump_file: When provided, import the dump instead of dumping and fetching. 453 | :param with_media: If `--with-media` argument is provided, import the media content as well. 454 | """ 455 | db_credentials = get_local_env_variable("DATABASE_URL") 456 | db_credentials_dict = dj_database_url.parse(db_credentials) 457 | 458 | if not is_supported_db_engine(db_credentials_dict["ENGINE"]): 459 | raise NotImplementedError( 460 | "The import_db task doesn't support your database engine" 461 | ) 462 | 463 | if dump_file is None: 464 | dump_file = fetch_db(c, local_project_root) 465 | 466 | pg_opts_mapping = { 467 | "-h": db_credentials_dict["HOST"], 468 | "-U": db_credentials_dict["USER"], 469 | } 470 | pg_opts = " ".join( 471 | [f"{option} '{value}'" for option, value in pg_opts_mapping.items() if value] 472 | ) 473 | db_name = db_credentials_dict["NAME"] 474 | db_info = {"pg_opts": pg_opts, "db": db_name} 475 | 476 | env = {"PGPASSWORD": db_credentials_dict["PASSWORD"].replace("$", "\\$")} 477 | close_sessions_command = """ 478 | psql {pg_opts} template1 -c " 479 | SELECT pg_terminate_backend(pg_stat_activity.pid) 480 | FROM pg_stat_activity 481 | WHERE pg_stat_activity.datname = '{db}' AND pid != pg_backend_pid(); 482 | " 483 | """.strip() 484 | c.run(close_sessions_command.format(**db_info), env=env, hide="out") 485 | c.run("dropdb {pg_opts} '{db}'".format(**db_info), env=env) 486 | c.run("createdb {pg_opts} '{db}'".format(**db_info), env=env) 487 | c.run( 488 | "gunzip -c {db_dump}|psql {pg_opts} '{db}'".format( 489 | db_dump=dump_file, **db_info 490 | ), 491 | env=env, 492 | hide="out", 493 | ) 494 | 495 | if with_media: 496 | import_media(c) 497 | 498 | 499 | @remote 500 | def update_or_create_last_master(c): 501 | with c.conn.cd(c.conn.project_root): 502 | c.conn.git("branch -f last_master master", hide=True).stdout.strip() 503 | 504 | 505 | @remote 506 | def _init_last_master(c): 507 | with c.conn.cd(c.conn.project_root): 508 | last_master = c.conn.git("rev-parse --verify last_master", hide=True, warn=True) 509 | if last_master.exited: 510 | update_or_create_last_master(c) 511 | 512 | 513 | @task 514 | @remote 515 | def deploy(c, noconfirm=False): 516 | """ 517 | Execute all deployment steps 518 | """ 519 | 520 | # Prerequisite steps 521 | c.conn.create_structure() 522 | 523 | outgoing_commits(c) 524 | if not noconfirm and input( 525 | "Do you want to proceed with the deployment of the above commits ? [y/N] " 526 | ).lower() not in ("y", "yes"): 527 | return 528 | 529 | local_modification_count = get_local_modifications_count() 530 | if not noconfirm and local_modification_count > 0: 531 | if input( 532 | f"Warning ! There are {local_modification_count} local files that are not commited. " 533 | f"Do you want to proceed ? [y/N] " 534 | ).lower() not in ("y", "yes"): 535 | return 536 | 537 | _init_last_master(c) 538 | compile_assets() 539 | push_code_update(c, "HEAD") 540 | sync_settings(c) 541 | c.conn.dump_db(c.conn.backups_root) 542 | install_requirements(c) 543 | sync_assets(c) 544 | dj_collect_static(c) 545 | dj_migrate_database(c) 546 | reload_uwsgi(c) 547 | c.conn.clean_old_database_backups(nb_backups_to_keep=10) 548 | update_or_create_last_master(c) 549 | 550 | 551 | @task 552 | @remote 553 | def push_code_update(c, git_ref): 554 | """ 555 | Synchronize the remote code repository 556 | """ 557 | git_remote_url = "ssh://{user}@{host}:{port}/{directory}".format( 558 | user=c.conn.user, 559 | host=c.conn.host, 560 | port=c.conn.port, 561 | directory=c.conn.project_root, 562 | ) 563 | 564 | # Now push our code to the remote, always as FABHEAD branch 565 | porcelain.push(local_project_root, git_remote_url, "{}:FABHEAD".format(git_ref)) 566 | 567 | with c.conn.cd(c.conn.project_root): 568 | c.conn.git("checkout -f -B master FABHEAD", hide=True) 569 | c.conn.git("branch -d FABHEAD", hide=True) 570 | c.conn.git("submodule update --init", hide=True) 571 | 572 | 573 | @task 574 | @remote 575 | def install_requirements(c): 576 | """ 577 | Install project requirements in venv 578 | """ 579 | try: 580 | c.conn.run("test -r {}".format(c.conn.venv_path), hide=True) 581 | except UnexpectedExit: 582 | c.conn.mk_venv() 583 | 584 | c.conn.pip("install -r requirements/base.txt") 585 | 586 | 587 | @task 588 | @remote 589 | def sync_settings(c): 590 | """ 591 | Synchronize the settings from the above environment to the server 592 | """ 593 | 594 | required_settings = set( 595 | [ 596 | "DATABASE_URL", 597 | "MEDIA_ROOT", 598 | "STATIC_ROOT", 599 | "MEDIA_URL", 600 | "STATIC_URL", 601 | "ALLOWED_HOSTS", 602 | ] 603 | ) 604 | 605 | env_settings = getattr(c.config, "settings", {}) 606 | for setting, value in env_settings.items(): 607 | c.conn.set_setting(setting, value=value) 608 | 609 | # Ask for settings that are required but were not set in the parameters 610 | # file 611 | for setting in required_settings - set(env_settings.keys()): 612 | c.conn.set_setting(setting, force=False) 613 | 614 | c.conn.set_setting( 615 | "DJANGO_SETTINGS_MODULE", 616 | value="%s.config.settings.base" % project_name, 617 | force=False, 618 | ) 619 | c.conn.set_setting("SECRET_KEY", value=generate_secret_key(), force=False) 620 | 621 | 622 | @task 623 | @remote 624 | def dj_collect_static(c): 625 | """ 626 | Django: collect the statics 627 | """ 628 | c.conn.manage_py("collectstatic --noinput") 629 | 630 | 631 | @task 632 | @remote 633 | def dj_migrate_database(c): 634 | """ 635 | Django: Migrate the database 636 | """ 637 | c.conn.manage_py("migrate") 638 | 639 | 640 | @task 641 | @remote 642 | def reload_uwsgi(c): 643 | """ 644 | Reload uWSGI workers 645 | """ 646 | c.conn.run_in_project_root( 647 | "touch %s" 648 | % os.path.join(c.conn.project_root, project_name, "config", "wsgi.py") 649 | ) 650 | 651 | 652 | def compile_assets(): 653 | subprocess.run(["npm", "install"], cwd=local_project_root) 654 | subprocess.run(["npm", "run", "build"], cwd=local_project_root) 655 | 656 | 657 | @task 658 | @remote 659 | def sync_assets(c): 660 | subprocess.run( 661 | [ 662 | "rsync", 663 | "-r", 664 | "-e", 665 | "ssh -p {port}".format(port=c.conn.port), 666 | "--exclude", 667 | "*.map", 668 | "--exclude", 669 | "*.swp", 670 | os.path.join(local_project_root, "static/dist"), 671 | "{user}@{host}:{path}".format( 672 | host=c.conn.host, 673 | user=c.conn.user, 674 | path=c.conn.static_root, 675 | ), 676 | ] 677 | ) 678 | 679 | 680 | # Environment handling stuff 681 | ############################ 682 | def create_environment_task(name, env_conf): 683 | """ 684 | Create a task function from an environment name 685 | """ 686 | 687 | @task(name=name) 688 | def load_environment(ctx): 689 | conf = env_conf.copy() 690 | conf["environment"] = name 691 | # So now conf is the ENVIRONMENTS[env] dict plus "environment" pointing to the name 692 | # Push them in the context config dict 693 | ctx.config.load_overrides(conf) 694 | # Add the common_settings in there 695 | ctx.conn = CustomConnection(host=conf["host"], inline_ssh_env=True) 696 | ctx.conn.config.load_overrides(conf) 697 | 698 | load_environment.__doc__ = ( 699 | """Prepare connection and load config for %s environment""" % name 700 | ) 701 | return load_environment 702 | 703 | 704 | def load_environments_tasks(environments): 705 | """ 706 | Load environments as fabric tasks 707 | """ 708 | for name, env_conf in environments.items(): 709 | globals()[name] = create_environment_task(name, env_conf) 710 | 711 | 712 | # Yes, do it 713 | load_environments_tasks(ENVIRONMENTS) 714 | --------------------------------------------------------------------------------