├── {{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 |
--------------------------------------------------------------------------------