├── .github ├── dependabot.yml └── workflows │ ├── automerge.yml │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cookiecutter.json ├── hooks └── post_gen_project.sh └── {{ cookiecutter.name }} ├── .dockerignore ├── .editorconfig ├── .github ├── actions │ └── setup-project │ │ └── action.yml └── workflows │ └── ci.yml ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── compose.yml ├── pyproject.toml ├── src ├── .django-app-template │ ├── __init__.py-tpl │ ├── admin.py-tpl │ ├── api │ │ └── v1 │ │ │ ├── __init__.py-tpl │ │ │ ├── serializers │ │ │ └── __init__.py-tpl │ │ │ ├── urls.py-tpl │ │ │ └── views │ │ │ └── __init__.py-tpl │ ├── apps.py-tpl │ ├── factory.py-tpl │ ├── fixtures.py-tpl │ ├── migrations │ │ └── __init__.py-tpl │ ├── models │ │ └── __init__.py-tpl │ └── tests │ │ ├── __init__.py-tpl │ │ └── api │ │ ├── __init__.py-tpl │ │ └── v1 │ │ └── __init__.py-tpl ├── .locale │ └── .gitkeep ├── a12n │ ├── __init__.py │ ├── api │ │ ├── serializers.py │ │ ├── throttling.py │ │ ├── urls.py │ │ └── views.py │ ├── migrations │ │ └── __init__.py │ └── tests │ │ └── jwt_views │ │ ├── conftest.py │ │ ├── tests_logout_jwt_view.py │ │ ├── tests_obtain_jwt_view.py │ │ └── tests_refresh_jwt_view.py ├── app │ ├── .env.ci │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ └── model_admin.py │ ├── api │ │ ├── pagination.py │ │ ├── parsers.py │ │ ├── renderers.py │ │ ├── request.py │ │ ├── throttling.py │ │ └── viewsets.py │ ├── apps.py │ ├── base_config.py │ ├── celery.py │ ├── conf │ │ ├── api.py │ │ ├── auth.py │ │ ├── boilerplate.py │ │ ├── celery.py │ │ ├── db.py │ │ ├── environ.py │ │ ├── healthchecks.py │ │ ├── http.py │ │ ├── i18n.py │ │ ├── installed_apps.py │ │ ├── media.py │ │ ├── middleware.py │ │ ├── sentry.py │ │ ├── static.py │ │ ├── storage.py │ │ ├── templates.py │ │ └── timezone.py │ ├── exceptions.py │ ├── factory.py │ ├── fixtures │ │ ├── __init__.py │ │ ├── api.py │ │ └── factory.py │ ├── management │ │ └── commands │ │ │ ├── makemigrations.py │ │ │ └── startapp.py │ ├── middleware │ │ └── real_ip.py │ ├── models.py │ ├── services.py │ ├── settings.py │ ├── testing │ │ ├── __init__.py │ │ ├── api.py │ │ ├── factory.py │ │ ├── mixer.py │ │ ├── runner.py │ │ └── types.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_health.py │ │ ├── test_remote_addr_midlleware.py │ │ └── testing │ │ │ ├── __init__.py │ │ │ └── factory │ │ │ ├── __init__.py │ │ │ ├── test_factory.py │ │ │ └── test_registry.py │ ├── urls │ │ ├── __init__.py │ │ └── v1.py │ └── wsgi.py ├── conftest.py ├── manage.py └── users │ ├── __init__.py │ ├── admin.py │ ├── api │ ├── serializers.py │ ├── urls.py │ └── viewsets.py │ ├── factory.py │ ├── fixtures.py │ ├── migrations │ └── __init__.py │ ├── models.py │ └── tests │ ├── test_password_hashing.py │ └── test_whoami.py └── uv.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: uv 4 | directory: "/{{ cookiecutter.name }}" 5 | schedule: 6 | interval: daily 7 | time: "02:00" 8 | open-pull-requests-limit: 10 9 | allow: 10 | - dependency-type: direct 11 | - dependency-type: indirect 12 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.actor == 'dependabot[bot]' 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' 20 | run: gh pr merge --auto --squash "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | bootstrap: 12 | runs-on: ubuntu-latest 13 | services: 14 | postgres: 15 | env: 16 | POSTGRES_PASSWORD: secret 17 | image: postgres:16.2-alpine 18 | options: >- 19 | --health-cmd pg_isready 20 | --health-interval 10s 21 | --health-retries 5 22 | --health-timeout 5s 23 | ports: 24 | - 5432:5432 25 | 26 | env: 27 | DATABASE_URL: postgres://postgres:secret@localhost:5432/postgres 28 | steps: 29 | - name: checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: setup uv # let uv handle python setup, like on developers machine 33 | uses: astral-sh/setup-uv@v5 34 | with: 35 | pyproject-file: "{{ cookiecutter.name }}/pyproject.toml" 36 | enable-cache: true 37 | cache-dependency-glob: "{{ cookiecutter.name }}/uv.lock" 38 | 39 | - name: bootstrap 40 | run: | 41 | make bootstrap 42 | 43 | - name: lint the generated project 44 | run: | 45 | cd testproject 46 | make lint 47 | 48 | - name: save the bootstrap result 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: generated 52 | path: testproject 53 | include-hidden-files: true 54 | 55 | build-docker-image: 56 | needs: bootstrap 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: checkout 60 | uses: actions/checkout@v4 61 | 62 | - name: setup uv 63 | uses: astral-sh/setup-uv@v5 64 | with: 65 | pyproject-file: "{{ cookiecutter.name }}/pyproject.toml" 66 | enable-cache: true 67 | cache-dependency-glob: "{{ cookiecutter.name }}/uv.lock" 68 | 69 | - name: get python version # python itself not needed, just to get the latest compatible version with project 70 | uses: actions/setup-python@v5 71 | id: setup-python 72 | with: 73 | python-version-file: "{{ cookiecutter.name }}/pyproject.toml" 74 | 75 | - name: download build artifact 76 | uses: actions/download-artifact@v4 77 | with: 78 | name: generated 79 | path: testproject 80 | 81 | - name: setup qemu 82 | uses: docker/setup-qemu-action@v3 83 | 84 | - name: setup buildx 85 | uses: docker/setup-buildx-action@v3 86 | 87 | - name: make sure docker image is buildable 88 | uses: docker/build-push-action@v6 89 | with: 90 | build-args: | 91 | PYTHON_VERSION=${{ steps.setup-python.outputs.python-version }} 92 | cache-from: type=gha 93 | cache-to: type=gha,mode=max 94 | context: testproject 95 | push: false 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | .idea 3 | .venv 4 | .vscode 5 | 6 | **/.DS_Store 7 | 8 | testproject 9 | venv 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Fedor Borshev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | bootstrap: 2 | rm -rf testproject 3 | 4 | uvx cookiecutter --no-input --keep-project-on-failure ./ 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fands.dev django template 2 | 3 | ![Shields.io](https://img.shields.io/github/last-commit/fandsdev/django?style=flat-square) 4 | 5 | ![Easy peasy](https://user-images.githubusercontent.com/1592663/79918184-93bca100-8434-11ea-9902-0ff726a864a3.gif) 6 | 7 | ## What is in the box 8 | 9 | * API-only django (checkout [this post](https://t.me/pmdaily/257) in Russian) based on Django REST Framework with JWT support. 10 | * [uv](https://docs.astral.sh/uv/) with separate development-time dependencies. 11 | * Strict type checking with mypy, [django-stubs](https://github.com/typeddjango/django-stubs) and [djangorestframework-stubs](https://github.com/typeddjango/djangorestframework-stubs). 12 | * tons of linters and formatters (contact us if any interesting linter is not included, see `Makefile` `fmt`, `lint` commands). 13 | * Starter CI configuration on GitHub Actions. 14 | * `pytest` with useful stuff like `freezegun`, `pytest-mock` and super convenient [DRF test client](https://github.com/fandsdev/django/blob/master/{{ cookiecutter.name }}/src/app/testing/api.py). 15 | * Custom [user model](https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#specifying-a-custom-user-model). 16 | * [drf-spectacular](https://github.com/tfranzel/drf-spectacular) for API Schema generation. 17 | * [django-axes](https://github.com/jazzband/django-axes) for additional security. 18 | * [Whitenoise](http://whitenoise.evans.io) for effortless static files hosting. 19 | * Cloudflare ready with [django-ipware](https://github.com/un33k/django-ipware). 20 | * Sentry. Set `SENTRY_DSN` env var if you need it. 21 | * Postgres ready. 22 | 23 | ## Installation 24 | 25 | You need [uv](https://docs.astral.sh/uv/), version >=0.6.0 (how to install [link](https://docs.astral.sh/uv/getting-started/installation/)). 26 | It will install python 3.12 automatically if you don't have it yet. 27 | 28 | ```bash 29 | uvx cookiecutter gh:fandsdev/django 30 | ``` 31 | 32 | ## FAQ 33 | 34 | ### I wanna hack this! 35 | 36 | Thank you so much! Check out our [build pipeline](https://github.com/fandsdev/django/blob/master/Makefile) and pick any free [issue](https://github.com/fandsdev/django/issues). 37 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Fedor Borshev", 3 | "description": "", 4 | "email": "fedor@borshev.com", 5 | "name": "testproject", 6 | "_copy_without_render": [ 7 | ".github/actions/setup-project/action.yml", 8 | ".github/workflows/ci.yml", 9 | "src/.django-app-template" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /hooks/post_gen_project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Echo colored text function 4 | echo_green() { 5 | local message=$1 6 | 7 | local green="\x1b[32m" 8 | local clear="\x1b[0m" 9 | 10 | echo -e "${green}${message}${clear}" 11 | } 12 | 13 | #### Post generation script #### 14 | 15 | # Set environment variables 16 | cp src/app/.env.ci src/app/.env 17 | 18 | # Install dependencies and fail if dependencies not in sync 19 | # https://docs.astral.sh/uv/concepts/projects/sync/#automatic-lock-and-sync 20 | uv sync --locked 21 | 22 | # Setup Django 23 | uv run python src/manage.py collectstatic 24 | uv run python src/manage.py makemigrations --name "initial" 25 | uv run python src/manage.py migrate 26 | 27 | # Run linters and tests 28 | make lint 29 | make test 30 | 31 | # Echo success message 32 | echo 33 | echo "=============================================" 34 | echo 35 | echo_green "=== Project setup completed successfully! ===" 36 | echo 37 | echo "Current config uses SQLite in-memory DB for fast development." 38 | echo "To switch to PostgreSQL:" 39 | echo " 1. Start PostgreSQL using your preferred method (see 'compose.yml' for a Docker example)" 40 | echo " 2. Set DATABASE_URL in 'src/app/.env' to your connection string" 41 | echo " e.g. DATABASE_URL=postgres://username:password@localhost:5432/dbname" 42 | echo 43 | echo "=============================================" 44 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/.dockerignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .venv/ 3 | static/ 4 | .circleci/ 5 | db.sqlite 6 | .env 7 | __pycache__/ 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | max_line_length = off 5 | 6 | [*.py] 7 | indent_size = 4 8 | 9 | [{Makefile,**.mk}] 10 | indent_style = tab 11 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/.github/actions/setup-project/action.yml: -------------------------------------------------------------------------------- 1 | name: setup-project 2 | description: 'Setup uv, python, install and cache deps, set default env variables' 3 | outputs: 4 | python-version: 5 | description: 'Python version' 6 | value: ${{ steps.setup-python.outputs.python-version }} 7 | 8 | runs: 9 | using: composite 10 | 11 | steps: 12 | - name: setup uv 13 | uses: astral-sh/setup-uv@v5 14 | with: 15 | pyproject-file: "pyproject.toml" 16 | enable-cache: true 17 | cache-dependency-glob: 'uv.lock' 18 | 19 | - name: setup python 20 | uses: actions/setup-python@v5 21 | id: setup-python 22 | with: 23 | python-version-file: pyproject.toml 24 | 25 | - name: install dev dependencies 26 | run: | 27 | make install-dev-deps 28 | shell: bash 29 | 30 | - name: restore default environment 31 | run: cp src/app/.env.ci src/app/.env 32 | shell: bash 33 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: setup project 19 | id: setup-project 20 | uses: ./.github/actions/setup-project 21 | 22 | - name: lint 23 | run: make lint 24 | 25 | test: 26 | needs: lint 27 | runs-on: ubuntu-latest 28 | 29 | services: 30 | postgres: 31 | image: postgres:16.1-alpine 32 | env: 33 | POSTGRES_PASSWORD: secret 34 | options: >- 35 | --health-cmd pg_isready 36 | --health-interval 10s 37 | --health-timeout 5s 38 | --health-retries 5 39 | ports: 40 | - 5432:5432 41 | 42 | redis: 43 | image: redis:7.2.3-alpine 44 | ports: 45 | - 6379:6379 46 | 47 | steps: 48 | - name: checkout 49 | uses: actions/checkout@v4 50 | 51 | - name: setup project 52 | id: setup-project 53 | uses: ./.github/actions/setup-project 54 | 55 | - name: install locale stuff 56 | uses: awalsh128/cache-apt-pkgs-action@v1 57 | with: 58 | packages: locales-all gettext 59 | version: 1 60 | 61 | - name: get number of cpu cores 62 | uses: SimenB/github-actions-cpu-cores@v2.0.0 63 | id: cpu-cores 64 | 65 | - name: test 66 | env: 67 | DATABASE_URL: postgres://postgres:secret@localhost:5432/postgres 68 | run: make test -e SIMULTANEOUS_TEST_JOBS=${{ steps.cpu-cores.outputs.count }} 69 | 70 | build-docker-image: 71 | needs: test 72 | runs-on: ubuntu-latest 73 | 74 | steps: 75 | - name: checkout 76 | uses: actions/checkout@v4 77 | 78 | - name: setup project 79 | id: setup-project 80 | uses: ./.github/actions/setup-project 81 | 82 | - name: setup qemu 83 | uses: docker/setup-qemu-action@v3 84 | 85 | - name: setup buildx 86 | uses: docker/setup-buildx-action@v3 87 | 88 | - name: Generate image identifier 89 | id: image-identifier 90 | uses: ASzc/change-string-case-action@v6 91 | with: 92 | string: ${{ github.repository_owner }} 93 | 94 | - name: Build web backend image 95 | uses: docker/build-push-action@v6 96 | with: 97 | context: . 98 | target: web 99 | push: false 100 | tags: | 101 | ghcr.io/${{ steps.image-identifier.outputs.lowercase }}-backend:latest 102 | ghcr.io/${{ steps.image-identifier.outputs.lowercase }}-backend:${{ github.sha }} 103 | build-args: | 104 | PYTHON_VERSION=${{ steps.setup-project.outputs.python-version }} 105 | RELEASE=${{ github.sha }} 106 | cache-from: type=gha 107 | cache-to: type=gha,mode=max 108 | 109 | - name: Build web worker image 110 | uses: docker/build-push-action@v6 111 | with: 112 | context: . 113 | target: worker 114 | push: false 115 | tags: | 116 | ghcr.io/${{ steps.image-identifier.outputs.lowercase }}-worker:latest 117 | ghcr.io/${{ steps.image-identifier.outputs.lowercase }}-worker:${{ github.sha }} 118 | build-args: | 119 | PYTHON_VERSION=${{ steps.setup-project.outputs.python-version }} 120 | RELEASE=${{ github.sha }} 121 | cache-from: type=gha 122 | cache-to: type=gha,mode=max 123 | 124 | - name: Build web scheduler image 125 | uses: docker/build-push-action@v6 126 | with: 127 | context: . 128 | target: scheduler 129 | push: false 130 | tags: | 131 | ghcr.io/${{ steps.image-identifier.outputs.lowercase }}-scheduler:latest 132 | ghcr.io/${{ steps.image-identifier.outputs.lowercase }}-scheduler:${{ github.sha }} 133 | build-args: | 134 | PYTHON_VERSION=${{ steps.setup-project.outputs.python-version }} 135 | RELEASE=${{ github.sha }} 136 | cache-from: type=gha 137 | cache-to: type=gha,mode=max 138 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python 2 | # Edit at https://www.gitignore.io/?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | **/.DS_Store 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # pipenv 71 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 72 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 73 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 74 | # install all needed dependencies. 75 | #Pipfile.lock 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # Spyder project settings 84 | .spyderproject 85 | .spyproject 86 | 87 | # Rope project settings 88 | .ropeproject 89 | 90 | # Mr Developer 91 | .mr.developer.cfg 92 | .project 93 | .pydevproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | .dmypy.json 101 | dmypy.json 102 | 103 | # Pyre type checker 104 | .pyre/ 105 | 106 | # End of https://www.gitignore.io/api/python 107 | 108 | # IDE stuff 109 | .vscode 110 | .idea 111 | 112 | venv 113 | .venv 114 | static 115 | .env 116 | db.sqlite 117 | /media -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Python version has to be set in the build command 3 | # 4 | ARG PYTHON_VERSION=python-version-not-set 5 | 6 | # 7 | # Compile custom uwsgi, cuz debian's one is weird 8 | # 9 | FROM python:${PYTHON_VERSION}-slim-bookworm AS uwsgi-compile 10 | ENV _UWSGI_VERSION=2.0.29 11 | RUN apt-get update && apt-get --no-install-recommends install -y build-essential wget && rm -rf /var/lib/apt/lists/* 12 | RUN wget -O uwsgi-${_UWSGI_VERSION}.tar.gz https://github.com/unbit/uwsgi/archive/${_UWSGI_VERSION}.tar.gz \ 13 | && tar zxvf uwsgi-*.tar.gz \ 14 | && UWSGI_BIN_NAME=/uwsgi make -C uwsgi-${_UWSGI_VERSION} \ 15 | && rm -Rf uwsgi-* 16 | 17 | # 18 | # Build virtual environment with dependencies 19 | # https://github.com/astral-sh/uv-docker-example/blob/main/multistage.Dockerfile 20 | # 21 | FROM python:${PYTHON_VERSION}-slim-bookworm AS deps-compile 22 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ 23 | ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=0 24 | 25 | WORKDIR /code 26 | 27 | COPY pyproject.toml uv.lock /code/ 28 | RUN --mount=type=cache,target=/root/.cache/uv \ 29 | uv sync --frozen --no-dev 30 | 31 | # 32 | # Base image with django dependencies 33 | # It is important to use the image that matches the deps-compile image 34 | # 35 | FROM python:${PYTHON_VERSION}-slim-bookworm AS base 36 | LABEL maintainer="{{ cookiecutter.email }}" 37 | 38 | ENV DEBIAN_FRONTEND=noninteractive 39 | ENV PYTHONUNBUFFERED=1 40 | ENV STATIC_ROOT=/code/static 41 | # Define user ids to ensure consistent permissions, e.g., for mounted volumes 42 | ENV UID=999 GID=999 43 | 44 | RUN apt-get update \ 45 | && apt-get --no-install-recommends install -y gettext locales-all tzdata git wait-for-it wget \ 46 | && rm -rf /var/lib/apt/lists/* 47 | 48 | RUN groupadd --system --gid=${GID} "web" \ 49 | && useradd --system --uid=${UID} --gid=${GID} --create-home --home-dir "/code" "web" 50 | 51 | COPY --from=uwsgi-compile /uwsgi /usr/local/bin/ 52 | COPY --from=deps-compile --chown=web:web /code/.venv /code/.venv 53 | COPY --chown=web:web src /code/src 54 | ENV PATH="/code/.venv/bin:$PATH" 55 | 56 | WORKDIR /code/src 57 | 58 | USER web 59 | RUN python manage.py compilemessages 60 | RUN python manage.py collectstatic --noinput 61 | 62 | # Also to mark that when CMD is used in shell form, it is a conscious decision 63 | SHELL ["/bin/bash", "-c"] 64 | 65 | 66 | FROM base AS web 67 | HEALTHCHECK --interval=15s --timeout=15s --start-period=15s --retries=3 \ 68 | CMD wget --quiet --tries=1 --spider http://localhost:8000/api/v1/healthchecks/ 69 | 70 | CMD python manage.py migrate \ 71 | && uwsgi \ 72 | --master \ 73 | --http=:8000 \ 74 | --venv=/code/.venv/ \ 75 | --wsgi=app.wsgi \ 76 | --workers=2 \ 77 | --threads=2 \ 78 | --harakiri=25 \ 79 | --max-requests=1000 \ 80 | --log-x-forwarded-for 81 | 82 | FROM base AS worker 83 | 84 | ENV _CELERY_APP=app.celery 85 | HEALTHCHECK --interval=15s --timeout=15s --start-period=5s --retries=3 \ 86 | CMD celery --app=${_CELERY_APP} inspect ping --destination=celery@$HOSTNAME 87 | 88 | CMD celery \ 89 | --app=${_CELERY_APP} \ 90 | worker \ 91 | --concurrency=${CONCURENCY:-2} \ 92 | --hostname="celery@%h" \ 93 | --max-tasks-per-child=${MAX_REQUESTS_PER_CHILD:-50} \ 94 | --time-limit=${TIME_LIMIT:-900} \ 95 | --soft-time-limit=${SOFT_TIME_LIMIT:-45} 96 | 97 | 98 | FROM base AS scheduler 99 | 100 | ENV _SCHEDULER_DB_PATH=/var/db/scheduler 101 | USER root 102 | RUN mkdir --parent ${_SCHEDULER_DB_PATH} && chown web:web ${_SCHEDULER_DB_PATH} 103 | USER web 104 | VOLUME ${_SCHEDULER_DB_PATH} 105 | 106 | ENV _CELERY_APP=app.celery 107 | HEALTHCHECK NONE 108 | CMD celery \ 109 | --app=${_CELERY_APP} \ 110 | beat \ 111 | --pidfile=/tmp/celerybeat.pid \ 112 | --schedule=${_SCHEDULER_DB_PATH}/celerybeat-schedule.db 113 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/Makefile: -------------------------------------------------------------------------------- 1 | SIMULTANEOUS_TEST_JOBS = 4 2 | 3 | .DEFAULT_GOAL := help 4 | 5 | install-dev-deps: 6 | uv sync --locked 7 | 8 | install-deps: 9 | uv sync --locked --no-dev 10 | 11 | fmt: 12 | uv run ruff check src --fix --unsafe-fixes 13 | uv run ruff format src 14 | uv run toml-sort pyproject.toml 15 | 16 | lint: 17 | uv run python src/manage.py check 18 | uv run python src/manage.py makemigrations --check --dry-run --no-input 19 | 20 | uv run ruff check src 21 | uv run ruff format --check src 22 | uv run mypy src 23 | uv run toml-sort --check pyproject.toml 24 | uv run pymarkdown scan README.md 25 | uv run dotenv-linter src/app/.env.ci 26 | 27 | test: 28 | @mkdir -p src/static 29 | uv run pytest --dead-fixtures 30 | uv run pytest --create-db --exitfirst --numprocesses ${SIMULTANEOUS_TEST_JOBS} 31 | 32 | help: 33 | @echo "Makefile for {{ cookiecutter.name }}" 34 | @echo "" 35 | @echo "Usage:" 36 | @echo " make install-dev-deps Install development dependencies" 37 | @echo " make install-deps Install production dependencies" 38 | @echo " make fmt Format code" 39 | @echo " make lint Lint code" 40 | @echo " make test Run tests" 41 | @echo " make help Show this help message" 42 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/README.md: -------------------------------------------------------------------------------- 1 | # Django project 2 | 3 | This project is bootstrapped using [fandsdev/django](http://github.com/fandsdev/django) template. [Drop a line](https://github.com/fandsdev/django/issues) if you have some issues. 4 | 5 | ## Project structure 6 | 7 | The main django app is called `app`. It contains `.env` file for django-environ. For examples see `src/app/.env.ci`. Here are some usefull app-wide tools: 8 | 9 | * `app.admin` — app-wide django-admin customizations (empty yet), check out usage [examples](https://github.com/fandsdev/django/tree/master/%7B%7Bcookiecutter.name%7D%7D/src/app/admin) 10 | * `app.test.api_client` (available as `api` and `anon` fixtures within pytest) — a [convinient DRF test client](https://github.com/fandsdev/django/blob/master/%7B%7Bcookiecutter.name%7D%7D/src/users/tests/tests_whoami.py#L6-L16). 11 | 12 | Django user model is located in the separate `users` app. 13 | 14 | Also, feel free to add as much django apps as you want. 15 | 16 | ## Installing on a local machine 17 | 18 | The project dependencies are managed by [uv (version >0.6.0)](https://docs.astral.sh/uv/), (how to install [link](https://docs.astral.sh/uv/getting-started/installation/)). 19 | Python 3.11 is required (uv will install it automatically). 20 | 21 | Install requirements: 22 | 23 | ```bash 24 | make install-dev-deps # calls `uv sync` internally — it also creates a virtualenv (.venv) and installs python if needed 25 | ``` 26 | 27 | Run the server: 28 | 29 | ```bash 30 | source .venv/bin/activate # or any similar command if using different shell 31 | python src/manage.py migrate 32 | python src/manage.py createsuperuser 33 | python src/manage.py runserver 34 | ``` 35 | 36 | or the same but without activation of the virtualenv: 37 | 38 | ```bash 39 | uv run python src/manage.py migrate 40 | uv run python src/manage.py createsuperuser 41 | uv run python src/manage.py runserver 42 | ``` 43 | 44 | Useful commands 45 | 46 | ```bash 47 | make fmt # run code formatters 48 | 49 | make lint # run code quality checks 50 | 51 | make test # run tests 52 | ``` 53 | 54 | ## Backend code requirements 55 | 56 | ### Style 57 | 58 | * Obey [django's style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/#model-style). 59 | * Configure your IDE to use [ruff](https://pypi.org/project/ruff) for checking your Python code. To run our linters manually, do `make lint`. Feel free to [adjust](https://docs.astral.sh/ruff/configuration/) ruff [rules](https://docs.astral.sh/ruff/rules/) in `pyproject.toml` section `tool.ruff.lint` for your needs. 60 | * Prefer English over your native language in comments and commit messages. 61 | * Commit messages should contain the unique id of issue they are linked to (refs #100500). 62 | * Every model, service and model method should have a docstring. 63 | 64 | ### Code organisation 65 | 66 | * KISS and DRY. 67 | * Obey [django best practices](http://django-best-practices.readthedocs.io/en/latest/index.html). 68 | * **No logic is allowed within the views or serializers**. Only services and models. When a model grows beyond 500 lines of code — go create some services. 69 | * Use PEP-484 [type hints](https://www.python.org/dev/peps/pep-0484/) when possible. 70 | * Prefer composition over inheritance. 71 | * Never use [signals](https://docs.djangoproject.com/en/dev/topics/signals/) or [GenericRelations](https://docs.djangoproject.com/en/dev/ref/contrib/contenttypes/) in your own code. 72 | * No l10n is allowed in python code, use [django translation](https://docs.djangoproject.com/en/dev/topics/i18n/translation/). 73 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | x-default-build-args: &default-build-args 3 | PYTHON_VERSION: "3.12" 4 | 5 | x-default-environment: &default-environment 6 | DATABASE_URL: postgres://postgres@postgres:5432/postgres 7 | CELERY_BROKER_URL: redis://redis:6379/0 8 | 9 | x-default-depends_on: &default-depends_on 10 | - postgres 11 | - redis 12 | 13 | services: 14 | postgres: 15 | image: postgres:16-alpine 16 | environment: 17 | - POSTGRES_HOST_AUTH_METHOD=trust 18 | ports: 19 | - 5432:5432 20 | command: --autovacuum=off --fsync=off --synchronous_commit=off --full_page_writes=off --work_mem=12MB --max-connections=10 --max_wal_senders=0 21 | 22 | redis: 23 | image: redis:6-alpine 24 | ports: 25 | - 6379:6379 26 | 27 | backend: 28 | build: 29 | context: . 30 | target: web 31 | args: *default-build-args 32 | environment: *default-environment 33 | ports: 34 | - 8000:8000 35 | depends_on: *default-depends_on 36 | 37 | worker: 38 | build: 39 | context: . 40 | target: worker 41 | args: *default-build-args 42 | environment: *default-environment 43 | depends_on: *default-depends_on 44 | 45 | scheduler: 46 | build: 47 | context: . 48 | target: scheduler 49 | args: *default-build-args 50 | environment: *default-environment 51 | depends_on: *default-depends_on 52 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "{{ cookiecutter.name }}" 3 | version = "0.1.0" 4 | description = "{{ cookiecutter.description }}" 5 | readme = "README.md" 6 | requires-python = ">=3.12, <3.13" 7 | dependencies = [ 8 | "bcrypt>=4.3.0", 9 | "celery==5.4.0", 10 | "django-axes>=7.0.2", 11 | "django-behaviors>=0.5.1", 12 | "django-environ>=0.12.0", 13 | "django-filter>=25.1", 14 | "django-healthchecks>=1.5.0", 15 | "django-ipware>=7.0.1", 16 | "django-split-settings>=1.3.2", 17 | "django-storages>=1.14.6", 18 | "django>=5.2,<6.0", 19 | "djangorestframework-camel-case>=1.4.2", 20 | "djangorestframework-simplejwt[crypto]>=5.5.0", 21 | "djangorestframework>=3.15.2", 22 | "drf-orjson-renderer>=1.7.3", 23 | "drf-spectacular[sidecar]>=0.28.0", 24 | "pillow>=11.2.1", 25 | "psycopg[binary]>=3.2.6", 26 | "redis>=5.2.1", 27 | "sentry-sdk>=2.27.0", 28 | "whitenoise>=6.9.0", 29 | ] 30 | 31 | [dependency-groups] 32 | dev = [ 33 | "django-stubs>=5.2.0", 34 | "djangorestframework-stubs>=3.16.0", 35 | "dotenv-linter>=0.7.0", 36 | "freezegun>=1.5.1", 37 | "ipython>=9.2.0", 38 | "jedi>=0.19.2", 39 | "mixer>=7.2.2", 40 | "mypy>=1.15.0", 41 | "pymarkdownlnt>=0.9.29", 42 | "pytest-deadfixtures>=2.2.1", 43 | "pytest-django>=4.11.1", 44 | "pytest-env>=1.1.5", 45 | "pytest-freezer>=0.4.9", 46 | "pytest-mock>=3.14.0", 47 | "pytest-randomly>=3.16.0", 48 | "pytest-xdist>=3.6.1", 49 | "ruff>=0.11.7", 50 | "toml-sort>=0.24.2", 51 | "types-freezegun>=1.1.10", 52 | "types-pillow>=10.2.0.20240822", 53 | ] 54 | 55 | [tool.uv] 56 | required-version = ">=0.6.0" 57 | 58 | [tool.pymarkdown.plugins.md013] 59 | enabled = false 60 | 61 | [tool.pymarkdown.plugins.md045] 62 | enabled = false 63 | 64 | [tool.pytest.ini_options] 65 | DJANGO_SETTINGS_MODULE = "app.settings" 66 | addopts = ["--reuse-db"] 67 | env = [ 68 | "AXES_ENABLED = False", 69 | "CELERY_TASK_ALWAYS_EAGER = True", 70 | "CI = 1", 71 | "DISABLE_THROTTLING = True", 72 | ] 73 | # Pattern: `action:message:category:module:line` (https://docs.python.org/3/library/warnings.html#describing-warning-filters) 74 | # Example: `ignore:.*regex of the warning message.*:DeprecationWarning:rest_framework_jwt:` 75 | filterwarnings = [ 76 | ] 77 | pythonpath = ["src"] 78 | testpaths = ["src"] 79 | python_files = ["test*.py"] 80 | 81 | [tool.ruff] 82 | exclude = ["__pycache__", "migrations"] 83 | line-length = 160 84 | src = ["src"] 85 | 86 | [tool.ruff.lint] 87 | ignore = [ 88 | "A001", # variable `{}` is shadowing a Python builtin 89 | "A002", # argument `{}` is shadowing a Python builtin 90 | "A003", # class attribute `{}` is shadowing a Python builtin 91 | "ANN401", # dynamically typed expressions (typing.Any) are disallowed in `{}` 92 | "ARG002", # unused method argument: `{}` 93 | "ARG005", # unused lambda argument: `{}` 94 | "B018", # found useless expression. Either assign it to a variable or remove it 95 | "B904", # within an `except` clause, raise exceptions with [...] 96 | "C408", # unnecessary `dict` call (rewrite as a literal) 97 | "COM812", # trailing comma missing; may not be compatible with ruff formatter 98 | "D100", # missing docstring in public module 99 | "D101", # missing docstring in public class 100 | "D102", # missing docstring in public method 101 | "D103", # missing docstring in public function 102 | "D104", # missing docstring in public package 103 | "D105", # missing docstring in magic method 104 | "D106", # missing docstring in public nested class 105 | "D107", # missing docstring in `__init__` 106 | "D200", # one-line docstring should fit on one line 107 | "D202", # no blank lines allowed after function docstring (found {}) 108 | "D203", # 1 blank line required before class docstring 109 | "D205", # 1 blank line required between summary line and description 110 | "D209", # multi-line docstring closing quotes should be on a separate line 111 | "D210", # no whitespaces allowed surrounding docstring text 112 | "D212", # multi-line docstring summary should start at the first line 113 | "D213", # multi-line docstring summary should start at the second line 114 | "D400", # first line should end with a period 115 | "D401", # first line of docstring should be in imperative mood: "{}" 116 | "D404", # first word of the docstring should not be "This" 117 | "D415", # first line should end with a period, question mark, or exclamation point 118 | "DTZ001", # the use of `datetime.datetime()` without `tzinfo` argument is not allowed 119 | "E501", # line too long ({} > {}) 120 | "EM101", # exception must not use a string literal, assign to variable first 121 | "EM102", # exception must not use an f-string literal, assign to variable first 122 | "FBT001", # boolean-typed position argument in function definition 123 | "FBT002", # boolean default position argument in function definition 124 | "FBT003", # boolean positional value in function call 125 | "INP001", # file `{}` is part of an implicit namespace package. Add an `__init__.py` 126 | "INT001", # f-string is resolved before function call; consider `_("string %s") % arg` 127 | "ISC001", # implicitly concatenated string literals on one line; may not be compatible with ruff formatter 128 | "N802", # function name `{}` should be lowercase 129 | "N803", # argument name `{}` should be lowercase 130 | "N804", # first argument of a class method should be named `cls` 131 | "N806", # variable `{}` in function should be lowercase 132 | "N812", # lowercase `{}` imported as non-lowercase `{}` 133 | "N818", # exception name `{}` should be named with an Error suffix 134 | "N999", # invalid module name: '{}' 135 | "PERF401", # use a list comprehension to create a transformed list 136 | "RET501", # do not explicitly `return None` in function if it is the only possible return value 137 | "RET502", # do not implicitly `return None` in function able to return non-`None` value 138 | "RET503", # missing explicit `return` at the end of function able to return non-`None` value 139 | "RUF012", # mutable class attributes should be annotated with `typing.ClassVar` 140 | "RUF015", # prefer next({iterable}) over single element slice 141 | "S101", # use of `assert` detected 142 | "S311", # standard pseudo-random generators are not suitable for cryptographic purposes 143 | "S324", # probable use of insecure hash functions in `{}`: `{}` 144 | "SIM102", # use a single `if` statement instead of nested `if` statements 145 | "SIM108", # use ternary operator `{}` instead of `if`-`else`-block 146 | "SIM113", # use enumerate instead of manually incrementing a counter 147 | "TC001", # move application import `{}` into a type-checking block 148 | "TC002", # move third-party import `{}` into a type-checking block 149 | "TC003", # move standard library import `{}` into a type-checking block 150 | "TRY003", # avoid specifying long messages outside the exception class 151 | "TRY300", # consider moving this statement to an `else` block 152 | ] 153 | select = ["ALL"] 154 | 155 | [tool.ruff.lint.flake8-tidy-imports] 156 | ban-relative-imports = "all" 157 | 158 | [tool.ruff.lint.isort] 159 | combine-as-imports = true 160 | known-first-party = ["src"] 161 | lines-after-imports = 2 162 | 163 | [tool.ruff.lint.per-file-ignores] 164 | "*/factory.py" = [ 165 | "ANN", # flake8-annotations 166 | "ARG001", 167 | ] 168 | "*/fixtures.py" = [ 169 | "ANN", # flake8-annotations 170 | "ARG001", 171 | ] 172 | "*/management/*" = [ 173 | "ANN", # flake8-annotations 174 | ] 175 | "*/migrations/*" = [ 176 | "ANN", # flake8-annotations 177 | ] 178 | "*/tests/*" = [ 179 | "ANN", # flake8-annotations 180 | "ARG001", 181 | "PLR2004", 182 | ] 183 | "src/app/conf/*" = [ 184 | "ANN", # flake8-annotations 185 | ] 186 | "src/app/testing/*" = [ 187 | "ANN", # flake8-annotations 188 | "ARG001", 189 | "PLR2004", 190 | ] 191 | 192 | [tool.mypy] 193 | python_version = "3.12" 194 | mypy_path = "src" 195 | files = "src" 196 | namespace_packages = true 197 | explicit_package_bases = true 198 | warn_no_return = false 199 | warn_unused_configs = true 200 | warn_unused_ignores = true 201 | warn_redundant_casts = true 202 | no_implicit_optional = true 203 | no_implicit_reexport = true 204 | strict_equality = true 205 | warn_unreachable = true 206 | disallow_untyped_calls = true 207 | disallow_untyped_defs = true 208 | exclude = "migrations/" 209 | plugins = [ 210 | "mypy_django_plugin.main", 211 | "mypy_drf_plugin.main", 212 | ] 213 | 214 | [[tool.mypy.overrides]] 215 | module = [ 216 | "*.management.*", 217 | "*.tests.*", 218 | "app.testing.api.*", 219 | ] 220 | disallow_untyped_defs = false 221 | 222 | [[tool.mypy.overrides]] 223 | module = [ 224 | "axes.*", 225 | "celery.*", 226 | "django_filters.*", 227 | "djangorestframework_camel_case.*", 228 | "drf_orjson_renderer.*", 229 | "ipware.*", 230 | "mixer.*", 231 | ] 232 | ignore_missing_imports = true 233 | 234 | [tool.django-stubs] 235 | django_settings_module = "app.settings" 236 | strict_settings = false 237 | 238 | [tool.tomlsort] 239 | in_place = true 240 | no_sort_tables = true # preserves the manual order of tables (like [project], [dependency-groups], etc.) 241 | sort_inline_tables = true 242 | sort_inline_arrays = true 243 | spaces_before_inline_comment = 2 244 | spaces_indent_inline_array = 4 245 | trailing_comma_inline_array = true 246 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/__init__.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/__init__.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/admin.py-tpl: -------------------------------------------------------------------------------- 1 | from app.admin import ModelAdmin, admin # noqa: F401 2 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/api/v1/__init__.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/api/v1/__init__.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/api/v1/serializers/__init__.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/api/v1/serializers/__init__.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/api/v1/urls.py-tpl: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from rest_framework.routers import SimpleRouter 3 | 4 | 5 | router = SimpleRouter() 6 | 7 | urlpatterns = [ 8 | path("", include(router.urls)), 9 | ] 10 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/api/v1/views/__init__.py-tpl: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "", 3 | ] 4 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/apps.py-tpl: -------------------------------------------------------------------------------- 1 | from app.base_config import AppConfig 2 | 3 | 4 | class {{ camel_case_app_name }}Config(AppConfig): 5 | name = "{{ app_name }}" 6 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/factory.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/factory.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/fixtures.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/fixtures.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/migrations/__init__.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/migrations/__init__.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/models/__init__.py-tpl: -------------------------------------------------------------------------------- 1 | from app.models import DefaultModel, TimestampedModel, models 2 | 3 | 4 | __all__ = [ 5 | "DefaultModel", 6 | "TimestampedModel", 7 | "models", 8 | ] 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/tests/__init__.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/tests/__init__.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/tests/api/__init__.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/tests/api/__init__.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.django-app-template/tests/api/v1/__init__.py-tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.django-app-template/tests/api/v1/__init__.py-tpl -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/.locale/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/.locale/.gitkeep -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/a12n/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/api/serializers.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from rest_framework_simplejwt.serializers import TokenObtainPairSerializer 3 | 4 | 5 | class TokenObtainPairWithProperMessageSerializer(TokenObtainPairSerializer): 6 | default_error_messages = {"no_active_account": _("Invalid username or password.")} 7 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/api/throttling.py: -------------------------------------------------------------------------------- 1 | from rest_framework.throttling import AnonRateThrottle 2 | 3 | from app.api.throttling import ConfigurableThrottlingMixin 4 | 5 | 6 | class AuthAnonRateThrottle(ConfigurableThrottlingMixin, AnonRateThrottle): 7 | """Throttle for any authorization views.""" 8 | 9 | scope = "anon-auth" 10 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from rest_framework_simplejwt import views as jwt 3 | 4 | from a12n.api import views 5 | 6 | 7 | app_name = "a12n" 8 | 9 | urlpatterns = [ 10 | path("token/", views.TokenObtainPairView.as_view(), name="auth_obtain_pair"), 11 | path("token/refresh/", views.TokenRefreshView.as_view(), name="auth_refresh"), 12 | path("logout/", jwt.TokenBlacklistView.as_view(), name="auth_logout"), 13 | ] 14 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework_simplejwt import views as jwt 2 | 3 | from a12n.api.throttling import AuthAnonRateThrottle 4 | 5 | 6 | class TokenObtainPairView(jwt.TokenObtainPairView): 7 | throttle_classes = [AuthAnonRateThrottle] 8 | 9 | 10 | class TokenRefreshView(jwt.TokenRefreshView): 11 | throttle_classes = [AuthAnonRateThrottle] 12 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/a12n/migrations/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/tests/jwt_views/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework_simplejwt.tokens import RefreshToken 3 | 4 | 5 | @pytest.fixture 6 | def user(factory): 7 | user = factory.user(username="jwt-tester-user") 8 | user.set_password("sn00pd0g") 9 | user.save() 10 | 11 | return user 12 | 13 | 14 | @pytest.fixture 15 | def initial_token_pair(user): 16 | refresh = RefreshToken.for_user(user) 17 | return {"refresh": str(refresh), "access": str(refresh.access_token)} 18 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/tests/jwt_views/tests_logout_jwt_view.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken 3 | 4 | 5 | pytestmark = [ 6 | pytest.mark.django_db, 7 | pytest.mark.usefixtures("user"), 8 | ] 9 | 10 | 11 | @pytest.fixture 12 | def logout(as_anon): 13 | def _logout(token, expected_status=200): 14 | return as_anon.post( 15 | "/api/v1/auth/logout/", 16 | { 17 | "refresh": token, 18 | }, 19 | expected_status=expected_status, 20 | ) 21 | 22 | return _logout 23 | 24 | 25 | def test_logout_token_saved_to_blacklist(logout, initial_token_pair): 26 | logout(initial_token_pair["refresh"]) 27 | 28 | assert BlacklistedToken.objects.get(token__token=initial_token_pair["refresh"]) 29 | 30 | 31 | def test_logout_refresh_token_impossible_to_reuse(initial_token_pair, logout, as_anon): 32 | logout(initial_token_pair["refresh"]) 33 | 34 | result = as_anon.post( 35 | path="/api/v1/auth/token/refresh/", 36 | data={"refresh": initial_token_pair["refresh"]}, 37 | expected_status=401, 38 | ) 39 | 40 | assert "blacklisted" in result["detail"] 41 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/tests/jwt_views/tests_obtain_jwt_view.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from axes.models import AccessAttempt 3 | from freezegun import freeze_time 4 | 5 | 6 | pytestmark = [ 7 | pytest.mark.django_db, 8 | pytest.mark.usefixtures("user"), 9 | ] 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def _enable_django_axes(settings): 14 | settings.AXES_ENABLED = True 15 | 16 | 17 | @pytest.fixture 18 | def get_tokens(as_anon): 19 | def _get_tokens(username, password, expected_status=200): 20 | return as_anon.post( 21 | "/api/v1/auth/token/", 22 | { 23 | "username": username, 24 | "password": password, 25 | }, 26 | expected_status=expected_status, 27 | ) 28 | 29 | return _get_tokens 30 | 31 | 32 | def test_get_token_pair(get_tokens): 33 | result = get_tokens("jwt-tester-user", "sn00pd0g") 34 | 35 | assert len(result["access"]) > 40 36 | assert len(result["refresh"]) > 40 37 | 38 | 39 | def test_error_if_incorrect_password(get_tokens): 40 | result = get_tokens("jwt-tester-user", "50cent", expected_status=401) 41 | 42 | assert "Invalid username or password" in result["detail"] 43 | 44 | 45 | def test_error_if_user_is_not_active(get_tokens, user): 46 | user.is_active = False 47 | user.save() 48 | 49 | result = get_tokens("jwt-tester-user", "sn00pd0g", expected_status=401) 50 | 51 | assert "Invalid username or password" in result["detail"] 52 | 53 | 54 | def test_getting_token_with_incorrect_password_creates_access_attempt_log_entry(get_tokens): 55 | get_tokens("jwt-tester-user", "50cent", expected_status=401) 56 | 57 | assert AccessAttempt.objects.count() == 1 58 | 59 | 60 | def test_access_token_gives_access_to_correct_user(get_tokens, as_anon, user): 61 | access_token = get_tokens("jwt-tester-user", "sn00pd0g")["access"] 62 | 63 | as_anon.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") 64 | result = as_anon.get("/api/v1/users/me/") 65 | 66 | assert result["id"] == user.id 67 | 68 | 69 | @pytest.mark.freeze_time("2049-01-05 10:00:00Z") 70 | def test_token_is_not_allowed_to_access_if_expired(as_anon, get_tokens): 71 | access_token = get_tokens("jwt-tester-user", "sn00pd0g")["access"] 72 | 73 | with freeze_time("2049-01-05 10:15:01Z"): 74 | as_anon.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") 75 | result = as_anon.get("/api/v1/users/me/", expected_status=401) 76 | 77 | assert "not valid" in result["detail"] 78 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/a12n/tests/jwt_views/tests_refresh_jwt_view.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from freezegun import freeze_time 3 | 4 | 5 | pytestmark = [ 6 | pytest.mark.django_db, 7 | pytest.mark.usefixtures("user"), 8 | ] 9 | 10 | 11 | @pytest.fixture 12 | def refresh_tokens(as_anon): 13 | def _refresh_tokens(token, expected_status=200): 14 | return as_anon.post( 15 | "/api/v1/auth/token/refresh/", 16 | { 17 | "refresh": token, 18 | }, 19 | expected_status=expected_status, 20 | ) 21 | 22 | return _refresh_tokens 23 | 24 | 25 | def test_refresh_token_endpoint_token_pair(initial_token_pair, refresh_tokens): 26 | refreshed_token_pair = refresh_tokens(initial_token_pair["refresh"]) 27 | 28 | assert len(refreshed_token_pair["access"]) > 40 29 | assert len(refreshed_token_pair["refresh"]) > 40 30 | 31 | 32 | def test_refresh_tokens_are_new(initial_token_pair, refresh_tokens): 33 | refreshed_token_pair = refresh_tokens(initial_token_pair["refresh"]) 34 | 35 | assert initial_token_pair["access"] != refreshed_token_pair["access"] 36 | assert initial_token_pair["refresh"] != refreshed_token_pair["refresh"] 37 | 38 | 39 | def test_refreshed_access_token_works_as_expected(initial_token_pair, refresh_tokens, user, as_anon): 40 | refreshed_access_token = refresh_tokens(initial_token_pair["refresh"])["access"] 41 | 42 | as_anon.credentials(HTTP_AUTHORIZATION=f"Bearer {refreshed_access_token}") 43 | result = as_anon.get("/api/v1/users/me/") 44 | 45 | assert result["id"] == user.id 46 | 47 | 48 | def test_refreshed_refresh_token_is_also_good(initial_token_pair, refresh_tokens, user, as_anon): 49 | refreshed_refresh_token = refresh_tokens(initial_token_pair["refresh"])["refresh"] 50 | last_refreshed_access_token = refresh_tokens(refreshed_refresh_token)["access"] 51 | 52 | as_anon.credentials(HTTP_AUTHORIZATION=f"Bearer {last_refreshed_access_token}") 53 | result = as_anon.get("/api/v1/users/me/") 54 | 55 | assert result["id"] == user.id 56 | 57 | 58 | def test_refresh_token_fails_if_user_is_not_active(refresh_tokens, initial_token_pair, user): 59 | user.is_active = False 60 | user.save() 61 | 62 | result = refresh_tokens(initial_token_pair["refresh"], expected_status=401) 63 | 64 | assert "No active account found" in result["detail"] 65 | 66 | 67 | def test_refresh_token_fails_with_incorrect_previous_token(refresh_tokens): 68 | result = refresh_tokens("some-invalid-previous-token", expected_status=401) 69 | 70 | assert "Token is invalid" in result["detail"] 71 | 72 | 73 | @pytest.mark.freeze_time("2049-01-01 10:00:00Z") 74 | def test_token_is_not_allowed_to_refresh_if_expired(initial_token_pair, refresh_tokens): 75 | with freeze_time("2049-01-22 10:00:01Z"): # 21 days and 1 second later 76 | result = refresh_tokens(initial_token_pair["refresh"], expected_status=401) 77 | 78 | assert "expired" in result["detail"] 79 | 80 | 81 | def test_token_is_not_allowed_to_refresh_twice(initial_token_pair, refresh_tokens): 82 | refresh_tokens(initial_token_pair["refresh"]) 83 | 84 | result = refresh_tokens(initial_token_pair["refresh"], expected_status=401) 85 | 86 | assert "blacklisted" in result["detail"] 87 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/.env.ci: -------------------------------------------------------------------------------- 1 | DATABASE_URL=sqlite:///:memory: 2 | DEBUG=off 3 | SECRET_KEY={{ random_ascii_string(48, punctuation=True) }} 4 | 5 | CELERY_BROKER_URL=redis://localhost:6379/0 6 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/app/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from app.admin.model_admin import ModelAdmin 4 | 5 | 6 | __all__ = [ 7 | "ModelAdmin", 8 | "admin", 9 | ] 10 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/admin/model_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | 4 | class ModelAdmin(admin.ModelAdmin): 5 | """Future app-wide admin customizations""" 6 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/api/pagination.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.pagination import PageNumberPagination 3 | 4 | 5 | class AppPagination(PageNumberPagination): 6 | page_size_query_param = "page_size" 7 | max_page_size = settings.MAX_PAGE_SIZE 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/api/parsers.py: -------------------------------------------------------------------------------- 1 | from typing import IO, Any 2 | 3 | from djangorestframework_camel_case.settings import api_settings 4 | from djangorestframework_camel_case.util import underscoreize 5 | from drf_orjson_renderer.parsers import ORJSONParser 6 | from rest_framework.exceptions import ParseError 7 | 8 | 9 | class AppJSONParser(ORJSONParser): 10 | """Combination of ORJSONParser and CamelCaseJSONParser""" 11 | 12 | # djangorestframework_camel_case parameter 13 | # details: https://github.com/vbabiy/djangorestframework-camel-case?tab=readme-ov-file#underscoreize-options 14 | json_underscoreize = api_settings.JSON_UNDERSCOREIZE 15 | 16 | def parse(self, stream: IO[Any], media_type: Any = None, parser_context: Any = None) -> Any: 17 | try: 18 | data = super().parse(stream, media_type, parser_context) 19 | return underscoreize(data, **self.json_underscoreize) 20 | except ValueError as exc: 21 | raise ParseError(f"JSON parse error - {exc}") 22 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/api/renderers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from djangorestframework_camel_case.util import camelize 4 | from drf_orjson_renderer.renderers import ORJSONRenderer 5 | 6 | 7 | class AppJSONRenderer(ORJSONRenderer): 8 | """Combination of CamelCaseJSONRenderer and ORJSONRenderer""" 9 | 10 | charset = "utf-8" # force DRF to add charset header to the content-type 11 | json_underscoreize = {"no_underscore_before_number": True} # https://github.com/vbabiy/djangorestframework-camel-case#underscoreize-options 12 | 13 | def render(self, data: Any, *args: Any, **kwargs: Any) -> bytes: 14 | return super().render(camelize(data, **self.json_underscoreize), *args, **kwargs) 15 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/api/request.py: -------------------------------------------------------------------------------- 1 | from rest_framework.request import Request 2 | 3 | from users.models import User 4 | 5 | 6 | class AuthenticatedRequest(Request): 7 | user: User 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/api/throttling.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from django.conf import settings 4 | from rest_framework.request import Request 5 | from rest_framework.views import APIView 6 | 7 | 8 | class BaseThrottle(Protocol): 9 | def allow_request(self, request: Request, view: APIView) -> bool: ... 10 | 11 | 12 | class ConfigurableThrottlingMixin: 13 | def allow_request(self: BaseThrottle, request: Request, view: APIView) -> bool: 14 | if settings.DISABLE_THROTTLING: 15 | return True 16 | 17 | return super().allow_request(request, view) # type: ignore[safe-super] 18 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/api/viewsets.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Protocol 2 | 3 | from rest_framework import mixins, status 4 | from rest_framework.mixins import CreateModelMixin, UpdateModelMixin 5 | from rest_framework.request import Request 6 | from rest_framework.response import Response 7 | from rest_framework.serializers import BaseSerializer 8 | from rest_framework.viewsets import GenericViewSet 9 | 10 | 11 | __all__ = ["DefaultModelViewSet"] 12 | 13 | 14 | class BaseGenericViewSet(Protocol): 15 | def get_serializer(self, *args: Any, **kwargs: Any) -> Any: ... 16 | 17 | def get_response(self, *args: Any, **kwargs: Any) -> Any: ... 18 | 19 | def perform_create(self, *args: Any, **kwargs: Any) -> Any: ... 20 | 21 | def perform_update(self, *args: Any, **kwargs: Any) -> Any: ... 22 | 23 | def get_success_headers(self, *args: Any, **kwargs: Any) -> Any: ... 24 | 25 | def get_serializer_class(self, *args: Any, **kwargs: Any) -> Any: ... 26 | 27 | def get_object(self, *args: Any, **kwargs: Any) -> Any: ... 28 | 29 | 30 | class DefaultCreateModelMixin(CreateModelMixin): 31 | """Return detail-serialized created instance""" 32 | 33 | def create(self: BaseGenericViewSet, request: Request, *args: Any, **kwargs: Any) -> Response: 34 | serializer = self.get_serializer(data=request.data) 35 | serializer.is_valid(raise_exception=True) 36 | instance = self.perform_create(serializer) # No getting created instance in original DRF 37 | headers = self.get_success_headers(serializer.data) 38 | return self.get_response(instance, status.HTTP_201_CREATED, headers) 39 | 40 | def perform_create(self: BaseGenericViewSet, serializer: Any) -> Any: 41 | return serializer.save() # No returning created instance in original DRF 42 | 43 | 44 | class DefaultUpdateModelMixin(UpdateModelMixin): 45 | """Return detail-serialized updated instance""" 46 | 47 | def update(self: BaseGenericViewSet, request: Request, *args: Any, **kwargs: Any) -> Response: 48 | partial = kwargs.pop("partial", False) 49 | instance = self.get_object() 50 | serializer = self.get_serializer(instance, data=request.data, partial=partial) 51 | serializer.is_valid(raise_exception=True) 52 | instance = self.perform_update(serializer) # No getting updated instance in original DRF 53 | 54 | if getattr(instance, "_prefetched_objects_cache", None): 55 | # If 'prefetch_related' has been applied to a queryset, we need to 56 | # forcibly invalidate the prefetch cache on the instance. 57 | instance._prefetched_objects_cache = {} # noqa: SLF001 58 | 59 | return self.get_response(instance, status.HTTP_200_OK) 60 | 61 | def perform_update(self: BaseGenericViewSet, serializer: Any) -> Any: 62 | return serializer.save() # No returning updated instance in original DRF 63 | 64 | 65 | class ResponseWithRetrieveSerializerMixin: 66 | """ 67 | Always response with 'retrieve' serializer or fallback to `serializer_class`. 68 | Usage: 69 | 70 | class MyViewSet(DefaultModelViewSet): 71 | serializer_class = MyDefaultSerializer 72 | serializer_action_classes = { 73 | 'list': MyListSerializer, 74 | 'my_action': MyActionSerializer, 75 | } 76 | @action 77 | def my_action: 78 | ... 79 | 80 | 'my_action' request will be validated with MyActionSerializer, 81 | but response will be serialized with MyDefaultSerializer 82 | (or 'retrieve' if provided). 83 | 84 | Thanks gonz: http://stackoverflow.com/a/22922156/11440 85 | 86 | """ 87 | 88 | def get_response( 89 | self: BaseGenericViewSet, 90 | instance: Any, 91 | status: Any, 92 | headers: Any = None, 93 | ) -> Response: 94 | retrieve_serializer_class = self.get_serializer_class(action="retrieve") 95 | context = self.get_serializer_context() # type: ignore[attr-defined] 96 | retrieve_serializer = retrieve_serializer_class(instance, context=context) 97 | return Response( 98 | retrieve_serializer.data, 99 | status=status, 100 | headers=headers, 101 | ) 102 | 103 | def get_serializer_class( 104 | self: BaseGenericViewSet, 105 | action: str | None = None, 106 | ) -> type[BaseSerializer]: 107 | if action is None: 108 | action = self.action # type: ignore[attr-defined] 109 | 110 | try: 111 | return self.serializer_action_classes[action] # type: ignore[attr-defined] 112 | except (KeyError, AttributeError): 113 | return super().get_serializer_class() # type: ignore[safe-super] 114 | 115 | 116 | class DefaultModelViewSet( 117 | DefaultCreateModelMixin, # Create response is overriden 118 | mixins.RetrieveModelMixin, 119 | DefaultUpdateModelMixin, # Update response is overriden 120 | mixins.DestroyModelMixin, 121 | mixins.ListModelMixin, 122 | ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer 123 | GenericViewSet, 124 | ): 125 | pass 126 | 127 | 128 | class ReadonlyModelViewSet( 129 | mixins.RetrieveModelMixin, 130 | mixins.ListModelMixin, 131 | ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer 132 | GenericViewSet, 133 | ): 134 | pass 135 | 136 | 137 | class ListOnlyModelViewSet( 138 | mixins.ListModelMixin, 139 | ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer 140 | GenericViewSet, 141 | ): 142 | pass 143 | 144 | 145 | class UpdateOnlyModelViewSet( 146 | DefaultUpdateModelMixin, 147 | ResponseWithRetrieveSerializerMixin, 148 | GenericViewSet, 149 | ): 150 | pass 151 | 152 | 153 | class DefaultRetrieveDestroyListViewSet( 154 | mixins.RetrieveModelMixin, 155 | mixins.DestroyModelMixin, 156 | mixins.ListModelMixin, 157 | ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer 158 | GenericViewSet, 159 | ): 160 | pass 161 | 162 | 163 | class ListUpdateModelViewSet( 164 | DefaultUpdateModelMixin, 165 | mixins.ListModelMixin, 166 | ResponseWithRetrieveSerializerMixin, 167 | GenericViewSet, 168 | ): 169 | pass 170 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig as DjangoAppConfig 2 | 3 | 4 | class AppConfig(DjangoAppConfig): 5 | name = "app" 6 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/base_config.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import importlib 3 | 4 | from django.apps.config import AppConfig as BaseAppConfig 5 | 6 | 7 | class AppConfig(BaseAppConfig): 8 | """Default App configuration template. Import app.signals.handers. 9 | 10 | We do not recomend you to use django signals at all. 11 | Check out https://lincolnloop.com/blog/django-anti-patterns-signals/ if 12 | you know nothing about that. 13 | 14 | Allthough, if you wish to use signals, place handlers to the `signals/handlers.py`: 15 | your code be automatically imported and used. 16 | """ 17 | 18 | def ready(self) -> None: 19 | """Import a module with handlers if it exists to avoid boilerplate code.""" 20 | with contextlib.suppress(ModuleNotFoundError): 21 | importlib.import_module(".signals.handlers", self.module.__name__) # type: ignore[union-attr] 22 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | from django.conf import settings 5 | 6 | 7 | __all__ = ["celery"] 8 | 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") 10 | 11 | celery = Celery("app") 12 | celery.config_from_object("django.conf:settings", namespace="CELERY") 13 | celery.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 14 | 15 | celery.conf.beat_schedule = {} 16 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/api.py: -------------------------------------------------------------------------------- 1 | from app.conf.environ import env 2 | 3 | 4 | # Django REST Framework 5 | # https://www.django-rest-framework.org/api-guide/settings/ 6 | 7 | DISABLE_THROTTLING = env("DISABLE_THROTTLING", cast=bool, default=False) 8 | MAX_PAGE_SIZE = env("MAX_PAGE_SIZE", cast=int, default=1000) 9 | 10 | REST_FRAMEWORK = { 11 | "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), 12 | "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticatedOrReadOnly",), 13 | "DEFAULT_AUTHENTICATION_CLASSES": [ 14 | "rest_framework_simplejwt.authentication.JWTAuthentication", 15 | "rest_framework.authentication.TokenAuthentication", 16 | ], 17 | "DEFAULT_RENDERER_CLASSES": [ 18 | "app.api.renderers.AppJSONRenderer", 19 | ], 20 | "DEFAULT_PARSER_CLASSES": [ 21 | "app.api.parsers.AppJSONParser", 22 | "djangorestframework_camel_case.parser.CamelCaseMultiPartParser", 23 | "djangorestframework_camel_case.parser.CamelCaseFormParser", 24 | ], 25 | "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", 26 | "DEFAULT_PAGINATION_CLASS": "app.api.pagination.AppPagination", 27 | "PAGE_SIZE": env("PAGE_SIZE", cast=int, default=20), 28 | "DEFAULT_THROTTLE_RATES": { 29 | "anon-auth": "10/min", 30 | }, 31 | "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", 32 | "EXCEPTION_HANDLER": "app.exceptions.app_service_exception_handler", 33 | } 34 | 35 | # Adding session auth and browsable API at the developer machine 36 | if env("DEBUG", cast=bool, default=False): 37 | REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].append("rest_framework.authentication.SessionAuthentication") 38 | REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"].append("djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer") 39 | 40 | 41 | # Set up drf_spectacular, https://drf-spectacular.readthedocs.io/en/latest/settings.html 42 | SPECTACULAR_SETTINGS = { 43 | "TITLE": "Our fancy API", 44 | "DESCRIPTION": "So great, needs no docs", 45 | "SWAGGER_UI_DIST": "SIDECAR", 46 | "SWAGGER_UI_FAVICON_HREF": "SIDECAR", 47 | "REDOC_DIST": "SIDECAR", 48 | "CAMELIZE_NAMES": True, 49 | "POSTPROCESSING_HOOKS": [ 50 | "drf_spectacular.hooks.postprocess_schema_enums", 51 | "drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields", 52 | ], 53 | } 54 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from app.conf.environ import env 4 | 5 | 6 | AUTH_USER_MODEL = "users.User" 7 | AXES_ENABLED = env("AXES_ENABLED", cast=bool, default=True) 8 | 9 | AUTHENTICATION_BACKENDS = [ 10 | "axes.backends.AxesBackend", 11 | "django.contrib.auth.backends.ModelBackend", 12 | ] 13 | 14 | SIMPLE_JWT = { 15 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), 16 | "REFRESH_TOKEN_LIFETIME": timedelta(days=21), 17 | "AUTH_HEADER_TYPES": ("Bearer",), 18 | "ROTATE_REFRESH_TOKENS": True, 19 | "BLACKLIST_AFTER_ROTATION": True, 20 | "TOKEN_OBTAIN_SERIALIZER": "a12n.api.serializers.TokenObtainPairWithProperMessageSerializer", 21 | } 22 | 23 | 24 | # 25 | # Security notice: we use plain bcrypt to store passwords. 26 | # 27 | # We avoid django default pre-hashing algorithm 28 | # from contrib.auth.hashers.BCryptSHA256PasswordHasher. 29 | # 30 | # The reason is compatibility with other hashing libraries, like 31 | # Ruby Devise or Laravel default hashing algorithm. 32 | # 33 | # This means we can't store passwords longer then 72 symbols. 34 | # 35 | 36 | PASSWORD_HASHERS = [ 37 | "django.contrib.auth.hashers.BCryptPasswordHasher", 38 | ] 39 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/boilerplate.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | BASE_DIR = Path(__file__).resolve().parent.parent 5 | 6 | ROOT_URLCONF = "app.urls" 7 | 8 | # Disable built-in ./manage.py test command in favor of pytest 9 | TEST_RUNNER = "app.test.disable_test_command_runner.DisableTestCommandRunner" 10 | 11 | WSGI_APPLICATION = "app.wsgi.application" 12 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/celery.py: -------------------------------------------------------------------------------- 1 | from app.conf.environ import env 2 | from app.conf.timezone import TIME_ZONE 3 | 4 | 5 | CELERY_BROKER_URL = env("CELERY_BROKER_URL", cast=str, default="redis://localhost:6379/0") 6 | CELERY_TASK_ALWAYS_EAGER = env("CELERY_TASK_ALWAYS_EAGER", cast=bool, default=env("DEBUG")) 7 | CELERY_TIMEZONE = TIME_ZONE 8 | CELERY_ENABLE_UTC = False 9 | CELERY_TASK_ACKS_LATE = True 10 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/db.py: -------------------------------------------------------------------------------- 1 | # Database 2 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 3 | 4 | from app.conf.environ import env 5 | 6 | 7 | DATABASES = { 8 | # read os.environ["DATABASE_URL"] and raises ImproperlyConfigured exception if not found 9 | "default": env.db(), 10 | } 11 | 12 | # https://docs.djangoproject.com/en/3.2/releases/3.2/#customizing-type-of-auto-created-primary-keys 13 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 14 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/environ.py: -------------------------------------------------------------------------------- 1 | import environ # type: ignore[import-untyped] 2 | 3 | from app.conf.boilerplate import BASE_DIR 4 | 5 | 6 | env = environ.Env( 7 | DEBUG=(bool, False), 8 | CI=(bool, False), 9 | ) 10 | 11 | envpath = BASE_DIR / ".env" 12 | 13 | if envpath.exists(): 14 | env.read_env(envpath) 15 | 16 | __all__ = [ 17 | "env", 18 | ] 19 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/healthchecks.py: -------------------------------------------------------------------------------- 1 | # Django Healthchecks 2 | # http://django-healthchecks.readthedocs.io 3 | 4 | HEALTH_CHECKS_ERROR_CODE = 503 5 | HEALTH_CHECKS = { 6 | "db": "django_healthchecks.contrib.check_database", 7 | } 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/http.py: -------------------------------------------------------------------------------- 1 | from app.conf.environ import env 2 | 3 | 4 | ALLOWED_HOSTS = ["*"] # Wildcard disables Host header validation, so pls do NOT rely on the Host header in your code with this setting enabled. 5 | CSRF_TRUSTED_ORIGINS = [ 6 | "http://your.app.origin", 7 | ] 8 | 9 | if env("DEBUG"): 10 | ABSOLUTE_HOST = "http://localhost:3000" 11 | else: 12 | ABSOLUTE_HOST = "https://your.app.com" 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/i18n.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from app.conf.boilerplate import BASE_DIR 4 | 5 | 6 | LANGUAGE_CODE = "ru" 7 | 8 | LOCALE_PATHS = [Path(BASE_DIR).parent / ".locale"] 9 | 10 | USE_i18N = True 11 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/installed_apps.py: -------------------------------------------------------------------------------- 1 | # Application definition 2 | 3 | APPS = [ 4 | "a12n", 5 | "app", 6 | "users", 7 | ] 8 | 9 | THIRD_PARTY_APPS = [ 10 | "drf_spectacular", 11 | "drf_spectacular_sidecar", 12 | "rest_framework", 13 | "rest_framework.authtoken", 14 | "rest_framework_simplejwt.token_blacklist", 15 | "django_filters", 16 | "axes", 17 | "django.contrib.admin", 18 | "django.contrib.auth", 19 | "django.contrib.contenttypes", 20 | "django.contrib.sessions", 21 | "django.contrib.messages", 22 | "django.contrib.staticfiles", 23 | ] 24 | 25 | INSTALLED_APPS = THIRD_PARTY_APPS + APPS 26 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/media.py: -------------------------------------------------------------------------------- 1 | from app.conf.environ import env 2 | 3 | 4 | MEDIA_URL = "/media/" 5 | MEDIA_ROOT = env("MEDIA_ROOT", cast=str, default="media") 6 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/middleware.py: -------------------------------------------------------------------------------- 1 | MIDDLEWARE = [ 2 | "django.middleware.security.SecurityMiddleware", 3 | "whitenoise.middleware.WhiteNoiseMiddleware", 4 | "django.contrib.sessions.middleware.SessionMiddleware", 5 | "django.middleware.common.CommonMiddleware", 6 | "django.middleware.csrf.CsrfViewMiddleware", 7 | "django.contrib.auth.middleware.AuthenticationMiddleware", 8 | "django.contrib.messages.middleware.MessageMiddleware", 9 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 10 | "app.middleware.real_ip.real_ip_middleware", 11 | "axes.middleware.AxesMiddleware", 12 | ] 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/sentry.py: -------------------------------------------------------------------------------- 1 | from app.conf.environ import env 2 | 3 | 4 | # Sentry 5 | # https://sentry.io/for/django/ 6 | 7 | SENTRY_DSN = env("SENTRY_DSN", cast=str, default="") 8 | 9 | if not env("DEBUG") and len(SENTRY_DSN): 10 | import sentry_sdk 11 | from sentry_sdk.integrations.django import DjangoIntegration 12 | 13 | sentry_sdk.init( 14 | dsn=SENTRY_DSN, 15 | integrations=[DjangoIntegration()], 16 | ) 17 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/static.py: -------------------------------------------------------------------------------- 1 | from app.conf.environ import env 2 | 3 | 4 | # Static files (CSS, JavaScript, Images) 5 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 6 | 7 | STATIC_URL = "/static/" 8 | STATIC_ROOT = env("STATIC_ROOT", cast=str, default="static") 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/storage.py: -------------------------------------------------------------------------------- 1 | from app.conf.environ import env 2 | 3 | 4 | STORAGES = { 5 | "default": { 6 | "BACKEND": env( 7 | "FILE_STORAGE", 8 | cast=str, 9 | default="django.core.files.storage.FileSystemStorage", 10 | ), 11 | }, 12 | "staticfiles": { 13 | "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", 14 | }, 15 | } 16 | 17 | AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", default="") 18 | AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", default="") 19 | AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME", default="") 20 | AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", default="") 21 | AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", default="") 22 | AWS_DEFAULT_ACL = env("AWS_DEFAULT_ACL", default="") 23 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/templates.py: -------------------------------------------------------------------------------- 1 | TEMPLATES = [ 2 | { 3 | "BACKEND": "django.template.backends.django.DjangoTemplates", 4 | "DIRS": [], 5 | "APP_DIRS": True, 6 | "OPTIONS": { 7 | "context_processors": [ 8 | "django.template.context_processors.debug", 9 | "django.template.context_processors.request", 10 | "django.contrib.auth.context_processors.auth", 11 | "django.contrib.messages.context_processors.messages", 12 | ], 13 | }, 14 | }, 15 | ] 16 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/conf/timezone.py: -------------------------------------------------------------------------------- 1 | USE_TZ = True 2 | TIME_ZONE = "UTC" 3 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.response import Response 2 | from rest_framework.views import exception_handler 3 | 4 | 5 | class AppServiceException(Exception): 6 | """Inherit your custom service exceptions from this class.""" 7 | 8 | 9 | def app_service_exception_handler(exc: Exception, context: dict) -> Response | None: 10 | """Transform service errors to standard 400 errors.""" 11 | 12 | if not isinstance(exc, AppServiceException): 13 | return exception_handler(exc, context) 14 | 15 | return Response(status=400, data={"serviceError": str(exc)}) 16 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/factory.py: -------------------------------------------------------------------------------- 1 | from django.core.files.uploadedfile import SimpleUploadedFile 2 | from faker import Faker 3 | 4 | from app.testing import register 5 | from app.testing.types import FactoryProtocol 6 | 7 | 8 | faker = Faker() 9 | 10 | 11 | @register 12 | def image(self: FactoryProtocol, name: str = "image.gif", content_type: str = "image/gif") -> SimpleUploadedFile: 13 | return SimpleUploadedFile(name=name, content=faker.image(), content_type=content_type) 14 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | from app.fixtures.api import as_anon, as_user 2 | from app.fixtures.factory import factory 3 | 4 | 5 | __all__ = [ 6 | "as_anon", 7 | "as_user", 8 | "factory", 9 | ] 10 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/fixtures/api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.testing import ApiClient 4 | from users.models import User 5 | 6 | 7 | @pytest.fixture 8 | def as_anon() -> ApiClient: 9 | return ApiClient() 10 | 11 | 12 | @pytest.fixture 13 | def as_user(user: User) -> ApiClient: 14 | return ApiClient(user=user) 15 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/fixtures/factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.testing.factory import FixtureFactory 4 | 5 | 6 | @pytest.fixture 7 | def factory() -> FixtureFactory: 8 | return FixtureFactory() 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/management/commands/makemigrations.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import CommandError 2 | from django.core.management.commands.makemigrations import Command as BaseCommand 3 | 4 | 5 | class MakemigrationsError(CommandError): 6 | pass 7 | 8 | 9 | class Command(BaseCommand): 10 | """Disable automatic names for django migrations, thanks https://adamj.eu/tech/2020/02/24/how-to-disallow-auto-named-django-migrations/""" 11 | 12 | def handle(self, *app_labels, **options): 13 | if options["name"] is None and not any([options["dry_run"], options["check_changes"]]): 14 | raise MakemigrationsError("Migration name is required. Run again with `-n/--name` argument and specify name explicitly.") 15 | 16 | super().handle(*app_labels, **options) 17 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/management/commands/startapp.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.commands.startapp import Command as BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | """Set custom template for all newly generated apps""" 7 | 8 | def handle(self, **options): 9 | if "directory" not in options or options["directory"] is None: 10 | directory = settings.BASE_DIR.parent / options["name"] 11 | 12 | directory.mkdir(exist_ok=True) 13 | 14 | options["directory"] = str(directory) 15 | 16 | if "template" not in options or options["template"] is None: 17 | template = settings.BASE_DIR.parent / ".django-app-template" 18 | 19 | options["template"] = str(template) 20 | 21 | super().handle(**options) 22 | 23 | def validate_name(self, *args, **kwargs): ... 24 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/middleware/real_ip.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from django.http import HttpRequest, HttpResponse 4 | from ipware import get_client_ip 5 | 6 | 7 | def real_ip_middleware(get_response: Callable) -> Callable: 8 | """Set request.META["REMOTE_ADDR"] to ip guessed by django-ipware. 9 | 10 | We need this to make sure all apps using ip detection in django way stay usable behind 11 | any kind of reverse proxy. 12 | 13 | For custom proxy configuration check out django-ipware docs at https://github.com/un33k/django-ipware 14 | """ 15 | 16 | def middleware(request: HttpRequest) -> HttpResponse: 17 | request.META["REMOTE_ADDR"] = get_client_ip(request)[0] 18 | 19 | return get_response(request) 20 | 21 | return middleware 22 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/models.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from behaviors.behaviors import Timestamped # type: ignore[import-untyped] 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | 7 | 8 | __all__ = [ 9 | "DefaultModel", 10 | "TimestampedModel", 11 | "models", 12 | ] 13 | 14 | 15 | class DefaultModel(models.Model): 16 | class Meta: 17 | abstract = True 18 | 19 | def __str__(self) -> str: 20 | """Default name for all models""" 21 | name = getattr(self, "name", None) 22 | if name is not None: 23 | return str(name) 24 | 25 | return super().__str__() 26 | 27 | @classmethod 28 | def get_contenttype(cls) -> ContentType: 29 | return ContentType.objects.get_for_model(cls) 30 | 31 | def update_from_kwargs(self, **kwargs: dict[str, Any]) -> None: 32 | """A shortcut method to update model instance from the kwargs.""" 33 | for key, value in kwargs.items(): 34 | setattr(self, key, value) 35 | 36 | def setattr_and_save(self, key: str, value: Any) -> None: 37 | """Shortcut for testing -- set attribute of the model and save""" 38 | setattr(self, key, value) 39 | self.save() 40 | 41 | @classmethod 42 | def get_label(cls) -> str: 43 | """Get a unique within the app model label""" 44 | return cls._meta.label_lower.split(".")[-1] 45 | 46 | 47 | class TimestampedModel(DefaultModel, Timestamped): 48 | """ 49 | Default app model that has `created` and `updated` attributes. 50 | Currently based on https://github.com/audiolion/django-behaviors 51 | """ 52 | 53 | class Meta: 54 | abstract = True 55 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/services.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from collections.abc import Callable 3 | from typing import Any 4 | 5 | 6 | class BaseService(metaclass=ABCMeta): 7 | """This is a template of a a base service. 8 | All services in the app should follow this rules: 9 | * Input variables should be done at the __init__ phase 10 | * Service should implement a single entrypoint without arguments 11 | 12 | This is ok: 13 | @dataclass 14 | class UserCreator(BaseService): 15 | first_name: str 16 | last_name: Optional[str] 17 | 18 | def act(self) -> User: 19 | return User.objects.create(first_name=self.first_name, last_name=self.last_name) 20 | 21 | # user = UserCreator(first_name="Ivan", last_name="Petrov")() 22 | 23 | This is not ok: 24 | class UserCreator: 25 | def __call__(self, first_name: str, last_name: Optional[str]) -> User: 26 | return User.objects.create(first_name=self.first_name, last_name=self.last_name) 27 | 28 | For more implementation examples, check out https://github.com/tough-dev-school/education-backend/blob/master/src/apps/orders/services/order_course_changer.py 29 | """ 30 | 31 | def __call__(self) -> Any: 32 | self.validate() 33 | return self.act() 34 | 35 | def get_validators(self) -> list[Callable]: 36 | return [] 37 | 38 | def validate(self) -> None: 39 | validators = self.get_validators() 40 | for validator in validators: 41 | validator() 42 | 43 | @abstractmethod 44 | def act(self) -> Any: 45 | raise NotImplementedError("Please implement in the service class") 46 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/settings.py: -------------------------------------------------------------------------------- 1 | # This file was generated using http://github.com/f213/django starter template. 2 | # 3 | # Settings are split into multiple files using http://github.com/sobolevn/django-split-settings 4 | 5 | from split_settings.tools import include 6 | 7 | from app.conf.environ import env 8 | 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = env("SECRET_KEY") 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = env("DEBUG", cast=bool, default=False) 15 | CI = env("CI", cast=bool, default=False) 16 | 17 | include( 18 | "conf/api.py", 19 | "conf/auth.py", 20 | "conf/boilerplate.py", 21 | "conf/db.py", 22 | "conf/celery.py", 23 | "conf/healthchecks.py", 24 | "conf/http.py", 25 | "conf/i18n.py", 26 | "conf/installed_apps.py", 27 | "conf/media.py", 28 | "conf/middleware.py", 29 | "conf/storage.py", 30 | "conf/sentry.py", 31 | "conf/static.py", 32 | "conf/templates.py", 33 | "conf/timezone.py", 34 | ) 35 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/testing/__init__.py: -------------------------------------------------------------------------------- 1 | from app.testing.api import ApiClient 2 | from app.testing.factory import FixtureFactory, register 3 | 4 | 5 | __all__ = [ 6 | "ApiClient", 7 | "FixtureFactory", 8 | "register", 9 | ] 10 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/testing/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import string 4 | 5 | from rest_framework.authtoken.models import Token 6 | from rest_framework.response import Response 7 | from rest_framework.test import APIClient as DRFAPIClient 8 | 9 | from users.models import User 10 | 11 | 12 | class ApiClient(DRFAPIClient): 13 | """Testing HTTP client to deal with the app API. 14 | 15 | Takes care of authentication and JSON parsing. 16 | 17 | Client is available as two fixtures: `as_anon` and `as_user`. Use it like this: 18 | 19 | def test(as_anon): 20 | as_anon.get("/api/v1/healthchecks/db/") # fetch endpoint anonymously 21 | 22 | 23 | def test_whoami(as_user, user): 24 | result = as_user.get("/api/v1/users/me/") # fetch endpoint, authenticated as current user. 25 | 26 | assert result["id"] == user.pk # fixture `as_user` always takes the `user` fixture 27 | 28 | 29 | def test_raw(as_user, user): 30 | raw = as_user.get("/api/v1/users/me/", as_response=True) # get raw django HTTPResponse 31 | 32 | result = json.loads(raw.content) 33 | 34 | assert result["id"] == user.pk 35 | 36 | You can build your own fixtures around the client, for example like this: 37 | 38 | from app.testing import ApiClient 39 | 40 | @pytest.fixture 41 | def as() -> ApiClient: 42 | return lambda user: User, ApiClient(user=user) 43 | 44 | And use this fixture like this: 45 | 46 | def test_whoami(as, another_user): 47 | result = as(another_user).get("/api/v1/users/me/") # your custom user to authenticate 48 | 49 | 50 | """ 51 | 52 | def __init__(self, user: User | None = None, *args, **kwargs) -> None: 53 | super().__init__(*args, **kwargs) 54 | 55 | if user: 56 | self.user = user 57 | self.password = "".join([random.choice(string.hexdigits) for _ in range(6)]) 58 | self.user.set_password(self.password) 59 | self.user.save() 60 | 61 | token = Token.objects.create(user=self.user) 62 | self.credentials( 63 | HTTP_AUTHORIZATION=f"Token {token}", 64 | HTTP_X_CLIENT="testing", 65 | ) 66 | 67 | def get(self, *args, **kwargs): 68 | expected_status = kwargs.get("expected_status", 200) 69 | return self._request("get", expected_status, *args, **kwargs) 70 | 71 | def patch(self, *args, **kwargs): 72 | expected_status = kwargs.get("expected_status", 200) 73 | return self._request("patch", expected_status, *args, **kwargs) 74 | 75 | def post(self, *args, **kwargs): 76 | expected_status = kwargs.get("expected_status", 201) 77 | return self._request("post", expected_status, *args, **kwargs) 78 | 79 | def put(self, *args, **kwargs): 80 | expected_status = kwargs.get("expected_status", 200) 81 | return self._request("put", expected_status, *args, **kwargs) 82 | 83 | def delete(self, *args, **kwargs): 84 | expected_status = kwargs.get("expected_status", 204) 85 | return self._request("delete", expected_status, *args, **kwargs) 86 | 87 | def _request(self, method, expected, *args, **kwargs): 88 | kwargs["format"] = kwargs.get("format", "json") 89 | as_response = kwargs.pop("as_response", False) 90 | method = getattr(super(), method) 91 | 92 | response = method(*args, **kwargs) 93 | if as_response: 94 | return response 95 | 96 | content = self._decode(response) 97 | assert response.status_code == expected, content 98 | return content 99 | 100 | def _decode(self, response: Response): 101 | if response.status_code == 204: 102 | return {} 103 | 104 | content = response.content.decode("utf-8", errors="ignore") 105 | 106 | if self.is_json(response): 107 | return json.loads(content) 108 | return content 109 | 110 | @staticmethod 111 | def is_json(response: Response) -> bool: 112 | if response.has_header("content-type"): 113 | return "json" in response.get("content-type", "") 114 | 115 | return False 116 | 117 | 118 | __all__ = [ 119 | "ApiClient", 120 | ] 121 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/testing/factory.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from functools import partial 3 | 4 | from app.testing.mixer import mixer 5 | 6 | 7 | def register(method: Callable) -> Callable: 8 | name = method.__name__ 9 | FixtureRegistry.METHODS[name] = method 10 | return method 11 | 12 | 13 | class FixtureRegistry: 14 | METHODS: dict[str, Callable] = {} 15 | 16 | def get(self, name: str) -> Callable: 17 | method = self.METHODS.get(name) 18 | if not method: 19 | raise AttributeError(f"Factory method “{name}” not found.") 20 | return method 21 | 22 | 23 | class CycleFixtureFactory: 24 | def __init__(self, factory: "FixtureFactory", count: int) -> None: 25 | self.factory = factory 26 | self.count = count 27 | 28 | def __getattr__(self, name: str) -> Callable: 29 | return lambda *args, **kwargs: [getattr(self.factory, name)(*args, **kwargs) for _ in range(self.count)] 30 | 31 | 32 | class FixtureFactory: 33 | def __init__(self) -> None: 34 | self.mixer = mixer 35 | self.registry = FixtureRegistry() 36 | 37 | def __getattr__(self, name: str) -> Callable: 38 | method = self.registry.get(name) 39 | return partial(method, self) 40 | 41 | def cycle(self, count: int) -> CycleFixtureFactory: 42 | """ 43 | Run given method X times: 44 | factory.cycle(5).order() # gives 5 orders 45 | """ 46 | return CycleFixtureFactory(self, count) 47 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/testing/mixer.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from mixer.backend.django import mixer 4 | 5 | 6 | __all__ = [ 7 | "mixer", 8 | ] 9 | 10 | 11 | def _random_user_name() -> str: 12 | return str(uuid.uuid4()) 13 | 14 | 15 | def _random_email() -> str: 16 | uuid_as_str = str(uuid.uuid4()).replace("-", "_") 17 | return f"{uuid_as_str}@mail.com" 18 | 19 | 20 | mixer.register("users.User", username=_random_user_name, email=_random_email) 21 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/testing/runner.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.core.management.base import CommandError 4 | 5 | 6 | class DisableTestCommandRunner: 7 | def __init__(self, *args: Any, **kwargs: Any) -> None: 8 | pass 9 | 10 | def run_tests(self, *args: Any) -> None: 11 | raise CommandError("Pytest here. Run it with `make test`") 12 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/testing/types.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from mixer.backend.django import mixer 4 | 5 | 6 | class FactoryProtocol(Protocol): 7 | mixer: mixer 8 | 9 | 10 | __all__ = [ 11 | "FactoryProtocol", 12 | ] 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/app/tests/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/tests/test_health.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | pytestmark = [ 5 | pytest.mark.django_db, 6 | pytest.mark.filterwarnings("ignore:.*inspect.getargspec().*:DeprecationWarning"), 7 | ] 8 | 9 | 10 | def test(as_anon): 11 | result = as_anon.get("/api/v1/healthchecks/db/") 12 | 13 | assert result == "true" 14 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/tests/test_remote_addr_midlleware.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.apps import apps 3 | 4 | from app.testing.api import ApiClient 5 | 6 | 7 | pytestmark = [pytest.mark.django_db] 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def _require_users_app_installed(settings): 12 | assert apps.is_installed("users"), """ 13 | Stock f213/django users app should be installed to run this test. 14 | 15 | Make sure to test app.middleware.real_ip.real_ip_middleware on your own, if you drop 16 | the stock users app. 17 | """ 18 | 19 | 20 | @pytest.fixture 21 | def api(user): 22 | return ApiClient(user=user, HTTP_X_FORWARDED_FOR="100.200.250.150, 10.0.0.1") 23 | 24 | 25 | def test_remote_addr(api): 26 | result = api.get("/api/v1/users/me/") 27 | 28 | assert result["remoteAddr"] == "100.200.250.150" 29 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/tests/testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/app/tests/testing/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/tests/testing/factory/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/app/tests/testing/factory/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/tests/testing/factory/test_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.testing import FixtureFactory, register 4 | 5 | 6 | @pytest.fixture 7 | def fixture_factory() -> FixtureFactory: 8 | return FixtureFactory() 9 | 10 | 11 | @pytest.fixture 12 | def registered_method(mocker): 13 | mock = mocker.Mock(name="registered_method", return_value="i should be returned after gettatr") 14 | mock.__name__ = "registered_method" 15 | register(mock) 16 | return mock 17 | 18 | 19 | def test_call_getattr_returns_what_method_returned(fixture_factory: FixtureFactory, registered_method): 20 | result = fixture_factory.registered_method() 21 | 22 | assert result == "i should be returned after gettatr" 23 | 24 | 25 | def test_registered_method_called_with_factory_instance(fixture_factory: FixtureFactory, registered_method): 26 | fixture_factory.registered_method(foo=1) # act 27 | 28 | registered_method.assert_called_with(fixture_factory, foo=1) 29 | 30 | 31 | def test_cycle_returns_given_method_n_times(fixture_factory: FixtureFactory, registered_method, mocker): 32 | fixture_factory.cycle(4).registered_method(bar=1) # act 33 | 34 | registered_method.assert_has_calls( 35 | calls=[ 36 | mocker.call(fixture_factory, bar=1), 37 | mocker.call(fixture_factory, bar=1), 38 | mocker.call(fixture_factory, bar=1), 39 | mocker.call(fixture_factory, bar=1), 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/tests/testing/factory/test_registry.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.testing.factory import FixtureRegistry, register 4 | 5 | 6 | @pytest.fixture 7 | def fixture_registry() -> FixtureRegistry: 8 | return FixtureRegistry() 9 | 10 | 11 | def test_registry_raises_exception_if_no_method(fixture_registry: FixtureRegistry): 12 | with pytest.raises(AttributeError, match=r"Factory method \“not_real\” not found\."): 13 | fixture_registry.get("not_real") 14 | 15 | 16 | def test_registry_returns_correct_method_after_register_decorator(fixture_registry: FixtureRegistry): 17 | @register 18 | def some_method_to_add(): 19 | pass 20 | 21 | method = fixture_registry.get("some_method_to_add") # act 22 | 23 | assert some_method_to_add == method 24 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/urls/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | 5 | api = [ 6 | path("v1/", include("app.urls.v1", namespace="v1")), 7 | ] 8 | 9 | urlpatterns = [ 10 | path("admin/", admin.site.urls), 11 | path("api/", include(api)), 12 | ] 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/urls/v1.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 3 | 4 | 5 | app_name = "api_v1" 6 | 7 | urlpatterns = [ 8 | path("auth/", include("a12n.api.urls")), 9 | path("users/", include("users.api.urls")), 10 | path("healthchecks/", include("django_healthchecks.urls")), 11 | path("docs/schema/", SpectacularAPIView.as_view(), name="schema"), 12 | path("docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema")), 13 | ] 14 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/app/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") 7 | 8 | application = get_wsgi_application() 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = [ 2 | "app.factory", 3 | "app.fixtures", 4 | "users.factory", 5 | "users.fixtures", 6 | ] 7 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | def main() -> None: 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") 8 | 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Could not import Django. Are you sure it is installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?", 16 | ) from exc 17 | 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/users/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | 4 | from users.models import User 5 | 6 | 7 | admin.site.register(User, UserAdmin) 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from users.models import User 4 | 5 | 6 | class UserSerializer(serializers.ModelSerializer): 7 | remote_addr = serializers.SerializerMethodField() 8 | 9 | class Meta: 10 | model = User 11 | fields = [ 12 | "id", 13 | "username", 14 | "first_name", 15 | "last_name", 16 | "email", 17 | "remote_addr", 18 | ] 19 | 20 | def get_remote_addr(self, obj: User) -> str: 21 | return self.context["request"].META["REMOTE_ADDR"] 22 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from users.api import viewsets 4 | 5 | 6 | app_name = "users" 7 | 8 | urlpatterns = [ 9 | path("me/", viewsets.SelfView.as_view()), 10 | ] 11 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/api/viewsets.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | from rest_framework.generics import GenericAPIView 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.response import Response 5 | 6 | from app.api.request import AuthenticatedRequest 7 | from users.api.serializers import UserSerializer 8 | from users.models import User 9 | 10 | 11 | class SelfView(GenericAPIView): 12 | serializer_class = UserSerializer 13 | permission_classes = [IsAuthenticated] 14 | 15 | request: AuthenticatedRequest 16 | 17 | def get(self, request: AuthenticatedRequest) -> Response: 18 | user = self.get_object() 19 | serializer = self.get_serializer(user) 20 | 21 | return Response(serializer.data) 22 | 23 | def get_object(self) -> User: 24 | return self.get_queryset().get(pk=self.request.user.pk) 25 | 26 | def get_queryset(self) -> QuerySet[User]: 27 | return User.objects.filter(is_active=True) 28 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/factory.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | 3 | from app.testing import register 4 | from app.testing.types import FactoryProtocol 5 | from users.models import User 6 | 7 | 8 | @register 9 | def user(self: FactoryProtocol, **kwargs: dict) -> User: 10 | return self.mixer.blend("users.User", **kwargs) 11 | 12 | 13 | @register 14 | def anon(self: FactoryProtocol, **kwargs: dict) -> AnonymousUser: 15 | return AnonymousUser() 16 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/fixtures.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | 5 | from users.models import User 6 | 7 | 8 | if TYPE_CHECKING: 9 | from app.testing.factory import FixtureFactory 10 | 11 | 12 | @pytest.fixture 13 | def user(factory: "FixtureFactory") -> User: 14 | return factory.user() 15 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fandsdev/django/e43f62e82462d47b2ac9f35164e0ad7c4713a4b8/{{ cookiecutter.name }}/src/users/migrations/__init__.py -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/models.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from django.contrib.auth.models import AbstractUser, UserManager as _UserManager 4 | 5 | 6 | class User(AbstractUser): 7 | objects: ClassVar[_UserManager] = _UserManager() 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/tests/test_password_hashing.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from users.models import User 6 | 7 | 8 | pytestmark = [pytest.mark.django_db] 9 | 10 | 11 | def test(): 12 | user = User.objects.create(username=str(uuid.uuid4())) 13 | user.set_password("l0ve") 14 | 15 | user.save() # act 16 | 17 | assert user.password.startswith("bcrypt") 18 | -------------------------------------------------------------------------------- /{{ cookiecutter.name }}/src/users/tests/test_whoami.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | pytestmark = [pytest.mark.django_db] 5 | 6 | 7 | def test_ok(as_user, user): 8 | result = as_user.get("/api/v1/users/me/") 9 | 10 | assert result["id"] == user.pk 11 | assert result["username"] == user.username 12 | 13 | 14 | def test_anon(as_anon): 15 | result = as_anon.get("/api/v1/users/me/", as_response=True) 16 | 17 | assert result.status_code == 401 18 | --------------------------------------------------------------------------------