├── .dockerignore ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .importlinter ├── .python-version ├── CHANGELOG.md ├── README.md ├── config └── .env.template ├── docker-compose.override.yml ├── docker-compose.yml ├── docker ├── caddy │ ├── Caddyfile │ └── ci.sh ├── django │ ├── Dockerfile │ ├── ci.sh │ ├── entrypoint.sh │ ├── gunicorn.sh │ ├── gunicorn_config.py │ └── smoke.sh └── docker-compose.prod.yml ├── docs ├── Makefile ├── README.md ├── _static │ └── .gitkeep ├── _templates │ └── moreinfo.html ├── conf.py ├── index.rst ├── make.bat └── pages │ ├── project │ └── glossary.rst │ └── template │ ├── development.rst │ ├── django.rst │ ├── documentation.rst │ ├── faq.rst │ ├── gitlab-ci.rst │ ├── linters.rst │ ├── overview.rst │ ├── production-checklist.rst │ ├── production.rst │ ├── security.rst │ ├── testing.rst │ ├── troubleshooting.rst │ └── upgrading-template.rst ├── locale └── .gitkeep ├── manage.py ├── poetry.lock ├── pyproject.toml ├── scripts └── create_dev_database.sql ├── server ├── __init__.py ├── apps │ ├── __init__.py │ ├── identity │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── container.py │ │ ├── infrastructure │ │ │ ├── __init__.py │ │ │ ├── django │ │ │ │ ├── __init__.py │ │ │ │ ├── decorators.py │ │ │ │ └── forms.py │ │ │ └── services │ │ │ │ ├── __init__.py │ │ │ │ └── placeholder.py │ │ ├── logic │ │ │ ├── __init__.py │ │ │ └── usecases │ │ │ │ ├── __init__.py │ │ │ │ ├── user_create_new.py │ │ │ │ └── user_update.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── templates │ │ │ └── identity │ │ │ │ ├── includes │ │ │ │ └── user_model_form.html │ │ │ │ └── pages │ │ │ │ ├── login.html │ │ │ │ ├── registration.html │ │ │ │ └── user_update.html │ │ ├── urls.py │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── login.py │ │ │ └── user.py │ └── pictures │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── container.py │ │ ├── infrastructure │ │ ├── __init__.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ └── forms.py │ │ └── services │ │ │ ├── __init__.py │ │ │ └── placeholder.py │ │ ├── logic │ │ ├── __init__.py │ │ ├── repo │ │ │ ├── __init__.py │ │ │ └── queries │ │ │ │ ├── __init__.py │ │ │ │ └── favourite_pictures.py │ │ └── usecases │ │ │ ├── __init__.py │ │ │ ├── favourites_list.py │ │ │ └── pictures_fetch.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── templates │ │ └── pictures │ │ │ └── pages │ │ │ ├── dashboard.html │ │ │ ├── favourites.html │ │ │ └── index.html │ │ ├── urls.py │ │ └── views.py ├── common │ ├── __init__.py │ ├── django │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── decorators.py │ │ ├── forms.py │ │ ├── models.py │ │ ├── templates │ │ │ └── common │ │ │ │ ├── _base.html │ │ │ │ ├── includes │ │ │ │ ├── field.html │ │ │ │ ├── footer.html │ │ │ │ ├── header.html │ │ │ │ └── messages.html │ │ │ │ └── txt │ │ │ │ ├── humans.txt │ │ │ │ └── robots.txt │ │ └── types.py │ ├── pydantic_model.py │ └── services │ │ ├── __init__.py │ │ └── http.py ├── settings │ ├── __init__.py │ ├── components │ │ ├── __init__.py │ │ ├── caches.py │ │ ├── common.py │ │ ├── csp.py │ │ ├── identity.py │ │ ├── logging.py │ │ └── placeholder.py │ └── environments │ │ ├── __init__.py │ │ ├── development.py │ │ ├── local.py.template │ │ └── production.py ├── urls.py └── wsgi.py ├── setup.cfg └── tests ├── conftest.py ├── plugins ├── django_settings.py ├── identity │ └── .gitkeep └── pictures │ └── .gitkeep ├── test_apps ├── test_identity │ └── .gitkeep └── test_pictures │ └── .gitkeep └── test_server └── test_urls.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | .local/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | docs/documents/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # Docker 63 | Dockerfile 64 | docker-compose.yml 65 | docker-compose.override.yml 66 | docker/docker-compose.prod.yml 67 | 68 | # JetBrains 69 | .idea/ 70 | 71 | # import-linter 72 | .import_linter_cache/ 73 | 74 | # Release script: 75 | release.config.js 76 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Check http://editorconfig.org for more information 2 | # This is the main config file for this project: 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.py] 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [*.pyi] 18 | indent_style = space 19 | indent_size = 4 20 | 21 | [{Makefile,Caddyfile}] 22 | indent_style = tab 23 | 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: github-actions 6 | directory: "/" 7 | schedule: 8 | interval: daily 9 | time: "02:00" 10 | open-pull-requests-limit: 10 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: CI 4 | "on": 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 30 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Create default environment 26 | run: cp config/.env.template config/.env 27 | 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Build the image 35 | uses: docker/bake-action@v4 36 | with: 37 | files: docker-compose.yml 38 | targets: web 39 | load: true 40 | set: | 41 | *.cache-from=type=gha,scope=cached-stage 42 | *.cache-to=type=gha,scope=cached-stage,mode=max 43 | 44 | - name: Test 45 | run: | 46 | docker compose run \ 47 | -e DJANGO_SECRET_KEY --user=root --rm web ./docker/django/ci.sh 48 | env: 49 | # This must not be public and must be stored as a secret value. 50 | # But, we don't really care in this homework. 51 | DJANGO_SECRET_KEY: | 52 | htrG1aAJYa9fwmVMoW13PL0zwIFcwBV4iCevHTJzZHmpq1oR8Q 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### joe made this: https://goel.io/joe 2 | 3 | # Git style-guide: 4 | # https://github.com/agis-/git-style-guide 5 | 6 | #####=== OSX ===##### 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear on external disk 19 | .Spotlight-V100 20 | .Trashes 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | #####=== Windows ===##### 30 | # Windows image file caches 31 | Thumbs.db 32 | ehthumbs.db 33 | 34 | # Folder config file 35 | Desktop.ini 36 | 37 | # Recycle Bin used on file shares 38 | $RECYCLE.BIN/ 39 | 40 | # Windows Installer files 41 | *.cab 42 | *.msi 43 | *.msm 44 | *.msp 45 | 46 | # Windows shortcuts 47 | *.lnk 48 | 49 | #####=== Python ===##### 50 | 51 | # Byte-compiled / optimized / DLL files 52 | __pycache__/ 53 | *.py[cod] 54 | 55 | # C extensions 56 | *.so 57 | 58 | # Distribution / packaging 59 | .Python 60 | env/ 61 | develop-eggs/ 62 | dist/ 63 | downloads/ 64 | eggs/ 65 | lib/ 66 | lib64/ 67 | parts/ 68 | sdist/ 69 | var/ 70 | *.egg-info/ 71 | .installed.cfg 72 | *.egg 73 | 74 | # PyInstaller 75 | # Usually these files are written by a python script from a template 76 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 77 | *.manifest 78 | *.spec 79 | 80 | # Installer logs 81 | pip-log.txt 82 | pip-delete-this-directory.txt 83 | 84 | # Unit test / coverage reports 85 | htmlcov/ 86 | .tox/ 87 | .coverage 88 | .cache 89 | nosetests.xml 90 | coverage.xml 91 | 92 | # Translations 93 | *.mo 94 | *.pot 95 | 96 | # Django stuff: 97 | *.log 98 | 99 | # Sphinx documentation 100 | docs/_build/ 101 | 102 | # PyBuilder 103 | target/ 104 | 105 | #####=== Sass ===##### 106 | 107 | .sass-cache 108 | *.css.map 109 | 110 | #####=== Yeoman ===##### 111 | 112 | node_modules/ 113 | bower_components/ 114 | *.log 115 | 116 | build/ 117 | dist/ 118 | 119 | #####=== Vagrant ===##### 120 | .vagrant/ 121 | 122 | #####=== Node ===##### 123 | 124 | # Logs 125 | logs 126 | *.log 127 | 128 | # Runtime data 129 | pids 130 | *.pid 131 | *.seed 132 | 133 | # Directory for instrumented libs generated by jscoverage/JSCover 134 | lib-cov 135 | 136 | # Coverage directory used by tools like istanbul 137 | coverage 138 | 139 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 140 | .grunt 141 | 142 | # node-waf configuration 143 | .lock-wscript 144 | 145 | # Compiled binary addons (http://nodejs.org/api/addons.html) 146 | build/Release 147 | 148 | # Dependency directory 149 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 150 | node_modules 151 | 152 | # Debug log from npm 153 | npm-debug.log 154 | 155 | #### jetbrains #### 156 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 157 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 158 | # You can uncomment these lines to enable configuration sharing between 159 | # team members, or you can restrict `.idea/` folder at all (default). 160 | 161 | # User-specific stuff: 162 | # .idea/**/workspace.xml 163 | # .idea/**/tasks.xml 164 | # .idea/dictionaries 165 | 166 | # # Sensitive or high-churn files: 167 | # .idea/**/dataSources/ 168 | # .idea/**/dataSources.ids 169 | # .idea/**/dataSources.xml 170 | # .idea/**/dataSources.local.xml 171 | # .idea/**/sqlDataSources.xml 172 | # .idea/**/dynamic.xml 173 | # .idea/**/uiDesigner.xml 174 | 175 | # # Gradle: 176 | # .idea/**/gradle.xml 177 | # .idea/**/libraries 178 | 179 | # # Mongo Explorer plugin: 180 | # .idea/**/mongoSettings.xml 181 | 182 | # # Cursive Clojure plugin 183 | # .idea/replstate.xml 184 | 185 | # Restrict `.idea/` folder at all: 186 | .idea/ 187 | 188 | # CMake 189 | cmake-build-debug/ 190 | 191 | ## File-based project format: 192 | *.iws 193 | 194 | ## Plugin-specific files: 195 | 196 | # IntelliJ 197 | /out/ 198 | 199 | # mpeltonen/sbt-idea plugin 200 | .idea_modules/ 201 | 202 | # JIRA plugin 203 | atlassian-ide-plugin.xml 204 | 205 | # Crashlytics plugin (for Android Studio and IntelliJ) 206 | com_crashlytics_export_strings.xml 207 | crashlytics.properties 208 | crashlytics-build.properties 209 | fabric.properties 210 | 211 | 212 | #####=== Custom ===##### 213 | # Directories: 214 | media/ 215 | .static/ 216 | /static/ 217 | 218 | # File types: 219 | *.sqlite3 220 | *.db 221 | 222 | # Configuration file with private data: 223 | *.env 224 | .env 225 | 226 | # Local settings files: 227 | *local.py 228 | 229 | # Deploy files for Docker: 230 | docker-compose.deploy.yml 231 | 232 | # Certificates: 233 | docker/caddy/certs/ 234 | 235 | # Artifacts: 236 | .gitlab/.svn/ 237 | artifacts/ 238 | 239 | # mypy: 240 | .mypy_cache/ 241 | 242 | # pytest: 243 | .pytest_cache/ 244 | 245 | # ipython: 246 | .ipython/ 247 | -------------------------------------------------------------------------------- /.importlinter: -------------------------------------------------------------------------------- 1 | # Docs: https://github.com/seddonym/import-linter 2 | 3 | [importlinter] 4 | root_package = server 5 | include_external_packages = True 6 | 7 | 8 | [importlinter:contract:layers] 9 | name = Layered architecture of our project 10 | type = layers 11 | 12 | containers = 13 | server.apps.pictures 14 | server.apps.identity 15 | 16 | layers = 17 | (urls) | (admin) 18 | (views) 19 | (container) 20 | (logic) 21 | (infrastructure) 22 | (models) 23 | 24 | 25 | [importlinter:contract:apps-independence] 26 | name = All apps must be independent 27 | type = independence 28 | 29 | modules = 30 | server.apps.pictures 31 | server.apps.identity 32 | 33 | 34 | [importlinter:contract:common-module-is-independent] 35 | name = Common utilities cannot import things from apps 36 | type = forbidden 37 | 38 | source_modules = 39 | server.common 40 | 41 | forbidden_modules = 42 | server.apps 43 | 44 | 45 | [importlinter:contract:tests-restrictions] 46 | name = Explicit import restrictions for tests 47 | type = forbidden 48 | 49 | source_modules = 50 | server 51 | 52 | forbidden_modules = 53 | tests 54 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.5 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/CHANGELOG.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # testing_homework 2 | 3 | This project was generated with [`wemake-django-template`](https://github.com/wemake-services/wemake-django-template). Current template version is: [4e5b885](https://github.com/wemake-services/wemake-django-template/tree/4e5b8853c7f2d263302421229b5ed7981229b954). See what is [updated](https://github.com/wemake-services/wemake-django-template/compare/4e5b8853c7f2d263302421229b5ed7981229b954...master) since then. 4 | 5 | 6 | [![wemake.services](https://img.shields.io/badge/%20-wemake.services-green.svg?label=%20&logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC%2FxhBQAAAAFzUkdCAK7OHOkAAAAbUExURQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP%2F%2F%2F5TvxDIAAAAIdFJOUwAjRA8xXANAL%2Bv0SAAAADNJREFUGNNjYCAIOJjRBdBFWMkVQeGzcHAwksJnAPPZGOGAASzPzAEHEGVsLExQwE7YswCb7AFZSF3bbAAAAABJRU5ErkJggg%3D%3D)](https://wemake-services.github.io) 7 | [![wemake-python-styleguide](https://img.shields.io/badge/style-wemake-000000.svg)](https://github.com/wemake-services/wemake-python-styleguide) 8 | 9 | 10 | ## What does this app do? 11 | 12 | This app serves just one main purpose: 13 | showing pictures and saving your favoirutes ones. 14 | 15 | To do that we also have supporting features, like: 16 | - User registration and login / logout mechanics 17 | - Integration with other external "services" 18 | - Admin panel 19 | - All the required infrastructure code: including CI/CD and build scripts 20 | 21 | We also care about: 22 | - Code quality 23 | - Naming conventions 24 | - Architecture 25 | - Typing 26 | - Tooling 27 | 28 | ### Glossary 29 | 30 | See https://github.com/sobolevn/testing_homework/blob/master/docs/pages/project/glossary.rst 31 | 32 | 33 | ## Prerequisites 34 | 35 | You will need: 36 | 37 | - `python3.11` (see `pyproject.toml` for full version) 38 | - `postgresql` with version `15` 39 | - Latest `docker` 40 | 41 | 42 | ## Development 43 | 44 | When developing locally, we use: 45 | 46 | - [`editorconfig`](http://editorconfig.org/) plugin (**required**) 47 | - [`poetry`](https://github.com/python-poetry/poetry) (**required**) 48 | - [`pyenv`](https://github.com/pyenv/pyenv) 49 | 50 | 51 | ## 🚀 Quickstart 52 | 53 | One time setup: 54 | 1. `git clone tough-dev-school/python-testing-homework` 55 | 2. `cd python-testing-homework` 56 | 3. Create your own `config/.env` file: `cp config/.env.template config/.env` and then update it with your own value 57 | 58 | Run tests with: 59 | 1. `docker compose run --rm web pytest` 60 | 61 | To start the whole project: 62 | 1. Run `docker compose run --rm web python manage.py migrate` (only once) 63 | 2. `docker compose up` 64 | 65 | 66 | ## Documentation 67 | 68 | Full documentation is available here: [`docs/`](docs). 69 | -------------------------------------------------------------------------------- /config/.env.template: -------------------------------------------------------------------------------- 1 | # Security Warning! Do not commit this file to any VCS! 2 | # This is a local file to speed up development process, 3 | # so you don't have to change your environment variables. 4 | # 5 | # This is not applied to `.env.template`! 6 | # Template files must be committed to the VCS, but must not contain 7 | # any secret values. 8 | 9 | 10 | # === General === 11 | 12 | # TODO: change 13 | DOMAIN_NAME=myapp.com 14 | 15 | 16 | # === Django === 17 | 18 | # Generate yours with: 19 | # python3 -c 'from django.utils.crypto import get_random_string; print(get_random_string(50))' 20 | DJANGO_SECRET_KEY= 21 | 22 | 23 | # === Database === 24 | 25 | # These variables are special, since they are consumed 26 | # by both django and postgres docker image. 27 | # Cannot be renamed if you use postgres in docker. 28 | # See: https://hub.docker.com/_/postgres 29 | 30 | POSTGRES_DB=testing_homework 31 | POSTGRES_USER=testing_homework 32 | POSTGRES_PASSWORD=testing_homework 33 | 34 | # Used only by django: 35 | DJANGO_DATABASE_HOST=localhost 36 | DJANGO_DATABASE_PORT=5432 37 | 38 | 39 | # === Placeholder API Integration === 40 | 41 | # By default it uses `bitrix` API mock service from `docker-compose`: 42 | DJANGO_PLACEHOLDER_API_URL=https://jsonplaceholder.typicode.com/ 43 | DJANGO_PLACEHOLDER_API_TIMEOUT=5 44 | 45 | 46 | # === Caddy === 47 | 48 | # We use this email to support HTTPS, certificate will be issued on this owner: 49 | # See: https://caddyserver.com/docs/caddyfile/directives/tls 50 | TLS_EMAIL=webmaster@myapp.com 51 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # This file is required to bind ports in development, 4 | # since binding ports in regular compose file will ruin scaling 5 | # in production. Due to how `ports` directive is merged with two files. 6 | # 7 | # This file is ignored in production, but 8 | # it is automatically picked up in development with: 9 | # 10 | # $ docker compose up 11 | 12 | version: "3.8" 13 | services: 14 | 15 | web: 16 | ports: 17 | # We only bind ports directly in development: 18 | - "8000:8000" 19 | volumes: 20 | # We only mount source code in development: 21 | - .:/code 22 | build: 23 | # Needed for fixing permissions of files created by Docker 24 | args: 25 | - UID=${UID:-1000} 26 | - GID=${GID:-1000} 27 | 28 | networks: 29 | # Fake API network: 30 | bitrixnet: 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # Default compose file for development and production. 4 | # Should be used directly in development. 5 | # Automatically loads `docker-compose.override.yml` if it exists. 6 | # No extra steps required. 7 | # Should be used together with `docker/docker-compose.prod.yml` 8 | # in production. 9 | 10 | version: "3.8" 11 | services: 12 | db: 13 | image: "postgres:15-alpine" 14 | volumes: 15 | - pgdata:/var/lib/postgresql/data 16 | networks: 17 | - webnet 18 | - postgresnet 19 | env_file: ./config/.env 20 | 21 | web: 22 | <<: &web 23 | # Image name is changed in production: 24 | image: "testing_homework:dev" 25 | build: 26 | target: development_build 27 | context: . 28 | dockerfile: ./docker/django/Dockerfile 29 | args: 30 | DJANGO_ENV: development 31 | cache_from: 32 | - "testing_homework:dev" 33 | - "testing_homework:latest" 34 | - "*" 35 | 36 | volumes: 37 | - django-static:/var/www/django/static 38 | depends_on: 39 | - db 40 | networks: 41 | - webnet 42 | - postgresnet 43 | env_file: ./config/.env 44 | environment: 45 | DJANGO_DATABASE_HOST: db 46 | 47 | command: python -Wd manage.py runserver 0.0.0.0:8000 48 | healthcheck: 49 | # We use `$$` here because: 50 | # one `$` goes to shell, 51 | # one `$` goes to `docker-compose.yml` escaping 52 | test: | 53 | /usr/bin/test $$( 54 | /usr/bin/curl --fail http://localhost:8000/health/?format=json 55 | --write-out "%{http_code}" --silent --output /dev/null 56 | ) -eq 200 57 | interval: 10s 58 | timeout: 5s 59 | retries: 5 60 | start_period: 30s 61 | 62 | # This task is an example of how to extend existing ones: 63 | # some_worker: 64 | # <<: *web 65 | # command: python manage.py worker_process 66 | 67 | networks: 68 | # Network for your internals, use it by default: 69 | webnet: 70 | # Network for postgres, use it for services that need access to the db: 71 | postgresnet: 72 | 73 | volumes: 74 | pgdata: 75 | django-static: 76 | -------------------------------------------------------------------------------- /docker/caddy/Caddyfile: -------------------------------------------------------------------------------- 1 | # See https://caddyserver.com/docs 2 | 3 | # Email for Let's Encrypt expiration notices 4 | { 5 | email {$TLS_EMAIL} 6 | } 7 | 8 | # "www" redirect to "non-www" version 9 | www.{$DOMAIN_NAME} { 10 | redir https://{$DOMAIN_NAME}{uri} 11 | } 12 | 13 | {$DOMAIN_NAME} { 14 | # HTTPS options: 15 | header Strict-Transport-Security max-age=31536000; 16 | 17 | # Removing some headers for improved security: 18 | header -Server 19 | 20 | # Serve static files 21 | handle_path /static/* { 22 | # STATIC_ROOT 23 | root * /var/www/django/static 24 | 25 | file_server { 26 | # Staticfiles are pre-compressed in `gunicorn.sh` 27 | precompressed br gzip 28 | } 29 | } 30 | 31 | # Serve media files 32 | handle_path /media/* { 33 | # MEDIA_ROOT 34 | root * /var/www/django/media 35 | 36 | file_server 37 | } 38 | 39 | # Serve Django app 40 | handle { 41 | reverse_proxy web:8000 42 | } 43 | 44 | # Dynamically compress response with gzip when it makes sense. 45 | # This setting is ignored for precompressed files. 46 | encode gzip 47 | 48 | # Logs: 49 | log { 50 | output stdout 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docker/caddy/ci.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -o errexit 3 | set -o nounset 4 | 5 | CADDYFILE_PATH='/etc/caddy/Caddyfile' 6 | 7 | run_ci () { 8 | # Validating: 9 | caddy validate --config "$CADDYFILE_PATH" 10 | 11 | # Checking formatting: 12 | # TODO: we use this hack, because `caddy fmt` does not have `--check` arg. 13 | old_caddyfile="$(md5sum "$CADDYFILE_PATH")" 14 | 15 | caddy fmt --overwrite "$CADDYFILE_PATH" 16 | 17 | if [ "$old_caddyfile" != "$(md5sum "$CADDYFILE_PATH")" ]; then 18 | echo 'Invalid format' 19 | exit 1 20 | else 21 | echo 'Valid format' 22 | fi 23 | } 24 | 25 | # Run the CI process: 26 | run_ci 27 | -------------------------------------------------------------------------------- /docker/django/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | # This Dockerfile uses multi-stage build to customize DEV and PROD images: 3 | # https://docs.docker.com/develop/develop-images/multistage-build/ 4 | 5 | FROM python:3.11.5-slim-bookworm AS development_build 6 | 7 | LABEL maintainer="myapp.com" 8 | LABEL vendor="myapp.com" 9 | 10 | # `DJANGO_ENV` arg is used to make prod / dev builds: 11 | ARG DJANGO_ENV \ 12 | # Needed for fixing permissions of files created by Docker: 13 | UID=1000 \ 14 | GID=1000 15 | 16 | ENV DJANGO_ENV=${DJANGO_ENV} \ 17 | # python: 18 | PYTHONFAULTHANDLER=1 \ 19 | PYTHONUNBUFFERED=1 \ 20 | PYTHONHASHSEED=random \ 21 | PYTHONDONTWRITEBYTECODE=1 \ 22 | # pip: 23 | PIP_NO_CACHE_DIR=1 \ 24 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 25 | PIP_DEFAULT_TIMEOUT=100 \ 26 | PIP_ROOT_USER_ACTION=ignore \ 27 | # tini: 28 | TINI_VERSION=v0.19.0 \ 29 | # poetry: 30 | POETRY_VERSION=1.6.1 \ 31 | POETRY_NO_INTERACTION=1 \ 32 | POETRY_VIRTUALENVS_CREATE=false \ 33 | POETRY_CACHE_DIR='/var/cache/pypoetry' \ 34 | POETRY_HOME='/usr/local' 35 | 36 | SHELL ["/bin/bash", "-eo", "pipefail", "-c"] 37 | 38 | # System deps (we don't use exact versions because it is hard to update them, 39 | # pin when needed): 40 | # hadolint ignore=DL3008 41 | RUN apt-get update && apt-get upgrade -y \ 42 | && apt-get install --no-install-recommends -y \ 43 | bash \ 44 | brotli \ 45 | build-essential \ 46 | curl \ 47 | gettext \ 48 | git \ 49 | libpq-dev \ 50 | wait-for-it \ 51 | # Installing `tini` utility: 52 | # https://github.com/krallin/tini 53 | # Get architecture to download appropriate tini release: 54 | # See https://github.com/wemake-services/wemake-django-template/issues/1725 55 | && dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \ 56 | && curl -o /usr/local/bin/tini -sSLO "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${dpkgArch}" \ 57 | && chmod +x /usr/local/bin/tini && tini --version \ 58 | # Installing `poetry` package manager: 59 | # https://github.com/python-poetry/poetry 60 | && curl -sSL 'https://install.python-poetry.org' | python - \ 61 | && poetry --version \ 62 | # Cleaning cache: 63 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 64 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* 65 | 66 | WORKDIR /code 67 | 68 | RUN groupadd -g "${GID}" -r web \ 69 | && useradd -d '/code' -g web -l -r -u "${UID}" web \ 70 | && chown web:web -R '/code' \ 71 | # Static and media files: 72 | && mkdir -p '/var/www/django/static' '/var/www/django/media' \ 73 | && chown web:web '/var/www/django/static' '/var/www/django/media' 74 | 75 | # Copy only requirements, to cache them in docker layer 76 | COPY --chown=web:web ./poetry.lock ./pyproject.toml /code/ 77 | 78 | # Project initialization: 79 | # hadolint ignore=SC2046 80 | RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \ 81 | echo "$DJANGO_ENV" \ 82 | && poetry version \ 83 | # Install deps: 84 | && poetry run pip install -U pip \ 85 | && poetry install \ 86 | $(if [ "$DJANGO_ENV" = 'production' ]; then echo '--only main'; fi) \ 87 | --no-interaction --no-ansi 88 | 89 | # This is a special case. We need to run this script as an entry point: 90 | COPY ./docker/django/entrypoint.sh /docker-entrypoint.sh 91 | 92 | # Setting up proper permissions: 93 | RUN chmod +x '/docker-entrypoint.sh' \ 94 | # Replacing line separator CRLF with LF for Windows users: 95 | && sed -i 's/\r$//g' '/docker-entrypoint.sh' 96 | 97 | # Running as non-root user: 98 | USER web 99 | 100 | # We customize how our app is loaded with the custom entrypoint: 101 | ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] 102 | 103 | 104 | # The following stage is only for production: 105 | # https://wemake-django-template.readthedocs.io/en/latest/pages/template/production.html 106 | FROM development_build AS production_build 107 | COPY --chown=web:web . /code 108 | -------------------------------------------------------------------------------- /docker/django/ci.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | # Initializing global variables and functions: 8 | : "${DJANGO_ENV:=development}" 9 | 10 | # Fail CI if `DJANGO_ENV` is not set to `development`: 11 | if [ "$DJANGO_ENV" != 'development' ]; then 12 | echo 'DJANGO_ENV is not set to development. Running tests is not safe.' 13 | exit 1 14 | fi 15 | 16 | pyclean () { 17 | # Cleaning cache: 18 | find . \ 19 | | grep -E \ 20 | '(__pycache__|\.(mypy_)?cache|\.hypothesis|\.perm|\.static|\.py[cod]$)' \ 21 | | xargs rm -rf \ 22 | || true 23 | } 24 | 25 | run_ci () { 26 | echo '[ci started]' 27 | set -x # we want to print commands during the CI process. 28 | 29 | # Testing filesystem and permissions: 30 | touch .perm && rm -f .perm 31 | touch '/var/www/django/media/.perm' && rm -f '/var/www/django/media/.perm' 32 | touch '/var/www/django/static/.perm' && rm -f '/var/www/django/static/.perm' 33 | 34 | # Checking `.env` files: 35 | dotenv-linter config/.env config/.env.template 36 | 37 | # Running linting for all python files in the project: 38 | flake8 . 39 | 40 | # Check HTML formatting: 41 | djlint --check server 42 | djlint --lint server 43 | 44 | # Running type checking, see https://github.com/typeddjango/django-stubs 45 | mypy manage.py server 46 | mypy tests 47 | 48 | # Architecture check: 49 | lint-imports 50 | 51 | # Running tests: 52 | pytest 53 | 54 | # Run checks to be sure we follow all django's best practices: 55 | python manage.py check --fail-level WARNING 56 | 57 | # Run checks to be sure settings are correct (production flag is required): 58 | DJANGO_ENV=production python manage.py check --deploy --fail-level WARNING 59 | 60 | # Check that staticfiles app is working fine: 61 | DJANGO_ENV=production DJANGO_COLLECTSTATIC_DRYRUN=1 \ 62 | python manage.py collectstatic --no-input --dry-run 63 | 64 | # Check that all migrations worked fine: 65 | python manage.py makemigrations --dry-run --check 66 | 67 | # Check that all migrations are backwards compatible: 68 | python manage.py lintmigrations --warnings-as-errors 69 | 70 | # Check production settings for gunicorn: 71 | gunicorn --check-config --config python:docker.django.gunicorn_config \ 72 | server.wsgi 73 | 74 | # Checking if all the dependencies are secure and do not have any 75 | # known vulnerabilities: 76 | safety check --full-report 77 | 78 | # Checking `pyproject.toml` file contents: 79 | poetry check 80 | 81 | # Checking dependencies status: 82 | pip check 83 | 84 | # Checking docs: 85 | doc8 -q docs 86 | 87 | # Checking `yaml` files: 88 | yamllint -d '{"extends": "default", "ignore": ".venv"}' -s . 89 | 90 | # Checking translation files, ignoring ordering and locations: 91 | polint -i location,unsorted locale 92 | 93 | # Also checking translation files for syntax errors: 94 | if find locale -name '*.po' -print0 | grep -q "."; then 95 | # Only executes when there is at least one `.po` file: 96 | dennis-cmd lint --errorsonly locale 97 | fi 98 | 99 | set +x 100 | echo '[ci finished]' 101 | } 102 | 103 | # Remove any cache before the script: 104 | pyclean 105 | 106 | # Clean everything up: 107 | trap pyclean EXIT INT TERM 108 | 109 | # Run the CI process: 110 | run_ci 111 | -------------------------------------------------------------------------------- /docker/django/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | readonly cmd="$*" 8 | 9 | : "${DJANGO_DATABASE_HOST:=db}" 10 | : "${DJANGO_DATABASE_PORT:=5432}" 11 | 12 | # We need this line to make sure that this container is started 13 | # after the one with postgres: 14 | wait-for-it \ 15 | --host=${DJANGO_DATABASE_HOST} \ 16 | --port=${DJANGO_DATABASE_PORT} \ 17 | --timeout=90 \ 18 | --strict 19 | 20 | # It is also possible to wait for other services as well: redis, elastic, mongo 21 | echo "Postgres ${DJANGO_DATABASE_HOST}:${DJANGO_DATABASE_PORT} is up" 22 | 23 | # Evaluating passed command (do not touch): 24 | # shellcheck disable=SC2086 25 | exec $cmd 26 | -------------------------------------------------------------------------------- /docker/django/gunicorn.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | # We are using `gunicorn` for production, see: 8 | # http://docs.gunicorn.org/en/stable/configure.html 9 | 10 | # Check that $DJANGO_ENV is set to "production", 11 | # fail otherwise, since it may break things: 12 | echo "DJANGO_ENV is $DJANGO_ENV" 13 | if [ "$DJANGO_ENV" != 'production' ]; then 14 | echo 'Error: DJANGO_ENV is not set to "production".' 15 | echo 'Application will not start.' 16 | exit 1 17 | fi 18 | 19 | export DJANGO_ENV 20 | 21 | # Run python specific scripts: 22 | # Running migrations in startup script might not be the best option, see: 23 | # docs/pages/template/production-checklist.rst 24 | python /code/manage.py migrate --noinput 25 | python /code/manage.py collectstatic --noinput --clear 26 | python /code/manage.py compilemessages 27 | 28 | # Precompress static files with brotli and gzip. 29 | # The list of ignored file types was taken from: 30 | # https://github.com/evansd/whitenoise 31 | find /var/www/django/static -type f \ 32 | ! -regex '^.+\.\(jpg\|jpeg\|png\|gif\|webp\|zip\|gz\|tgz\|bz2\|tbz\|xz\|br\|swf\|flv\|woff\|woff2\|3gp\|3gpp\|asf\|avi\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|wmv\)$' \ 33 | -exec brotli --force --best {} \+ \ 34 | -exec gzip --force --keep --best {} \+ 35 | 36 | # Start gunicorn: 37 | # Docs: http://docs.gunicorn.org/en/stable/settings.html 38 | # Make sure it is in sync with `django/ci.sh` check: 39 | /usr/local/bin/gunicorn \ 40 | --config python:docker.django.gunicorn_config \ 41 | server.wsgi 42 | -------------------------------------------------------------------------------- /docker/django/gunicorn_config.py: -------------------------------------------------------------------------------- 1 | # Gunicorn configuration file 2 | # https://docs.gunicorn.org/en/stable/configure.html#configuration-file 3 | # https://docs.gunicorn.org/en/stable/settings.html 4 | 5 | import multiprocessing 6 | 7 | bind = '0.0.0.0:8000' 8 | # Concerning `workers` setting see: 9 | # https://github.com/wemake-services/wemake-django-template/issues/1022 10 | workers = multiprocessing.cpu_count() * 2 + 1 11 | 12 | max_requests = 2000 13 | max_requests_jitter = 400 14 | 15 | log_file = '-' 16 | chdir = '/code' 17 | worker_tmp_dir = '/dev/shm' # noqa: S108 18 | -------------------------------------------------------------------------------- /docker/django/smoke.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # This file is executed to run sanity checks 4 | # on production image. 5 | # Please, don't put any test logic here. 6 | 7 | set -o errexit 8 | set -o nounset 9 | 10 | # Initializing global variables and functions: 11 | : "${DJANGO_ENV:=production}" 12 | 13 | # Fail CI if `DJANGO_ENV` is not set to `production`: 14 | if [ "$DJANGO_ENV" != 'production' ]; then 15 | echo 'DJANGO_ENV is not set to production. Running tests is not safe.' 16 | exit 1 17 | fi 18 | 19 | echo '[smoke started]' 20 | set -x 21 | 22 | # Ensure that Django sanity check works, 23 | # this also catches most invalid dependencies and configuration: 24 | python manage.py check --deploy --fail-level WARNING 25 | 26 | set +x 27 | echo '[smoke finished]' 28 | -------------------------------------------------------------------------------- /docker/docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # This compose-file is production only. So, it should not be called directly. 4 | # 5 | # Instead, it should be a part of your deployment strategy. 6 | # This setup is supposed to be used with `docker-swarm`. 7 | # See `./docs/pages/template/production.rst` docs. 8 | 9 | version: "3.8" 10 | services: 11 | caddy: 12 | image: "caddy:2.7.4" 13 | env_file: ./config/.env 14 | volumes: 15 | - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile # configuration 16 | - ./docker/caddy/ci.sh:/etc/ci.sh # test script 17 | - caddy-config:/config # configuration autosaves 18 | - caddy-data:/data # saving certificates 19 | - django-static:/var/www/django/static # serving django's statics 20 | - django-media:/var/www/django/media # serving django's media 21 | ports: 22 | - "80:80" 23 | - "443:443" 24 | depends_on: 25 | - web 26 | networks: 27 | - proxynet 28 | 29 | web: 30 | <<: &web 31 | # Image for production: 32 | image: "registry.gitlab.com/wemake.services/testing_homework:latest" 33 | build: 34 | target: production_build 35 | args: 36 | DJANGO_ENV: production 37 | 38 | volumes: 39 | - django-media:/var/www/django/media # since in dev it is app's folder 40 | - django-locale:/code/locale # since in dev it is app's folder 41 | 42 | command: bash ./docker/django/gunicorn.sh 43 | networks: 44 | - proxynet 45 | expose: 46 | - 8000 47 | 48 | # This task is an example of how to extend existing ones: 49 | # some_worker: 50 | # <<: *web 51 | # command: python manage.py worker_process 52 | # deploy: 53 | # replicas: 2 54 | 55 | networks: 56 | # Network for your proxy server and application to connect them, 57 | # do not use it for anything else! 58 | proxynet: 59 | 60 | volumes: 61 | django-media: 62 | django-locale: 63 | caddy-config: 64 | caddy-data: 65 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = wemake-django-template 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Starting with the docs 2 | 3 | We are using [Sphinx](http://www.sphinx-doc.org) to manage our documentation. 4 | If you have never worked with `Sphinx` this guide 5 | will cover the most common uses cases. 6 | 7 | 8 | ## Quickstart 9 | 10 | 1. Clone this repository 11 | 2. Install dependencies, [here's how to do it](pages/template/development.rst) 12 | 3. Run `cd docs && make html` 13 | 4. Open `_build/html/index.html` with your browser 14 | 15 | 16 | ## Where to go next 17 | 18 | Read the main page of the opened documentation website. It will guide you. 19 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/_templates/moreinfo.html: -------------------------------------------------------------------------------- 1 |

2 | Links 3 |

4 | 5 | 22 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # wemake-django-template documentation build configuration file, created by 2 | # sphinx-quickstart on Sat Sep 30 12:42:34 2017. 3 | # 4 | # This file is execfile()d with the current directory set to its 5 | # containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | # If extensions (or modules to document with autodoc) are in another directory, 14 | # add these directories to sys.path here. If the directory is relative to the 15 | # documentation root, use os.path.abspath to make it absolute, like shown here. 16 | 17 | import os 18 | import sys 19 | 20 | import django 21 | import tomli 22 | 23 | # We need `server` to be importable from here: 24 | sys.path.insert(0, os.path.abspath('..')) 25 | 26 | # Django setup, all deps must be present to succeed: 27 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') 28 | django.setup() 29 | 30 | 31 | # -- Project information ----------------------------------------------------- 32 | 33 | def _get_project_meta(): 34 | with open('../pyproject.toml', mode='rb') as pyproject: 35 | return tomli.load(pyproject)['tool']['poetry'] 36 | 37 | 38 | pkg_meta = _get_project_meta() 39 | project = str(pkg_meta['name']) 40 | author = str(pkg_meta['authors'][0]) 41 | copyright = author # noqa: WPS125 42 | 43 | # The short X.Y version 44 | version = str(pkg_meta['version']) 45 | # The full version, including alpha/beta/rc tags 46 | release = version 47 | 48 | 49 | # -- General configuration ------------------------------------------------ 50 | 51 | # If your documentation needs a minimal Sphinx version, state it here. 52 | needs_sphinx = '5.0' 53 | 54 | # Add any Sphinx extension module names here, as strings. They can be 55 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 56 | # ones. 57 | extensions = [ 58 | 'sphinx.ext.autodoc', 59 | 'sphinx.ext.doctest', 60 | 'sphinx.ext.todo', 61 | 'sphinx.ext.coverage', 62 | 'sphinx.ext.viewcode', 63 | 'sphinx.ext.githubpages', 64 | 'sphinx.ext.napoleon', 65 | 66 | # 3rd party, order matters: 67 | # https://github.com/wemake-services/wemake-django-template/issues/159 68 | 'sphinx_autodoc_typehints', 69 | ] 70 | 71 | # Add any paths that contain templates here, relative to this directory. 72 | templates_path = ['_templates'] 73 | 74 | # The suffix(es) of source filenames. 75 | # You can specify multiple suffix as a list of string: 76 | source_suffix = ['.rst'] 77 | 78 | # The master toctree document. 79 | master_doc = 'index' 80 | 81 | # The language for content autogenerated by Sphinx. Refer to documentation 82 | # for a list of supported languages. 83 | # 84 | # This is also used if you do content translation via gettext catalogs. 85 | # Usually you set "language" from the command line for these cases. 86 | language = 'en' 87 | 88 | # List of patterns, relative to source directory, that match files and 89 | # directories to ignore when looking for source files. 90 | # This patterns also effect to html_static_path and html_extra_path 91 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = 'sphinx' 95 | 96 | # If true, `todo` and `todoList` produce output, else they produce nothing. 97 | todo_include_todos = True 98 | 99 | 100 | # -- Options for HTML output ---------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = 'alabaster' 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | html_theme_options = {} 110 | 111 | # Add any paths that contain custom static files (such as style sheets) here, 112 | # relative to this directory. They are copied after the builtin static files, 113 | # so a file named "default.css" will overwrite the builtin "default.css". 114 | html_static_path = ['_static'] 115 | 116 | # Custom sidebar templates, must be a dictionary that maps document names 117 | # to template names. 118 | # 119 | # This is required for the alabaster theme 120 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 121 | html_sidebars = { 122 | '**': [ 123 | 'about.html', 124 | 'navigation.html', 125 | 'moreinfo.html', 126 | 'searchbox.html', 127 | ], 128 | } 129 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to wemake-django-template's documentation! 2 | ================================================== 3 | 4 | 5 | What this project is all about? 6 | The main idea of this project is to provide a fully configured 7 | template for ``django`` projects, where code quality, testing, 8 | documentation, security, and scalability are number one priorities. 9 | 10 | This template is a result of implementing 11 | `our processes `_, 12 | it should not be considered as an independent part. 13 | 14 | 15 | Goals 16 | ----- 17 | 18 | When developing this template we had several goals in mind: 19 | 20 | - Development environment should be bootstrapped easily, 21 | so we use ``docker-compose`` for that 22 | - Development should be consistent, so we use strict quality and style checks 23 | - Development, testing, and production should have the same environment, 24 | so again we develop, test, and run our apps in ``docker`` containers 25 | - Documentation and codebase are the only sources of truth 26 | 27 | 28 | Limitations 29 | ----------- 30 | 31 | This project implies that: 32 | 33 | - You are using ``docker`` for deployment 34 | - You are using Gitlab and Gitlab CI 35 | - You are not using any frontend assets in ``django``, 36 | you store your frontend separately 37 | 38 | 39 | Should I choose this template? 40 | ------------------------------ 41 | 42 | This template is oriented on big projects, 43 | when there are multiple people working on it for a long period of time. 44 | 45 | If you want to simply create a working prototype without all these 46 | limitations and workflows - feel free to choose any 47 | `other template `_. 48 | 49 | 50 | How to start 51 | ------------ 52 | 53 | You should start with reading the documentation. 54 | Reading order is important. 55 | 56 | There are multiple processes that you need to get familiar with: 57 | 58 | - First time setup phase: what system requirements you must fulfill, 59 | how to install dependencies, how to start your project 60 | - Active development phase: how to make changes, run tests, 61 | 62 | 63 | .. toctree:: 64 | :maxdepth: 2 65 | :caption: Setting things up: 66 | 67 | pages/template/overview.rst 68 | pages/template/development.rst 69 | pages/template/django.rst 70 | 71 | .. toctree:: 72 | :maxdepth: 2 73 | :caption: Quality assurance: 74 | 75 | pages/template/documentation.rst 76 | pages/template/linters.rst 77 | pages/template/testing.rst 78 | pages/template/security.rst 79 | pages/template/gitlab-ci.rst 80 | 81 | .. toctree:: 82 | :maxdepth: 2 83 | :caption: Production: 84 | 85 | pages/template/production-checklist.rst 86 | pages/template/production.rst 87 | 88 | .. toctree:: 89 | :maxdepth: 1 90 | :caption: Extras: 91 | 92 | pages/template/upgrading-template.rst 93 | pages/template/faq.rst 94 | pages/template/troubleshooting.rst 95 | 96 | 97 | Indexes and tables 98 | ================== 99 | 100 | * :ref:`genindex` 101 | * :ref:`modindex` 102 | * :ref:`search` 103 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=wemake-django-template 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/pages/project/glossary.rst: -------------------------------------------------------------------------------- 1 | Words that have special meaning in this context 2 | =============================================== 3 | 4 | Global context 5 | -------------- 6 | 7 | .. glossary:: 8 | 9 | Placeholder API 10 | Remove JSON API that we use as a fake service. 11 | https://jsonplaceholder.typicode.com/ 12 | 13 | Identity context 14 | ---------------- 15 | 16 | .. glossary:: 17 | 18 | lead_id 19 | Remote integer-based ID for our users generated by :term:`Placeholder API`. 20 | 21 | Pictures context 22 | ---------------- 23 | 24 | .. glossary:: 25 | 26 | Dashboard 27 | A place where we show :term:`picture` items. 28 | 29 | Picture 30 | A digital image that we fetch from :term:`Placeholder API` 31 | to show to our users. 32 | 33 | Favourites 34 | Locally saved links to pictures that user decided to have in their profile. 35 | -------------------------------------------------------------------------------- /docs/pages/template/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | Our development process is focused on high quality and development comfort. 5 | We use tools that are proven to be the best in class. 6 | 7 | There are two possible ways to develop your apps. 8 | 9 | 1. local development 10 | 2. development inside ``docker`` 11 | 12 | You can choose one or use both at the same time. 13 | How to choose what method should you use? 14 | 15 | Local development is much easier and much faster. 16 | You can choose it if you don't have too many infrastructure dependencies. 17 | That's a default option for the new projects. 18 | 19 | Choosing ``docker`` development means that you already have a complex 20 | setup of different technologies, containers, networks, etc. 21 | This is a default option for older and more complicated projects. 22 | 23 | 24 | Dependencies 25 | ------------ 26 | 27 | We use ``poetry`` to manage dependencies. 28 | So, please do not use ``virtualenv`` or ``pip`` directly. 29 | Before going any further, please, 30 | take a moment to read the `official documentation `_ 31 | about ``poetry`` to know some basics. 32 | 33 | If you are using ``docker`` then prepend ``docker compose run --rm web`` 34 | before any of those commands to execute them. 35 | 36 | Please, note that you don't need almost all of them with ``docker``. 37 | You can just skip this sub-section completely. 38 | Go right to `Development with docker`_. 39 | 40 | Installing dependencies 41 | ~~~~~~~~~~~~~~~~~~~~~~~ 42 | 43 | You do not need to run any of these command for ``docker`` based development, 44 | since it is already executed inside ``Dockerfile``. 45 | 46 | Please, note that ``poetry`` will automatically create a ``virtualenv`` for 47 | this project. It will use you current ``python`` version. 48 | To install all existing dependencies run: 49 | 50 | .. code:: bash 51 | 52 | poetry install 53 | 54 | To install dependencies for production use, you will need to run: 55 | 56 | .. code:: bash 57 | 58 | poetry install --no-dev 59 | 60 | And to activate ``virtualenv`` created by ``poetry`` run: 61 | 62 | .. code:: bash 63 | 64 | poetry shell 65 | 66 | Adding new dependencies 67 | ~~~~~~~~~~~~~~~~~~~~~~~ 68 | 69 | To add a new dependency you can run: 70 | 71 | - ``poetry add django`` to install ``django`` as a production dependency 72 | - ``poetry add --dev pytest`` to install ``pytest`` 73 | as a development dependency 74 | 75 | This command might be used with ``docker``. 76 | 77 | Updating poetry version 78 | ~~~~~~~~~~~~~~~~~~~~~~~ 79 | 80 | Package managers should also be pinned very strictly. 81 | We had a lot of problems in production 82 | because we were not pinning package manager versions. 83 | 84 | This can result in broken ``lock`` files, inconsistent installation process, 85 | bizarre bugs, and missing packages. You do not want to experience that! 86 | 87 | How can we have the same ``poetry`` version for all users in a project? 88 | That's where ``[build-system]`` tag shines. It specifies the exact version of 89 | your ``poetry`` installation that must be used for the project. 90 | Version mismatch will fail your build. 91 | 92 | When you want to update ``poetry``, you have to bump it in several places: 93 | 94 | 1. ``pyproject.toml`` 95 | 2. ``docker/django/Dockerfile`` 96 | 97 | Then you are fine! 98 | 99 | 100 | Development with docker 101 | ----------------------- 102 | 103 | To start development server inside ``docker`` you will need to run: 104 | 105 | .. code:: bash 106 | 107 | export DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 # enable buildkit 108 | docker compose build 109 | docker compose run --rm web python manage.py migrate 110 | docker compose up 111 | 112 | Running scripts inside docker 113 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 114 | 115 | As we have already mentioned inside the previous section 116 | we use ``docker compose run`` to run scripts inside docker. 117 | 118 | What do you need to know about it? 119 | 120 | 1. You can run anything you want: ``poetry``, ``python``, ``sh``, etc 121 | 2. Most likely it will have a permanent effect, due to ``docker volumes`` 122 | 3. You need to use ``--rm`` to automatically remove this container afterward 123 | 124 | **Note**: ``docker`` commands do not need to use ``virtualenv`` at all. 125 | 126 | Local development 127 | ----------------- 128 | 129 | When cloning a project for the first time you may 130 | need to configure it properly, 131 | see :ref:`django` section for more information. 132 | 133 | **Note**, that you will need to activate ``virtualenv`` created 134 | by ``poetry`` before running any of these commands. 135 | **Note**, that you only need to run these commands once per project. 136 | 137 | Local database 138 | ~~~~~~~~~~~~~~ 139 | 140 | When using local development environment without ``docker``, 141 | you will need a ``postgres`` up and running. 142 | To create new development database run 143 | (make sure that database and user names are correct for your case): 144 | 145 | .. code:: bash 146 | 147 | psql postgres -U postgres -f scripts/create_dev_database.sql 148 | 149 | Then migrate your database: 150 | 151 | .. code:: bash 152 | 153 | python manage.py migrate 154 | 155 | Running project 156 | ~~~~~~~~~~~~~~~ 157 | 158 | If you have reached this point, you should be able to run the project. 159 | 160 | .. code:: bash 161 | 162 | python manage.py runserver 163 | -------------------------------------------------------------------------------- /docs/pages/template/django.rst: -------------------------------------------------------------------------------- 1 | .. _django: 2 | 3 | Django 4 | ====== 5 | 6 | 7 | Configuration 8 | ------------- 9 | 10 | We share the same configuration structure for almost every possible 11 | environment. 12 | 13 | We use: 14 | 15 | - ``django-split-settings`` to organize ``django`` 16 | settings into multiple files and directories 17 | - ``.env`` files to store secret configuration 18 | - ``python-decouple`` to load ``.env`` files into ``django`` 19 | 20 | Components 21 | ~~~~~~~~~~ 22 | 23 | If you have some specific components like ``celery`` or ``mailgun`` installed, 24 | they could be configured in separate files. 25 | Just create a new file in ``server/settings/components/``. 26 | Then add it into ``server/settings/__init__.py``. 27 | 28 | Environments 29 | ~~~~~~~~~~~~ 30 | 31 | To run ``django`` on different environments just 32 | specify ``DJANGO_ENV`` environment variable. 33 | It must have the same name as one of the files 34 | from ``server/settings/environments/``. 35 | Then, values from this file will override other settings. 36 | 37 | Local settings 38 | ~~~~~~~~~~~~~~ 39 | 40 | If you need some specific local configuration tweaks, 41 | you can create file ``server/settings/environments/local.py.template`` 42 | to ``server/settings/environments/local.py``. 43 | It will be loaded into your settings automatically if exists. 44 | 45 | .. code:: bash 46 | 47 | cp server/settings/environments/local.py.template server/settings/environments/local.py 48 | 49 | See ``local.py.template`` version for the reference. 50 | 51 | 52 | Secret settings 53 | --------------- 54 | 55 | We share the same mechanism for secret settings for all our tools. 56 | We use ``.env`` files for ``django``, ``postgres``, ``docker``, etc. 57 | 58 | Initially, you will need to copy file 59 | ``config/.env.template`` to ``config/.env``: 60 | 61 | .. code:: bash 62 | 63 | cp config/.env.template config/.env 64 | 65 | When adding any new secret ``django`` settings you will need to: 66 | 67 | 1. Add new key and value to ``config/.env`` 68 | 2. Add new key without value to ``config/.env.template``, 69 | add a comment on how to get this value for other users 70 | 3. Add new variable inside ``django`` settings 71 | 4. Use ``python-decouple`` to load this ``env`` variable like so: 72 | ``MY_SECRET = config('MY_SECRET')`` 73 | 74 | 75 | Secret settings in production 76 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 77 | 78 | We do not store our secret settings inside our source code. 79 | All sensible settings are stored in ``config/.env`` file, 80 | which is not tracked by the version control. 81 | 82 | So, how do we store secrets? We store them as secret environment variables 83 | in `GitLab CI `_. 84 | Then we use `dump-env `_ 85 | to dump variables from both environment and ``.env`` file template. 86 | Then, this file is copied inside ``docker`` image and when 87 | this image is built - everything is ready for production. 88 | 89 | Here's an example: 90 | 91 | 1. We add a ``SECRET_DJANGO_SECRET_KEY`` variable to Gitlab CI secret variables 92 | 2. Then ``dump-env`` dumps ``SECRET_DJANGO_SECRET_KEY`` 93 | as ``DJANGO_SECRET_KEY`` and writes it to ``config/.env`` file 94 | 3. Then it is loaded by ``django`` inside the settings: 95 | ``SECRET_KEY = config('DJANGO_SECRET_KEY')`` 96 | 97 | However, there are different options to store secret settings: 98 | 99 | - `ansible-vault `_ 100 | - `git-secret `_ 101 | - `Vault `_ 102 | 103 | Depending on a project we use different tools. 104 | With ``dump-env`` being the default and the simplest one. 105 | 106 | 107 | Extensions 108 | ---------- 109 | 110 | We use different ``django`` extensions that make your life easier. 111 | Here's a full list of the extensions for both development and production: 112 | 113 | - `django-split-settings`_ - organize 114 | ``django`` settings into multiple files and directories. 115 | Easily override and modify settings. 116 | Use wildcards in settings file paths and mark settings files as optional 117 | - `django-axes`_ - keep track 118 | of failed login attempts in ``django`` powered sites 119 | - `django-csp`_ - `Content Security Policy`_ for ``django`` 120 | - `django-referrer-policy`_ - middleware implementing the `Referrer-Policy`_ 121 | - `django-health-check`_ - checks for various conditions and provides reports 122 | when anomalous behavior is detected 123 | - `django-add-default-value`_ - this django Migration Operation can be used to 124 | transfer a Fields default value to the database scheme 125 | - `django-deprecate-fields`_ - this package allows deprecating model fields and 126 | allows removing them in a backwards compatible manner 127 | - `django-migration-linter`_ - detect backward incompatible migrations for 128 | your django project 129 | - `zero-downtime-migrations`_ - apply ``django`` migrations on PostgreSql 130 | without long locks on tables 131 | 132 | Development only extensions: 133 | 134 | - `django-debug-toolbar`_ - a configurable set of panels that 135 | display various debug information about the current request/response 136 | - `django-querycount`_ - middleware that prints the number 137 | of DB queries to the runserver console 138 | - `nplusone`_ - auto-detecting the `n+1 queries problem`_ in ``django`` 139 | 140 | .. _django-split-settings: https://github.com/sobolevn/django-split-settings 141 | .. _django-axes: https://github.com/jazzband/django-axes 142 | .. _django-csp: https://github.com/mozilla/django-csp 143 | .. _`Content Security Policy`: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 144 | .. _django-referrer-policy: https://github.com/ubernostrum/django-referrer-policy 145 | .. _`Referrer-Policy`: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy 146 | .. _django-health-check: https://github.com/KristianOellegaard/django-health-check 147 | .. _django-add-default-value: https://github.com/3YOURMIND/django-add-default-value 148 | .. _django-deprecate-fields: https://github.com/3YOURMIND/django-deprecate-fields 149 | .. _django-migration-linter: https://github.com/3YOURMIND/django-migration-linter 150 | .. _zero-downtime-migrations: https://github.com/yandex/zero-downtime-migrations 151 | .. _django-debug-toolbar: https://github.com/jazzband/django-debug-toolbar 152 | .. _django-querycount: https://github.com/bradmontgomery/django-querycount 153 | .. _nplusone: https://github.com/jmcarp/nplusone 154 | .. _`n+1 queries problem`: https://stackoverflow.com/questions/97197/what-is-the-n1-select-query-issue 155 | 156 | 157 | Further reading 158 | --------------- 159 | 160 | - `django-split-settings tutorial `_ 161 | - `docker env-file docs `_ 162 | 163 | 164 | Django admin 165 | ~~~~~~~~~~~~ 166 | 167 | - `Django Admin Cookbook `_ 168 | -------------------------------------------------------------------------------- /docs/pages/template/documentation.rst: -------------------------------------------------------------------------------- 1 | Documentation 2 | ============= 3 | 4 | `We `_ write a lot of documentation. 5 | Since we believe, that documentation is a crucial factor 6 | which defines project success or failure. 7 | 8 | Here's how we write docs for ``django`` projects. 9 | 10 | 11 | Dependencies 12 | ------------ 13 | 14 | We are using ``sphinx`` as a documentation builder. 15 | We use ``sphinx.ext.napoleon`` to write 16 | pretty docstrings inside the source code. 17 | We also use ``sphinx_autodoc_typehints`` to inject type annotations into docs. 18 | 19 | We use ``pyproject.toml`` as the source of truth for our deps. 20 | All docs-related packages are stored under ``docs`` extra. 21 | 22 | To install them use: 23 | 24 | .. code:: bash 25 | 26 | poetry install -E docs 27 | 28 | 29 | Structure 30 | --------- 31 | 32 | We use a clear structure for this documentation. 33 | 34 | - ``pages/template`` contains docs 35 | from `wemake-django-template `_. 36 | These files should not be modified locally. 37 | If you have any kind of question or problems, 38 | just open an issue `on github `_ 39 | - ``pages/project`` contains everything related to the project itself. 40 | Usage examples, an auto-generated documentation from your source code, 41 | configuration, business, and project goals 42 | - ``documents`` contains different non-sphinx documents 43 | like ``doc`` files, spreadsheets, and mockups 44 | 45 | Please, do not mix it up. 46 | 47 | How to structure project docs 48 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 49 | 50 | It is a good practice to write a single ``rst`` document 51 | for every single ``py`` file. 52 | Obviously, ``rst`` structure fully copies the structure of your source code. 53 | This way it is very easy to navigate through the docs, 54 | since you already know the structure. 55 | 56 | For each ``django`` application we tend to create 57 | a file called ``index.rst`` which is considered 58 | the main file for the application. 59 | 60 | And ``pages/project/index.rst`` is the main file for the whole project. 61 | 62 | 63 | How to contribute 64 | ----------------- 65 | 66 | We enforce everyone to write clean and explaining documentation. 67 | However, there are several rules about writing styling. 68 | 69 | We are using `doc8 `_ to validate our docs. 70 | So, here's the command to do it: 71 | 72 | .. code:: bash 73 | 74 | doc8 ./docs 75 | 76 | This is also used in our CI process, so your build will fail 77 | if there are violations. 78 | 79 | 80 | Useful plugins 81 | -------------- 82 | 83 | Some ``sphinx`` plugins are not included, since they are very specific. 84 | However, they are very useful: 85 | 86 | - `sphinxcontrib-mermaid `_ - sphinx plugin to create general flowcharts, sequence and gantt diagrams 87 | - `sphinxcontrib-plantuml `_ - sphinx plugin to create UML diagrams 88 | - `nbsphinx `_ - sphinx plugin to embed ``ipython`` notebooks into your docs 89 | 90 | 91 | Further reading 92 | --------------- 93 | 94 | - `sphinx `_ 95 | - `sphinx with django `_ 96 | - `sphinx-autodoc-typehints `_ 97 | - `Architecture Decision Record (ADR) `_ 98 | - `adr-tools `_ 99 | -------------------------------------------------------------------------------- /docs/pages/template/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently asked questions 2 | ========================== 3 | 4 | Will you ever support drf / celery / flask / gevent? 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | No. This template is focused on bringing best practices to ``django`` 8 | projects. It only includes workflow and configuration for this framework. 9 | 10 | Other tools are not mandatory. And can easily be added by a developer. 11 | 12 | Will you have an build-time option to include or change anything? 13 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 14 | 15 | No, we believe that options bring inconsistencies to the project. 16 | You can also make the wrong choice. So, we are protecting you from that. 17 | 18 | You can only have options that are already present in this template. 19 | Fork it, if you do not agree with this policy. 20 | 21 | This code quality is unbearable! Can I turn it off? 22 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 23 | 24 | Of course, no one can stop you from that. 25 | But what the point in using this template then? 26 | 27 | Our code quality defined by this template is minimally acceptable. 28 | We know tools to make it even better. But they are not included. 29 | Since they are literally hardcore. 30 | -------------------------------------------------------------------------------- /docs/pages/template/gitlab-ci.rst: -------------------------------------------------------------------------------- 1 | Gitlab CI 2 | ========= 3 | 4 | We use ``Gitlab CI`` to build our containers, test it, 5 | and store them in the internal registry. 6 | 7 | These images are then pulled into the production servers. 8 | 9 | 10 | Configuration 11 | ------------- 12 | 13 | All configuration is done inside ``.gitlab-ci.yml``. 14 | 15 | 16 | Pipelines 17 | --------- 18 | 19 | We have two pipelines configured: for ``master`` and other branches. 20 | That's how it works: we only run testing for feature branches and do the whole 21 | building/testing/deploying process for the ``master`` branch. 22 | 23 | This allows us to speed up development process. 24 | 25 | 26 | Automatic dependencies update 27 | ----------------------------- 28 | 29 | You can use `dependabot `_ 30 | to enable automatic dependencies updates via Pull Requests to your repository. 31 | Similar to the original template repository: `list of pull requests `_. 32 | 33 | It is available to both Github and Gitlab. 34 | But, for Gitlab version you currently have to update your `.gitlab-ci.yml `_. 35 | 36 | 37 | Secret variables 38 | ---------------- 39 | 40 | If some real secret variables are required, then you can use `gitlab secrets `_. 41 | And these kind of variables are required *most* of the time. 42 | 43 | See :ref:`django` on how to use ``dump-env`` and ``gitlab-ci`` together. 44 | 45 | 46 | Documentation 47 | ------------- 48 | After each deploy from master branch this documentation compiles into nice looking html page. 49 | See `gitlab pages info `_. 50 | 51 | 52 | Further reading 53 | --------------- 54 | 55 | - `Container Registry `_ 56 | - `Gitlab CI/CD `_ 57 | -------------------------------------------------------------------------------- /docs/pages/template/linters.rst: -------------------------------------------------------------------------------- 1 | .. _linters: 2 | 3 | Linters 4 | ======= 5 | 6 | This project uses several linters to make coding style consistent. 7 | All configuration is stored inside ``setup.cfg``. 8 | 9 | 10 | wemake-python-styleguide 11 | ------------------------ 12 | 13 | ``wemake-python-styleguide`` is a ``flake8`` based plugin. 14 | And it is also the strictest and most opinionated python linter ever. 15 | See `wemake-python-styleguide `_ 16 | docs. 17 | 18 | Things that are included in the linting process: 19 | 20 | - `flake8 `_ is used a general tool for linting 21 | - `isort `_ is used to validate ``import`` order 22 | - `bandit `_ for static security checks 23 | - `eradicate `_ to find dead code 24 | - and more! 25 | 26 | Running linting process for all ``python`` files in the project: 27 | 28 | .. code:: bash 29 | 30 | flake8 . 31 | 32 | Extra plugins 33 | ~~~~~~~~~~~~~ 34 | 35 | We also use some extra plugins for ``flake8`` 36 | that are not bundled with ``wemake-python-styleguide``: 37 | 38 | - `flake8-pytest `_ - ensures that ``pytest`` best practices are used 39 | - `flake8-pytest-style `_ - ensures that ``pytest`` tests and fixtures are written in a single style 40 | - `flake8-django `_ - plugin to enforce best practices in a ``django`` project 41 | 42 | 43 | django-migration-linter 44 | ----------------------- 45 | 46 | We use ``django-migration-linter`` to find backward incompatible migrations. 47 | It allows us to write 0-downtime friendly code. 48 | 49 | See `django-migration-linter `_ 50 | docs, it contains a lot of useful information about ways and tools to do it. 51 | 52 | That's how this check is executed: 53 | 54 | .. code:: bash 55 | 56 | python manage.py lintmigrations 57 | 58 | Important note: you might want to exclude some packages with broken migrations. 59 | Sometimes, there's nothing we can do about it. 60 | 61 | 62 | yamllint 63 | -------- 64 | 65 | Is used to lint your ``yaml`` files. 66 | See `yamllint `_ docs. 67 | 68 | .. code:: bash 69 | 70 | yamllint -d '{"extends": "default", "ignore": ".venv"}' -s . 71 | 72 | 73 | djlint 74 | ------ 75 | 76 | Is used to lint and format your ``html`` files. 77 | See `djlint `_ docs. 78 | 79 | .. code:: bash 80 | 81 | djlint --check server 82 | djlint --lint server 83 | 84 | 85 | dotenv-linter 86 | ------------- 87 | 88 | Is used to lint your ``.env`` files. 89 | See `dotenv-linter `_ docs. 90 | 91 | .. code:: bash 92 | 93 | dotenv-linter config/.env config/.env.template 94 | 95 | 96 | polint and dennis 97 | ----------------- 98 | 99 | Are used to lint your ``.po`` files. 100 | See `polint `_ docs. 101 | Also see `dennis `_ docs. 102 | 103 | .. code:: bash 104 | 105 | polint -i location,unsorted locale 106 | dennis-cmd lint --errorsonly locale 107 | 108 | 109 | Packaging 110 | --------- 111 | 112 | We also use ``pip`` and ``poetry`` self checks to be sure 113 | that packaging works correctly. 114 | 115 | .. code:: bash 116 | 117 | poetry check && pip check 118 | 119 | 120 | Linters that are not included 121 | ----------------------------- 122 | 123 | Sometimes we use several other linters that are not included. 124 | That's because they require another technology stack to be installed 125 | or just out of scope. 126 | 127 | We also recommend to check the list of linters 128 | `recommended by wemake-python-styleguide `_. 129 | 130 | Here's the list of these linters. You may still find them useful. 131 | 132 | shellcheck 133 | ~~~~~~~~~~ 134 | 135 | This linter is used to lint your ``.sh`` files. 136 | See `shellcheck `_ docs. 137 | 138 | hadolint 139 | ~~~~~~~~ 140 | 141 | This linter is used to lint your ``Dockerfile`` syntax. 142 | See `hadolint `_ 143 | -------------------------------------------------------------------------------- /docs/pages/template/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | 5 | System requirements 6 | ------------------- 7 | 8 | - ``git`` with a version at least ``2.16`` or higher 9 | - ``docker`` with a version at least ``18.02`` or higher 10 | - ``docker-compose`` with a version at least ``1.21`` or higher 11 | - ``python`` with exact version, see ``pyproject.toml`` 12 | 13 | 14 | Architecture 15 | ------------ 16 | 17 | config 18 | ~~~~~~ 19 | 20 | - ``config/.env.template`` - a basic example of what keys must be contained in 21 | your ``.env`` file, this file is committed to VCS 22 | and must not contain private or secret values 23 | - ``config/.env`` - main file for secret configuration, 24 | contains private and secret values, should not be committed to VCS 25 | 26 | root project 27 | ~~~~~~~~~~~~ 28 | 29 | - ``README.md`` - main readme file, it specifies the entry 30 | point to the project's documentation 31 | - ``.dockerignore`` - specifies what files should not be 32 | copied to the ``docker`` image 33 | - ``.editorconfig`` - file with format specification. 34 | You need to install the required plugin for your IDE in order to enable it 35 | - ``.gitignore`` - file that specifies 36 | what should we commit into the repository and we should not 37 | - ``.gitlab-ci.yml`` - GitLab CI configuration file. 38 | It basically defines what to do with your project 39 | after pushing it to the repository. Currently it is used for testing 40 | and releasing a ``docker`` image 41 | - ``docker-compose.yml`` - this the file specifies ``docker`` services 42 | that are needed for development and testing 43 | - ``docker-compose.override.yml`` - local override for ``docker-compose``. 44 | Is applied automatically and implicitly when 45 | no arguments provided to ``docker-compose`` command 46 | - ``manage.py`` - main file for your ``django`` project. 47 | Used as an entry point for the ``django`` project 48 | - ``pyproject.toml`` - main file of the project. 49 | It defines the project's dependencies. 50 | - ``poetry.lock`` - lock file for dependencies. 51 | It is used to install exactly the same versions of dependencies on each build 52 | - ``setup.cfg`` - configuration file, that is used by all tools in this project 53 | - ``locale/`` - helper folder, that is used to store locale data, 54 | empty by default 55 | - ``scripts/`` - helper folder, that contains various development scripts 56 | and teardown for local development 57 | 58 | server 59 | ~~~~~~ 60 | 61 | - ``server/__init__.py`` - package definition, empty file 62 | - ``server/urls.py`` - ``django`` `urls definition `_ 63 | - ``server/wsgi.py`` - ``django`` `wsgi definition `_ 64 | - ``server/asgi.py`` - ``django`` `asgi definition `_ 65 | - ``server/apps/`` - place to put all your apps into 66 | - ``server/apps/main`` - ``django`` application, used as an example, 67 | could be removed 68 | - ``server/settings`` - settings defined with ``django-split-settings``, 69 | see this `tutorial `_ 70 | for more information 71 | - ``server/templates`` - external folder for ``django`` templates, 72 | used for simple files as ``robots.txt`` and so on 73 | 74 | docker 75 | ~~~~~~ 76 | 77 | - ``docker/docker-compose.prod.yml`` - additional service definition file 78 | used for production 79 | - ``docker/django/Dockerfile`` - ``django`` container definition, 80 | used both for development and production 81 | - ``docker/django/entrypoint.sh`` - entry point script that is used 82 | when ``django`` container is starting 83 | - ``docker/django/gunicorn_config.py`` - that's how we 84 | configure ``gunicorn`` runner 85 | - ``docker/django/gunicorn.sh`` - production script 86 | for ``django`` using ``gunicorn`` 87 | - ``docker/django/ci.sh`` - file that specifies all possible checks that 88 | we execute during our CI process for django 89 | - ``docker/caddy/Caddyfile`` - configuration file for Caddy webserver 90 | - ``docker/caddy/ci.sh`` - file that specifies all possible checks that 91 | we execute during our CI process for caddy 92 | 93 | tests 94 | ~~~~~ 95 | 96 | - ``tests/test_server`` - tests that ensures that basic ``django`` 97 | stuff is working, should not be removed 98 | - ``tests/test_apps/test_main`` - example tests for the ``django`` app, 99 | could be removed 100 | - ``tests/conftest.py`` - main configuration file for ``pytest`` runner 101 | 102 | docs 103 | ~~~~ 104 | 105 | - ``docs/Makefile`` - command file that builds the documentation for Unix 106 | - ``docs/make.bat`` - command file for Windows 107 | - ``docs/conf.py`` - ``sphinx`` configuration file 108 | - ``docs/index.rst`` - main documentation file, used as an entry point 109 | - ``docs/pages/project`` - folder that will contain 110 | documentation written by you! 111 | - ``docs/pages/template`` - folder that contains documentation that 112 | is common for each project built with this template 113 | - ``docs/documents`` - folder that should contain any documents you have: 114 | spreadsheets, images, requirements, presentations, etc 115 | - ``docs/README.rst`` - helper file for this directory, 116 | just tells what to do next 117 | 118 | 119 | Container internals 120 | ------------------- 121 | 122 | We use the ``docker-compose`` to link different containers together. 123 | We also utilize different ``docker`` networks to control access. 124 | 125 | Some containers might have long starting times, for example: 126 | 127 | - ``postgres`` 128 | - ``rabbitmq`` 129 | - frontend, like ``node.js`` 130 | 131 | We start containers with ``tini``. 132 | Because this way we have a proper signal handling 133 | and eliminate zombie processes. 134 | Read the `official docs `_ to know more. 135 | -------------------------------------------------------------------------------- /docs/pages/template/production-checklist.rst: -------------------------------------------------------------------------------- 1 | .. _`going-to-production`: 2 | 3 | Going to production 4 | =================== 5 | 6 | This section covers everything you need to know before going to production. 7 | 8 | 9 | Django 10 | ------ 11 | 12 | Checks 13 | ~~~~~~ 14 | 15 | Before going to production make sure you have checked everything: 16 | 17 | 1. Migrations are up-to-date 18 | 2. Static files are all present 19 | 3. There are no security or other ``django`` warnings 20 | 21 | Checking migrations, static files, 22 | and security is done inside ``ci.sh`` script. 23 | 24 | We check that there are no unapplied migrations: 25 | 26 | .. code :: bash 27 | 28 | python manage.py makemigrations --dry-run --check 29 | 30 | If you have forgotten to create a migration and changed the model, 31 | you will see an error on this line. 32 | 33 | We also check that static files can be collected: 34 | 35 | .. code :: bash 36 | 37 | DJANGO_ENV=production python manage.py collectstatic --no-input --dry-run 38 | 39 | However, this check does not cover all the cases. 40 | Sometimes ``ManifestStaticFilesStorage`` will fail on real cases, 41 | but will pass with ``--dry-run`` option. 42 | You can disable ``--dry-run`` option if you know what you are doing. 43 | Be careful with this option, when working with auto-uploading 44 | your static files to any kind of CDNs. 45 | 46 | That's how we check ``django`` warnings: 47 | 48 | .. code:: bash 49 | 50 | DJANGO_ENV=production python manage.py check --deploy --fail-level WARNING 51 | 52 | These warnings are raised by ``django`` 53 | when it detects any configuration issues. 54 | 55 | This command should give not warnings or errors. 56 | It is bundled into ``docker``, so the container will not work with any warnings. 57 | 58 | Static and media files 59 | ~~~~~~~~~~~~~~~~~~~~~~ 60 | 61 | We use ``/var/www/django`` folder to store our media 62 | and static files in production as ``/var/www/django/static`` 63 | and ``/var/www/django/media``. 64 | Docker uses these two folders as named volumes. 65 | And later these volumes are also mounted to ``caddy`` 66 | with ``ro`` mode so it possible to read their contents. 67 | 68 | To find the exact location of these files on your host 69 | you will need to do the following: 70 | 71 | .. code:: bash 72 | 73 | docker volume ls # to find volumes' names 74 | docker volume inspect VOLUME_NAME 75 | 76 | Sometimes storing your media files inside a container is not a good idea. 77 | Use ``CDN`` when you have a lot of user content 78 | or it is very important not to lose it. 79 | There are `helper libraries `_ 80 | to bind ``django`` and these services. 81 | 82 | If you don't need ``media`` files support, just remove the volumes. 83 | 84 | Migrations 85 | ~~~~~~~~~~ 86 | 87 | We do run migration in the ``gunicorn.sh`` by default. 88 | Why do we do this? Because that's probably the easiest way to do it. 89 | But it clearly has some disadvantages: 90 | 91 | - When scaling your container for multiple nodes you will have multiple 92 | threads running the same migrations. And it might be a problem since 93 | migrations do not guarantee that it will work this way. 94 | - You can perform some operations multiple times 95 | - Possible other evil things may happen 96 | 97 | So, what to do in this case? 98 | Well, you can do whatever it takes to run migrations in a single thread. 99 | For example, you can create a separate container to do just that. 100 | Other options are fine as well. 101 | 102 | 103 | Postgres 104 | -------- 105 | 106 | Sometimes using ``postgres`` inside a container 107 | `is not a good idea `_. 108 | So, what should be done in this case? 109 | 110 | First of all, move your database ``docker`` service definition 111 | inside ``docker-compose.override.yml``. 112 | Doing so will not affect development, 113 | but will remove database service from production. 114 | Next, you will need to specify `extra_hosts `_ 115 | to contain your ``postgresql`` address. 116 | Lastly, you would need to add new hosts to ``pg_hba.conf``. 117 | 118 | `Here `_ 119 | is a nice tutorial about this topic. 120 | 121 | 122 | Caddy 123 | ----- 124 | 125 | Let's Encrypt 126 | ~~~~~~~~~~~~~ 127 | 128 | We are using ``Caddy`` and ``Let's Encrypt`` for HTTPS. 129 | The Caddy webserver used in the default configuration will get 130 | you a valid certificate from ``Let's Encrypt`` and update it automatically. 131 | All you need to do to enable this is to make sure 132 | that your DNS records are pointing to the server Caddy runs on. 133 | 134 | Read more: `Automatic HTTPS `_ 135 | in Caddy docs. 136 | 137 | Caddyfile validation 138 | ~~~~~~~~~~~~~~~~~~~~ 139 | 140 | You can also run ``-validate`` command to validate ``Caddyfile`` contents. 141 | 142 | Here's it would look like: 143 | 144 | .. code:: bash 145 | 146 | docker compose-f docker-compose.yml -f docker/docker-compose.prod.yml 147 | run --rm caddy -validate 148 | 149 | This check is not included in the pipeline by default, 150 | because it is quite long to start all the machinery for this single check. 151 | 152 | Disabling HTTPS 153 | ~~~~~~~~~~~~~~~ 154 | 155 | You would need to `disable `_ 156 | ``https`` inside ``Caddy`` and in production settings for Django. 157 | Because Django itself also redirects to ``https``. 158 | See `docs `_. 159 | 160 | You would also need to disable ``manage.py check`` 161 | in ``docker/ci.sh``. 162 | Otherwise, your application won't start, 163 | it would not pass ``django``'s security checks. 164 | 165 | Disabling WWW subdomain 166 | ~~~~~~~~~~~~~~~~~~~~~~~ 167 | 168 | If you for some reason do not require ``www.`` subdomain, 169 | then delete ``www.{$DOMAIN_NAME}`` section from ``Caddyfile``. 170 | 171 | Third-Level domains 172 | ~~~~~~~~~~~~~~~~~~~ 173 | 174 | You have to disable ``www`` subdomain if 175 | your app works on third-level domains like: 176 | 177 | - ``kira.wemake.services`` 178 | - ``support.myapp.com`` 179 | 180 | Otherwise, ``Caddy`` will server redirects to ``www.example.yourdomain.com``. 181 | 182 | 183 | Further reading 184 | --------------- 185 | 186 | - Django's deployment `checklist `_ 187 | -------------------------------------------------------------------------------- /docs/pages/template/production.rst: -------------------------------------------------------------------------------- 1 | Production 2 | ========== 3 | 4 | We use different tools and setup for production. 5 | We do not fully provide this part with the template. Why? 6 | 7 | 1. It requires a lot of server configuration 8 | 2. It heavily depends on your needs: performance, price, technology, etc 9 | 3. It is possible to show some vulnerable parts to possible attackers 10 | 11 | So, you will need to deploy your application by yourself. 12 | Here, we would like to cover some basic things that are not changed 13 | from deployment strategy. 14 | 15 | The easiest deployment strategy for small apps is ``docker-compose`` and 16 | ``systemd`` inside a host operating system. 17 | 18 | 19 | Production configuration 20 | ------------------------ 21 | 22 | You will need to specify extra configuration 23 | to run ``docker-compose`` in production. 24 | Since production build also uses ``caddy``, 25 | which is not required into the development build. 26 | 27 | .. code:: bash 28 | 29 | docker compose-f docker-compose.yml -f docker/docker-compose.prod.yml up 30 | 31 | 32 | Pulling pre-built images 33 | ------------------------ 34 | 35 | You will need to pull pre-built images from ``Gitlab`` to run them. 36 | How to do that? 37 | 38 | The first step is to create a personal access token for this service. 39 | Then, login into your registry with: 40 | 41 | .. code:: bash 42 | 43 | docker login registry.gitlab.your.domain 44 | 45 | And now you are ready to pull your images: 46 | 47 | .. code:: bash 48 | 49 | docker pull your-image:latest 50 | 51 | See `official Gitlab docs `_. 52 | 53 | 54 | Updating already running service 55 | -------------------------------- 56 | 57 | If you need to update an already running service, 58 | them you will have to use ``docker service update`` 59 | or ``docker stack deploy``. 60 | 61 | Updating existing `service `_. 62 | Updating existing `stack `_. 63 | 64 | Zero-Time Updates 65 | ~~~~~~~~~~~~~~~~~ 66 | 67 | Zero-Time Updates can be tricky. 68 | You need to create containers with the new code, update existing services, 69 | wait for the working sessions to be completed, and to shut down old 70 | containers. 71 | 72 | 73 | Further reading 74 | --------------- 75 | 76 | - Production with `docker compose `_ 77 | - `Full tutorial `_ 78 | -------------------------------------------------------------------------------- /docs/pages/template/security.rst: -------------------------------------------------------------------------------- 1 | Security 2 | ======== 3 | 4 | Security is our first priority. 5 | We try to make projects as secure as possible. 6 | We use a lot of 3rd party tools to achieve that. 7 | 8 | 9 | Django 10 | ------ 11 | 12 | Django has a lot of `security-specific settings `_ 13 | that are all turned on by default in this template. 14 | 15 | We also :ref:`enforce ` all the best practices 16 | by running ``django`` checks inside CI for each commit. 17 | 18 | We also use a set of custom ``django`` apps 19 | to enforce even more security rules: 20 | 21 | - `django-axes `_ to track and ban repeating access requests 22 | - `django-csp `_ to enforce `Content-Security Policy `_ for our webpages 23 | - `django-http-referrer-policy `_ to enforce `Referrer Policy `_ for our webpages 24 | 25 | And there are also some awesome extensions that are not included: 26 | 27 | - `django-honeypot `_ - django application that provides utilities for preventing automated form spam 28 | 29 | Passwords 30 | ~~~~~~~~~ 31 | 32 | We use strong algorithms for password hashing: 33 | ``bcrypt``, ``PBKDF2`` and ``Argon2`` which are known to be secure enough. 34 | 35 | 36 | Dependencies 37 | ------------ 38 | 39 | We use `poetry `_ which ensures 40 | that all the dependencies hashes match during the installation process. 41 | Otherwise, the build will fail. 42 | So, it is almost impossible to replace an already existing package 43 | with a malicious one. 44 | 45 | We also use `safety `_ 46 | to analyze vulnerable dependencies to prevent the build 47 | to go to the production with known unsafe dependencies. 48 | 49 | .. code:: bash 50 | 51 | safety check 52 | 53 | We also use `Github security alerts `_ 54 | for our main template repository. 55 | 56 | 57 | Static analysis 58 | --------------- 59 | 60 | We use ``wemake-python-styleguide`` which 61 | includes `bandit `_ security checks inside. 62 | 63 | You can also install `pyt `_ 64 | which is not included by default. 65 | It will include even more static checks for 66 | ``sql`` injections, ``xss`` and others. 67 | 68 | 69 | Dynamic analysis 70 | ---------------- 71 | 72 | You can monitor your running application to detect anomalous activities. 73 | Tools to consider: 74 | 75 | - `dagda `_ - a tool to perform static analysis of known vulnerabilities, trojans, viruses, malware & other malicious threats in docker images/containers and to monitor the docker daemon and running docker containers for detecting anomalous activities 76 | 77 | All the tools above are not included into this template. 78 | You have to install them by yourself. 79 | 80 | 81 | Secrets 82 | ------- 83 | 84 | We store secrets separately from code. So, it is harder for them to leak. 85 | However, we encourage to use tools like 86 | `truffleHog `_ or `detect-secrets `_ inside your workflow. 87 | 88 | You can also turn on `Gitlab secrets checker `_ which we highly recommend. 89 | 90 | 91 | Audits 92 | ------ 93 | 94 | The only way to be sure that your app is secure 95 | is to constantly audit it in production. 96 | 97 | There are different tools to help you: 98 | 99 | - `twa `_ - tiny web auditor that has a lot of security checks for the webpages 100 | - `XSStrike `_ - automated tool to check that your application is not vulnerable to ``xss`` errors 101 | - `docker-bench `_ - a script that checks for dozens of common best-practices around deploying Docker containers in production 102 | - `lynis `_ - a battle-tested security tool for systems running Linux, macOS, or Unix-based operating system 103 | - `trivy `_ - a simple and comprehensive vulnerability scanner for containers 104 | 105 | But, even after all you attempts to secure your application, 106 | it **won't be 100% safe**. Do not fall into this false feeling of security. 107 | 108 | 109 | Further reading 110 | --------------- 111 | 112 | - `Open Web Application Security Project `_ 113 | - `Docker security `_ 114 | - `AppArmor `_ and `bane `_ 115 | -------------------------------------------------------------------------------- /docs/pages/template/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | We try to keep our quality standards high. 5 | So, we use different tools to make this possible. 6 | 7 | We use `mypy `_ for optional 8 | static typing. 9 | We run tests with `pytest `_ framework. 10 | 11 | 12 | pytest 13 | ------ 14 | 15 | ``pytest`` is the main tool for test discovery, collection, and execution. 16 | It is configured inside ``setup.cfg`` file. 17 | 18 | We use a lot of ``pytest`` plugins that enhance our development experience. 19 | List of these plugins is available inside ``pyproject.toml`` file. 20 | 21 | Running: 22 | 23 | .. code:: bash 24 | 25 | pytest 26 | 27 | We also have some options that are set on each run via ``--addopts`` 28 | inside the ``setup.cfg`` file. 29 | 30 | Plugins 31 | ~~~~~~~ 32 | 33 | We use different ``pytest`` plugins to make our testing process better. 34 | Here's the full list of things we use: 35 | 36 | - `pytest-django`_ - plugin that introduce a lot of ``django`` specific 37 | helpers, fixtures, and configuration 38 | - `django-test-migrations`_ - plugin to test Django migrations and their order 39 | - `pytest-cov`_ - plugin to measure test coverage 40 | - `pytest-randomly`_ - plugin to execute tests in random order and 41 | also set predictable random seed, so you can easily debug 42 | what went wrong for tests that rely on random behavior 43 | - `pytest-deadfixtures`_ - plugin to find unused or duplicate fixtures 44 | - `pytest-timeout`_ - plugin to raise errors for tests 45 | that take too long to finish, this way you can control test execution speed 46 | 47 | .. _pytest-django: https://github.com/pytest-dev/pytest-django 48 | .. _django-test-migrations: https://github.com/wemake-services/django-test-migrations 49 | .. _pytest-cov: https://github.com/pytest-dev/pytest-cov 50 | .. _pytest-randomly: https://github.com/pytest-dev/pytest-randomly 51 | .. _pytest-deadfixtures: https://github.com/jllorencetti/pytest-deadfixtures 52 | .. _pytest-timeout: https://pypi.org/project/pytest-timeout 53 | 54 | Tweaking tests performance 55 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 56 | 57 | There are several options you can provide or remove to make your tests faster: 58 | 59 | - You can use ``pytest-xdist`` together with 60 | ``-n auto`` to schedule several numbers of workers, 61 | sometimes when there are a lot of tests it may increase the testing speed. 62 | But on a small project with a small amount of test it just 63 | gives you an overhead, so removing it (together with ``--boxed``) 64 | will boost your testing performance 65 | - If there are a lot of tests with database access 66 | it may be wise to add 67 | `--reuse-db option `_, 68 | so ``django`` won't recreate database on each test 69 | - If there are a lot of migrations to perform you may also add 70 | `--nomigrations option `_, 71 | so ``django`` won't run all the migrations 72 | and instead will inspect and create models directly 73 | - Removing ``coverage``. Sometimes that an option. 74 | When running tests in TDD style why would you need such a feature? 75 | So, coverage will be calculated when you will ask for it. 76 | That's a huge speed up 77 | - Removing linters. Sometimes you may want to split linting and testing phases. 78 | This might be useful when you have a lot of tests, and you want to run 79 | linters before, so it won't fail your complex testing pyramid with a simple 80 | whitespace violation 81 | 82 | 83 | mypy 84 | ---- 85 | 86 | Running ``mypy`` is required before any commit: 87 | 88 | .. code:: bash 89 | 90 | mypy server tests/**/*.py 91 | 92 | This will eliminate a lot of possible ``TypeError`` and other issues 93 | in both ``server/`` and ``tests/`` directories. 94 | We use ``tests/**/*.py`` because ``tests/`` is not a python package, 95 | so it is not importable. 96 | 97 | However, this will not make code 100% safe from errors. 98 | So, both the testing and review process are still required. 99 | 100 | ``mypy`` is configured via ``setup.cfg``. 101 | Read the `docs `_ 102 | for more information. 103 | 104 | We also use `django-stubs `_ 105 | to type ``django`` internals. 106 | This package is optional and can be removed, 107 | if you don't want to type your ``django`` for some reason. 108 | -------------------------------------------------------------------------------- /docs/pages/template/troubleshooting.rst: -------------------------------------------------------------------------------- 1 | Troubleshooting 2 | =============== 3 | 4 | This section is about some of the problems you may encounter and 5 | how to solve these problems. 6 | 7 | 8 | Docker 9 | ------ 10 | 11 | Pillow 12 | ~~~~~~ 13 | 14 | If you want to install ``Pillow`` that you should 15 | add this to dockerfile and rebuild image: 16 | 17 | - ``RUN apk add jpeg-dev zlib-dev`` 18 | - ``LIBRARY_PATH=/lib:/usr/lib /bin/sh -c "poetry install ..."`` 19 | 20 | See ``_ 21 | 22 | Root owns build artifacts 23 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 24 | 25 | This happens on some systems. 26 | It happens because build happens in ``docker`` as the ``root`` user. 27 | The fix is to pass current ``UID`` to ``docker``. 28 | See ``_. 29 | 30 | MacOS performance 31 | ~~~~~~~~~~~~~~~~~ 32 | 33 | If you use the MacOS you 34 | know that you have problems with disk performance. 35 | Starting and restarting an application is slower than with Linux 36 | (it's very noticeable for project with large codebase). 37 | For particular solve this problem add ``:delegated`` to each 38 | your volumes in ``docker-compose.yml`` file. 39 | 40 | .. code:: yaml 41 | 42 | volumes: 43 | - pgdata:/var/lib/postgresql/data:delegated 44 | 45 | For more information, you can look at the 46 | `docker documents `_ 47 | and a good `article `_. 48 | -------------------------------------------------------------------------------- /docs/pages/template/upgrading-template.rst: -------------------------------------------------------------------------------- 1 | Upgrading template 2 | ================== 3 | 4 | Upgrading your project to be up-to-date with this template is a primary goal. 5 | This is achieved by manually applying ``diff`` to your existing code. 6 | 7 | ``diff`` can be viewed from the project's ``README.md``. 8 | See `an example `_. 9 | 10 | When the upgrade is applied just change the commit hash in your template 11 | to the most recent one. 12 | 13 | 14 | Versions 15 | -------- 16 | 17 | Sometimes, when we break something heavily, we create a version. 18 | That's is required for our users, so they can use old releases to create 19 | projects as they used to be a long time ago. 20 | 21 | However, we do not officially support older versions. 22 | And we do not recommend to use them. 23 | 24 | A full list of versions can be `found here `_. 25 | 26 | 27 | Migration guides 28 | ---------------- 29 | 30 | Each time we create a new version, we also provide a migration guide. 31 | What is a migration guide? 32 | It is something you have to do to your project 33 | other than just copy-pasting diffs from new versions. 34 | 35 | Goodbye, pipenv! 36 | ~~~~~~~~~~~~~~~~ 37 | 38 | This version requires a manual migration step. 39 | 40 | 1. You need to install ``poetry`` 41 | 2. You need to create a new ``pyproject.toml`` file with ``poetry init`` 42 | 3. You need to adjust name, version, description, and authors meta fields 43 | 4. You need to copy-paste dependencies from ``Pipfile`` to ``pyproject.toml`` 44 | 5. You need to set correct version for each dependency in the list, 45 | use ``"^x.y"`` `notation `_ 46 | 6. You need to adjust ``[build-system]`` tag and ``POETRY_VERSION`` variable 47 | to fit your ``poetry`` version 48 | 7. Create ``poetry.lock`` file with ``poetry lock`` 49 | 50 | It should be fine! You may, however, experience some bugs related to different 51 | dependency version resolution mechanisms. But, ``poetry`` does it better. 52 | -------------------------------------------------------------------------------- /locale/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/locale/.gitkeep -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | 7 | def main() -> None: 8 | """ 9 | Main function. 10 | 11 | It does several things: 12 | 1. Sets default settings module, if it is not set 13 | 2. Warns if Django is not installed 14 | 3. Executes any given command 15 | """ 16 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') 17 | 18 | try: 19 | from django.core import management # noqa: WPS433 20 | except ImportError: 21 | raise ImportError( 22 | "Couldn't import Django. Are you sure it's installed and " + 23 | 'available on your PYTHONPATH environment variable? Did you ' + 24 | 'forget to activate a virtual environment?', 25 | ) 26 | 27 | management.execute_from_command_line(sys.argv) 28 | 29 | 30 | if __name__ == '__main__': 31 | main() 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "testing_homework" 3 | description = "Testing Homework for https://education.borshev.com/python-testing" 4 | version = "1.0.0" 5 | readme = "README.md" 6 | authors = ["Nikita Sobolev "] 7 | classifiers = [ 8 | "Private :: Do not Upload", 9 | ] 10 | 11 | 12 | [tool.poetry.dependencies] 13 | python = "3.11.5" 14 | 15 | django = { version = "^4.2", extras = ["argon2"] } 16 | django-split-settings = "^1.2" 17 | django-axes = "^6.1" 18 | django-csp = "^3.7" 19 | django-health-check = "^3.16" 20 | django-http-referrer-policy = "^1.1" 21 | django-permissions-policy = "^4.17" 22 | django-stubs-ext = "^4.2" 23 | django-ratelimit = "3.0.1" # API change in `^4.x` 24 | 25 | psycopg2-binary = "^2.9" 26 | gunicorn = "^21.2" 27 | python-decouple = "^3.8" 28 | structlog = "^23.1" 29 | requests = "^2.28" 30 | attrs = "^23.1" 31 | pydantic = "^2.3" 32 | punq = "^0.6" 33 | 34 | [tool.poetry.group.dev.dependencies] 35 | django-debug-toolbar = "^4.2" 36 | django-querycount = "^0.8" 37 | django-migration-linter = "^5.0" 38 | django-extra-checks = "^0.13" 39 | nplusone = "^1.0" 40 | 41 | wemake-python-styleguide = "^0.18" 42 | flake8-pytest-style = "^1.7" 43 | flake8-logging-format = "^0.9" 44 | nitpick = "^0.34" 45 | doc8 = "^1.0" 46 | 47 | pytest = "^7.4" 48 | pytest-django = "^4.5" 49 | pytest-cov = "^4.0" 50 | django-coverage-plugin = "^3.1" 51 | covdefaults = "^2.3" 52 | pytest-randomly = "^3.15" 53 | pytest-timeout = "^2.1" 54 | hypothesis = "^6.84" 55 | django-test-migrations = "^1.3" 56 | 57 | django-stubs = { version = "^4.2", extras = ["compatible-mypy"] } 58 | types-requests = "^2.31" 59 | 60 | djlint = "^1.32" 61 | yamllint = "^1.32" 62 | safety = "^2.3" 63 | dotenv-linter = "^0.4" 64 | polint = "^0.4" 65 | dennis = "^1.1" 66 | dump-env = "^1.3" 67 | ipython = "^8.15" 68 | import-linter = "^1.11" 69 | 70 | [tool.poetry.group.docs] 71 | optional = true 72 | 73 | [tool.poetry.group.docs.dependencies] 74 | sphinx = "^7.2" 75 | sphinx-autodoc-typehints = "^1.21" 76 | tomli = "^2.0" 77 | 78 | 79 | [build-system] 80 | requires = ["poetry-core>=1.6"] 81 | build-backend = "poetry.core.masonry.api" 82 | 83 | 84 | [tool.djlint] 85 | ignore = "H006,H030,H031,T002" 86 | include = "H017,H035" 87 | indent = 2 88 | blank_line_after_tag = "load,extends" 89 | profile = "django" 90 | max_line_length = 80 91 | format_attribute_template_tags = true 92 | 93 | 94 | [tool.nitpick] 95 | style = "https://raw.githubusercontent.com/wemake-services/wemake-python-styleguide/0.18.0/styles/nitpick-style-wemake.toml" 96 | -------------------------------------------------------------------------------- /scripts/create_dev_database.sql: -------------------------------------------------------------------------------- 1 | /* 2 | This file is used to bootstrap development database locally. 3 | 4 | Note: ONLY development database; 5 | */ 6 | 7 | CREATE USER testing_homework SUPERUSER; 8 | CREATE DATABASE testing_homework OWNER testing_homework ENCODING 'utf-8'; 9 | -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/__init__.py -------------------------------------------------------------------------------- /server/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/identity/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/admin.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | from django.contrib import admin 4 | 5 | from server.apps.identity.models import User 6 | from server.common.django.admin import TimeReadOnlyMixin 7 | 8 | 9 | @final 10 | @admin.register(User) 11 | class UserAdmin(TimeReadOnlyMixin, admin.ModelAdmin[User]): 12 | """This class represents `User` in admin panel.""" 13 | 14 | list_display = tuple(['id'] + User.REQUIRED_FIELDS) 15 | search_fields = ( 16 | 'email', 17 | 'first_name', 18 | 'last_name', 19 | ) 20 | -------------------------------------------------------------------------------- /server/apps/identity/container.py: -------------------------------------------------------------------------------- 1 | import punq 2 | from django.conf import settings 3 | 4 | from server.common.django.types import Settings 5 | 6 | container = punq.Container() 7 | 8 | # Custom dependencies go here: 9 | # TODO: add custom deps 10 | 11 | # Django stuff: 12 | container.register(Settings, instance=settings) 13 | -------------------------------------------------------------------------------- /server/apps/identity/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/identity/infrastructure/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/infrastructure/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/identity/infrastructure/django/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/infrastructure/django/decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, TypeVar 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.decorators import user_passes_test 5 | 6 | _CallableT = TypeVar('_CallableT', bound=Callable[..., Any]) 7 | 8 | 9 | def redirect_logged_in_users( 10 | *, 11 | redirect_field_name: str = '', 12 | ) -> Callable[[_CallableT], _CallableT]: 13 | """Decorator for views that checks that the user is NOT logged in.""" 14 | return user_passes_test( 15 | lambda user: not user.is_authenticated, 16 | login_url=settings.LOGIN_REDIRECT_URL, 17 | redirect_field_name=redirect_field_name, 18 | ) 19 | -------------------------------------------------------------------------------- /server/apps/identity/infrastructure/django/forms.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | from django import forms 4 | from django.contrib.auth.forms import ( 5 | AuthenticationForm as BaseAuthenticationForm, 6 | ) 7 | from django.contrib.auth.forms import UserCreationForm 8 | 9 | from server.apps.identity.models import User 10 | from server.common.django.forms import DateWidget 11 | 12 | 13 | @final 14 | class RegistrationForm(UserCreationForm[User]): 15 | """Create user with all the contact details.""" 16 | 17 | class Meta(object): 18 | model = User 19 | fields = [User.USERNAME_FIELD] + User.REQUIRED_FIELDS 20 | widgets = { 21 | User.USERNAME_FIELD: forms.EmailInput(), 22 | 'date_of_birth': DateWidget(), 23 | } 24 | 25 | 26 | @final 27 | class AuthenticationForm(BaseAuthenticationForm): 28 | """Redefined default email widget.""" 29 | 30 | username = forms.EmailField() 31 | 32 | 33 | @final 34 | class UserUpdateForm(forms.ModelForm[User]): 35 | """ 36 | Update user with all the required details. 37 | 38 | Except passwords. Why? 39 | 1. Passwords are hard to update 40 | 2. You have to input the current password to set a new one 41 | 3. We would need to notify user by email about the password change 42 | 4. All sessions are killed, we will need to restore at least one manually 43 | 5. We already have a change-password mechanics 44 | 45 | You also cannot change 'email': it is our main identifier. 46 | """ 47 | 48 | class Meta(object): 49 | model = User 50 | fields = User.REQUIRED_FIELDS 51 | widgets = { 52 | 'date_of_birth': DateWidget(), 53 | } 54 | -------------------------------------------------------------------------------- /server/apps/identity/infrastructure/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/identity/infrastructure/services/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/infrastructure/services/placeholder.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | import requests 4 | 5 | from server.apps.identity.models import User 6 | from server.common import pydantic_model 7 | from server.common.services import http 8 | 9 | 10 | @final 11 | class UserResponse(pydantic_model.BaseModel): 12 | """Schema for API response with :term:`lead_id`.""" 13 | 14 | id: int 15 | 16 | 17 | # TODO: use redis-based caching 18 | @final 19 | class LeadCreate(http.BaseFetcher): 20 | """Service around creating new users and fetching their :term:`lead_id`.""" 21 | 22 | _url_path = '/users' 23 | 24 | def __call__( 25 | self, 26 | *, 27 | user: User, 28 | ) -> UserResponse: 29 | """Create remote user and return assigned ids.""" 30 | response = requests.post( 31 | self.url_path(), 32 | json=_serialize_user(user), 33 | timeout=self._api_timeout, 34 | ) 35 | response.raise_for_status() 36 | return UserResponse.model_validate_json(response.text) 37 | 38 | 39 | @final 40 | class LeadUpdate(http.BaseFetcher): 41 | """Service around editing users.""" 42 | 43 | _url_path = '/users/{0}' 44 | 45 | def __call__( 46 | self, 47 | *, 48 | user: User, 49 | ) -> None: 50 | """Update remote user.""" 51 | response = requests.patch( 52 | self.url_path().format(user.lead_id), 53 | json=_serialize_user(user), 54 | timeout=self._api_timeout, 55 | ) 56 | response.raise_for_status() 57 | 58 | 59 | def _serialize_user(user: User) -> dict[str, str]: 60 | if user.date_of_birth is not None: 61 | date_of_birth = user.date_of_birth.strftime('%d.%m.%Y') 62 | else: 63 | date_of_birth = '' 64 | 65 | return { 66 | 'name': user.first_name, 67 | 'last_name': user.last_name, 68 | 'birthday': date_of_birth, 69 | 'city_of_birth': user.address, 70 | 'position': user.job_title, 71 | 'email': user.email, 72 | 'phone': user.phone, 73 | } 74 | -------------------------------------------------------------------------------- /server/apps/identity/logic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/identity/logic/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/logic/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/identity/logic/usecases/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/logic/usecases/user_create_new.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | import attr 4 | 5 | from server.apps.identity.infrastructure.services import placeholder 6 | from server.apps.identity.models import User 7 | from server.common.django.types import Settings 8 | 9 | 10 | @final 11 | @attr.dataclass(slots=True, frozen=True) 12 | class UserCreateNew(object): 13 | """ 14 | Create new user in :term:`Placeholder API`. 15 | 16 | Get their :term:`lead_id` back and save it locally. 17 | 18 | .. warning: 19 | This use-case does not handle transactions! 20 | 21 | """ 22 | 23 | _settings: Settings 24 | 25 | def __call__(self, user: User) -> None: 26 | """ 27 | Execute the usecase. 28 | 29 | Ideally this docstring must contain a link to the user-story, like: 30 | https://sobolevn.me/2019/02/engineering-guide-to-user-stories 31 | """ 32 | new_ids = self._create_lead(user) 33 | return self._update_user_ids(user, new_ids) 34 | 35 | def _create_lead(self, user: User) -> placeholder.UserResponse: 36 | return placeholder.LeadCreate( 37 | api_url=self._settings.PLACEHOLDER_API_URL, 38 | api_timeout=self._settings.PLACEHOLDER_API_TIMEOUT, 39 | )(user=user) 40 | 41 | def _update_user_ids( 42 | self, 43 | user: User, 44 | new_ids: placeholder.UserResponse, 45 | ) -> None: 46 | # This can be moved to some other place once this becomes too complex: 47 | user.lead_id = new_ids.id 48 | user.save(update_fields=['lead_id']) 49 | -------------------------------------------------------------------------------- /server/apps/identity/logic/usecases/user_update.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | import attr 4 | 5 | from server.apps.identity.infrastructure.services import placeholder 6 | from server.apps.identity.models import User 7 | from server.common.django.types import Settings 8 | 9 | 10 | @final 11 | @attr.dataclass(slots=True, frozen=True) 12 | class UserUpdate(object): 13 | """ 14 | Update existing user in :term:`Placeholder API`. 15 | 16 | Get their :term:`lead_id` back and save it locally. 17 | 18 | .. warning: 19 | This use-case does not handle transactions! 20 | 21 | """ 22 | 23 | _settings: Settings 24 | 25 | def __call__(self, user: User) -> None: 26 | """Update existing user in the remote api.""" 27 | return self._update_lead(user) 28 | 29 | def _update_lead(self, user: User) -> None: 30 | return placeholder.LeadUpdate( 31 | api_url=self._settings.PLACEHOLDER_API_URL, 32 | api_timeout=self._settings.PLACEHOLDER_API_TIMEOUT, 33 | )(user=user) 34 | -------------------------------------------------------------------------------- /server/apps/identity/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2022-12-02 09:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """Initial migration for our user model.""" 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ('auth', '0012_alter_user_first_name_max_length'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='User', 18 | fields=[ 19 | ( 20 | 'id', 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name='ID', 26 | ), 27 | ), 28 | ( 29 | 'password', 30 | models.CharField(max_length=128, verbose_name='password'), 31 | ), 32 | ( 33 | 'last_login', 34 | models.DateTimeField( 35 | blank=True, 36 | null=True, 37 | verbose_name='last login', 38 | ), 39 | ), 40 | ( 41 | 'is_superuser', 42 | models.BooleanField( 43 | default=False, 44 | help_text='Designates that this user has all permissions without explicitly assigning them.', # noqa: E501 45 | verbose_name='superuser status', 46 | ), 47 | ), 48 | ('created_at', models.DateTimeField(auto_now_add=True)), 49 | ('updated_at', models.DateTimeField(auto_now=True)), 50 | ('email', models.EmailField(max_length=254, unique=True)), 51 | ('first_name', models.CharField(max_length=254)), 52 | ('last_name', models.CharField(max_length=254)), 53 | ('date_of_birth', models.DateField(blank=True, null=True)), 54 | ('address', models.CharField(max_length=254)), 55 | ('job_title', models.CharField(max_length=254)), 56 | ('phone', models.CharField(max_length=254)), 57 | ('lead_id', models.IntegerField(blank=True, null=True)), 58 | ('is_staff', models.BooleanField(default=False)), 59 | ('is_active', models.BooleanField(default=True)), 60 | ( 61 | 'groups', 62 | models.ManyToManyField( 63 | blank=True, 64 | help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', # noqa: E501 65 | related_name='user_set', 66 | related_query_name='user', 67 | to='auth.Group', 68 | verbose_name='groups', 69 | ), 70 | ), 71 | ( 72 | 'user_permissions', 73 | models.ManyToManyField( 74 | blank=True, 75 | help_text='Specific permissions for this user.', 76 | related_name='user_set', 77 | related_query_name='user', 78 | to='auth.Permission', 79 | verbose_name='user permissions', 80 | ), 81 | ), 82 | ], 83 | options={ 84 | 'abstract': False, 85 | }, 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /server/apps/identity/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/identity/migrations/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/models.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Final, final 2 | 3 | from django.contrib.auth.models import ( 4 | AbstractBaseUser, 5 | BaseUserManager, 6 | PermissionsMixin, 7 | ) 8 | from django.db import models 9 | 10 | from server.common.django.models import TimedMixin 11 | 12 | # For now we use a single length for all items, later it can be changed. 13 | _NAME_LENGTH: Final = 254 14 | 15 | 16 | @final 17 | class _UserManager(BaseUserManager['User']): 18 | def create_user( 19 | self, 20 | email: str, 21 | password: str, 22 | **extra_fields: Any, 23 | ) -> 'User': 24 | """Create user: regular registration process.""" 25 | if not email: 26 | # We double check it here, 27 | # but validation should make this unreachable. 28 | raise ValueError('Users must have an email address') 29 | 30 | user = User(email=self.normalize_email(email), **extra_fields) 31 | user.set_password(password) 32 | user.save(using=self._db) 33 | return user 34 | 35 | def create_superuser( 36 | self, 37 | email: str, 38 | password: str, 39 | **extra_fields: Any, 40 | ) -> 'User': 41 | """Create super user.""" 42 | user = self.create_user(email, password, **extra_fields) 43 | user.is_superuser = True 44 | user.is_staff = True 45 | # Technically this is not transaction safe, but who cares. 46 | # It is only used in CLI / tests: 47 | user.save(using=self._db, update_fields=['is_superuser', 'is_staff']) 48 | return user 49 | 50 | 51 | @final 52 | class User(AbstractBaseUser, PermissionsMixin, TimedMixin): 53 | """Implementation of :term:`user` in the app.""" 54 | 55 | # Identity: 56 | email = models.EmailField(unique=True) 57 | 58 | # Details: 59 | first_name = models.CharField(max_length=_NAME_LENGTH) 60 | last_name = models.CharField(max_length=_NAME_LENGTH) 61 | date_of_birth = models.DateField(null=True, blank=True) 62 | address = models.CharField(max_length=_NAME_LENGTH) 63 | job_title = models.CharField(max_length=_NAME_LENGTH) 64 | 65 | # Contacts: 66 | # NOTE: we don't really care about phone correctness. 67 | phone = models.CharField(max_length=_NAME_LENGTH) 68 | 69 | # Integration with Placeholder API: 70 | lead_id = models.IntegerField(null=True, blank=True) 71 | 72 | # Security: 73 | is_staff = models.BooleanField(default=False) 74 | is_active = models.BooleanField(default=True) 75 | 76 | # Mechanics: 77 | objects = _UserManager() # noqa: WPS110 78 | 79 | USERNAME_FIELD = 'email' # noqa: WPS115 80 | REQUIRED_FIELDS = [ # noqa: WPS115 81 | 'first_name', 82 | 'last_name', 83 | 'date_of_birth', 84 | 'address', 85 | 'job_title', 86 | 'phone', 87 | ] 88 | 89 | if TYPE_CHECKING: # noqa: WPS604 90 | # Raw password that is stored in the instance before it is saved, 91 | # it is actually `str | None` in runtime, but `str` in most tests. 92 | _password: str 93 | -------------------------------------------------------------------------------- /server/apps/identity/templates/identity/includes/user_model_form.html: -------------------------------------------------------------------------------- 1 |
2 | {% csrf_token %} 3 |

Личные данные

4 | {% if form.email %} 5 | {% include 'common/includes/field.html' with field=form.email field_label='Электронная почта' %} 6 | {% endif %} 7 | {% include 'common/includes/field.html' with field=form.last_name field_label='Фамилия' %} 8 | {% include 'common/includes/field.html' with field=form.first_name field_label='Имя' %} 9 | {% include 'common/includes/field.html' with field=form.date_of_birth field_label='Дата рождения' %} 10 | {% include 'common/includes/field.html' with field=form.address field_label='Страна, город проживания' %} 11 | {% include 'common/includes/field.html' with field=form.job_title field_label='Должность' %} 12 | {% include 'common/includes/field.html' with field=form.phone field_label='Номер телефона' %} 13 | {% if form.password1 and form.password2 %} 14 |

Безопасность

15 | {% include 'common/includes/field.html' with field=form.password1 field_label='Пароль' %} 16 | {% include 'common/includes/field.html' with field=form.password2 field_label='Подтвердите пароль' %} 17 | {% endif %} 18 | 19 |
20 | -------------------------------------------------------------------------------- /server/apps/identity/templates/identity/pages/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'common/_base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block title %} 6 | Регистрация 7 | {% endblock title %} 8 | {% block content %} 9 |
10 |
11 |

Войти в личный кабинет

12 |
13 |

Ещё нет личного кабинета?

14 | Зарегистрироваться 15 |
16 |
{{ form.non_field_errors }}
17 |
18 | {% csrf_token %} 19 | {% include 'common/includes/field.html' with field=form.username field_label='Электронная почта' %} 20 | {% include 'common/includes/field.html' with field=form.password field_label='Пароль' %} 21 | 22 |
23 |
24 |
25 | {% endblock content %} 26 | -------------------------------------------------------------------------------- /server/apps/identity/templates/identity/pages/registration.html: -------------------------------------------------------------------------------- 1 | {% extends 'common/_base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block title %} 6 | Регистрация 7 | {% endblock title %} 8 | {% block content %} 9 |
10 |
11 |

Регистрация

12 |
13 |

Уже есть личный кабинет?

14 | Войти 15 |
16 |
{{ form.non_field_errors }}
17 | {% url 'identity:registration' as action %} 18 | {% include 'identity/includes/user_model_form.html' with form=form action=action button='Зарегистрироваться' show_confidential=True %} 19 |
20 |
21 | {% endblock content %} 22 | -------------------------------------------------------------------------------- /server/apps/identity/templates/identity/pages/user_update.html: -------------------------------------------------------------------------------- 1 | {% extends 'common/_base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block title %} 6 | Редактировать профиль 7 | {% endblock title %} 8 | {% block content %} 9 |
10 |
11 |

Редактировать профиль

12 |
13 |
{% include 'common/includes/messages.html' with messages=messages %}
14 |
{{ form.non_field_errors }}
15 |
16 | {% url 'identity:user_update' as action %} 17 | {% include 'identity/includes/user_model_form.html' with form=form action=action button='Сохранить' %} 18 |
19 |
20 | {% endblock content %} 21 | -------------------------------------------------------------------------------- /server/apps/identity/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.views import LogoutView 2 | from django.urls import path 3 | 4 | from server.apps.identity.views.login import LoginView, RegistrationView 5 | from server.apps.identity.views.user import UserUpdateView 6 | 7 | app_name = 'identity' 8 | 9 | urlpatterns = [ 10 | # Login mechanics: 11 | path( 12 | 'login', 13 | LoginView.as_view(template_name='identity/pages/login.html'), 14 | name='login', 15 | ), 16 | path('logout', LogoutView.as_view(), name='logout'), 17 | path('registration', RegistrationView.as_view(), name='registration'), 18 | 19 | # User updating: 20 | path('update', UserUpdateView.as_view(), name='user_update'), 21 | ] 22 | -------------------------------------------------------------------------------- /server/apps/identity/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/identity/views/__init__.py -------------------------------------------------------------------------------- /server/apps/identity/views/login.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | from axes.decorators import axes_dispatch 4 | from django.contrib.auth.views import LoginView as BaseLoginView 5 | from django.db import transaction 6 | from django.http import HttpResponse 7 | from django.urls import reverse_lazy 8 | from django.views.decorators.debug import sensitive_post_parameters 9 | from django.views.generic.edit import FormView 10 | from ratelimit.mixins import RatelimitMixin 11 | 12 | from server.apps.identity.container import container 13 | from server.apps.identity.infrastructure.django.decorators import ( 14 | redirect_logged_in_users, 15 | ) 16 | from server.apps.identity.infrastructure.django.forms import ( 17 | AuthenticationForm, 18 | RegistrationForm, 19 | ) 20 | from server.apps.identity.logic.usecases.user_create_new import UserCreateNew 21 | from server.common.django.decorators import dispatch_decorator 22 | 23 | 24 | @final 25 | @dispatch_decorator(redirect_logged_in_users()) 26 | @dispatch_decorator(axes_dispatch) 27 | @dispatch_decorator(sensitive_post_parameters()) 28 | class LoginView(BaseLoginView): 29 | """More protected version of the login view.""" 30 | 31 | form_class = AuthenticationForm 32 | 33 | 34 | @final 35 | @dispatch_decorator(redirect_logged_in_users()) 36 | @dispatch_decorator(sensitive_post_parameters()) 37 | class RegistrationView(RatelimitMixin, FormView[RegistrationForm]): 38 | """ 39 | Registers users. 40 | 41 | After the registration we notify :term:`Placeholder API` 42 | about new users and get their ids back. 43 | """ 44 | 45 | form_class = RegistrationForm 46 | template_name = 'identity/pages/registration.html' 47 | success_url = reverse_lazy('identity:login') 48 | 49 | # Rate-limiting: 50 | ratelimit_key = 'ip' 51 | ratelimit_rate = '5/h' 52 | ratelimit_block = True 53 | ratelimit_method = ['POST', 'PUT'] # GET is safe 54 | 55 | def form_valid(self, form: RegistrationForm) -> HttpResponse: 56 | """Save user after successful validation.""" 57 | user_create_new = container.instantiate(UserCreateNew) 58 | with transaction.atomic(): 59 | user = form.save() 60 | user_create_new(user) # does http request, can slow down db 61 | return super().form_valid(form) 62 | -------------------------------------------------------------------------------- /server/apps/identity/views/user.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | from django.contrib import messages 4 | from django.contrib.auth.decorators import login_required 5 | from django.db.models import QuerySet 6 | from django.http import HttpResponse 7 | from django.urls import reverse_lazy 8 | from django.views.decorators.debug import sensitive_post_parameters 9 | from django.views.generic import UpdateView 10 | from ratelimit.mixins import RatelimitMixin 11 | 12 | from server.apps.identity.container import container 13 | from server.apps.identity.infrastructure.django.forms import UserUpdateForm 14 | from server.apps.identity.logic.usecases.user_update import UserUpdate 15 | from server.apps.identity.models import User 16 | from server.common.django.decorators import dispatch_decorator 17 | 18 | 19 | @final 20 | @dispatch_decorator(login_required) 21 | @dispatch_decorator(sensitive_post_parameters('email')) 22 | class UserUpdateView(RatelimitMixin, UpdateView[User, UserUpdateForm]): 23 | """Change user details.""" 24 | 25 | model = User 26 | form_class = UserUpdateForm 27 | success_url = reverse_lazy('identity:user_update') 28 | template_name = 'identity/pages/user_update.html' 29 | 30 | # Rate-limiting: 31 | ratelimit_key = 'ip' 32 | ratelimit_rate = '10/h' 33 | ratelimit_block = True 34 | ratelimit_method = ['POST', 'PUT'] # GET is safe 35 | 36 | def get_object(self, queryset: QuerySet[User] | None = None) -> User: 37 | """We only work with the current user.""" 38 | assert self.request.user.is_authenticated # more for mypy # noqa: S101 39 | return self.request.user 40 | 41 | def form_valid(self, form: UserUpdateForm) -> HttpResponse: 42 | """ 43 | Data is valid. 44 | 45 | In this case we need to: 46 | 1. Show success message 47 | 2. Sync information with :term:`Placeholder API` 48 | """ 49 | user_update = container.instantiate(UserUpdate) 50 | 51 | # Using Russian text without `gettext` is ugly, but we don't support 52 | # other languages at all in this demo. 53 | messages.success(self.request, 'Ваши данные сохранены') 54 | response = super().form_valid(form) 55 | user_update(self.object) 56 | return response 57 | -------------------------------------------------------------------------------- /server/apps/pictures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/admin.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | from django.contrib import admin 4 | 5 | from server.apps.pictures.models import FavouritePicture 6 | from server.common.django.admin import TimeReadOnlyMixin 7 | 8 | 9 | @final 10 | @admin.register(FavouritePicture) 11 | class FavouritePictureAdmin( 12 | TimeReadOnlyMixin, 13 | admin.ModelAdmin[FavouritePicture], 14 | ): 15 | """This class represents `FavouritePicture` in admin panel.""" 16 | 17 | list_display = ('id', 'foreign_id', 'url', 'user_id') 18 | list_select_related = ('user',) 19 | raw_id_fields = ('user',) 20 | -------------------------------------------------------------------------------- /server/apps/pictures/container.py: -------------------------------------------------------------------------------- 1 | import punq 2 | from django.conf import settings 3 | 4 | from server.common.django.types import Settings 5 | 6 | container = punq.Container() 7 | 8 | # Custom dependencies go here: 9 | # TODO: add custom deps 10 | 11 | # Django stuff: 12 | container.register(Settings, instance=settings) 13 | -------------------------------------------------------------------------------- /server/apps/pictures/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/infrastructure/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/infrastructure/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/infrastructure/django/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/infrastructure/django/forms.py: -------------------------------------------------------------------------------- 1 | from typing import Any, final 2 | 3 | from django import forms 4 | 5 | from server.apps.pictures.models import FavouritePicture 6 | 7 | 8 | @final 9 | class FavouritesForm(forms.ModelForm[FavouritePicture]): 10 | """Model form for :class:`FavouritePicture`.""" 11 | 12 | class Meta(object): 13 | model = FavouritePicture 14 | fields = ('foreign_id', 'url') 15 | 16 | def __init__(self, *args: Any, **kwargs: Any) -> None: 17 | """We need an extra context: which user is adding items.""" 18 | self._user = kwargs.pop('user') 19 | super().__init__(*args, **kwargs) 20 | 21 | def save(self, commit: bool = True) -> FavouritePicture: 22 | """Add user to the model instance.""" 23 | instance = super().save(commit=False) 24 | instance.user_id = self._user.id 25 | if commit: 26 | instance.save() 27 | return instance 28 | -------------------------------------------------------------------------------- /server/apps/pictures/infrastructure/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/infrastructure/services/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/infrastructure/services/placeholder.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | import pydantic 4 | import requests 5 | 6 | from server.common import pydantic_model 7 | from server.common.services import http 8 | 9 | 10 | @final 11 | class PictureResponse(pydantic_model.BaseModel): 12 | """Schema for API response with :term:`picture` items.""" 13 | 14 | id: int 15 | url: str 16 | 17 | 18 | # TODO: use redis-based caching 19 | @final 20 | class PicturesFetch(http.BaseFetcher): 21 | """Service around fetching pictures from :term:`Placeholder API`.""" 22 | 23 | _url_path = '/photos' 24 | 25 | def __call__( 26 | self, 27 | *, 28 | limit: int, 29 | ) -> list[PictureResponse]: 30 | """Create remote user and return assigned ids.""" 31 | response = requests.get( 32 | self.url_path(), 33 | params={'_limit': limit}, 34 | timeout=self._api_timeout, 35 | ) 36 | response.raise_for_status() 37 | return pydantic.TypeAdapter( 38 | list[PictureResponse], 39 | ).validate_json( 40 | response.text, 41 | ) 42 | -------------------------------------------------------------------------------- /server/apps/pictures/logic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/logic/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/logic/repo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/logic/repo/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/logic/repo/queries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/logic/repo/queries/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/logic/repo/queries/favourite_pictures.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from server.apps.pictures.models import FavouritePicture 4 | 5 | 6 | def by_user(user_id: int) -> QuerySet[FavouritePicture]: 7 | """Search :class:`FavouritePicture` by user id.""" 8 | # TODO: this should be limited and probably paginated 9 | return FavouritePicture.objects.filter(user_id=user_id) 10 | -------------------------------------------------------------------------------- /server/apps/pictures/logic/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/logic/usecases/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/logic/usecases/favourites_list.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | import attr 4 | from django.db.models import QuerySet 5 | 6 | # NOTE: this can be a dependency as well 7 | from server.apps.pictures.logic.repo.queries import favourite_pictures 8 | from server.apps.pictures.models import FavouritePicture 9 | 10 | 11 | @final 12 | @attr.dataclass(slots=True, frozen=True) 13 | class FavouritesList(object): 14 | """List :term:`favourites` pictures for a given user.""" 15 | 16 | def __call__(self, user_id: int) -> QuerySet[FavouritePicture]: 17 | """Update existing user in the remote api.""" 18 | return self._list_pictures(user_id) 19 | 20 | def _list_pictures(self, user_id: int) -> QuerySet[FavouritePicture]: 21 | return favourite_pictures.by_user(user_id) 22 | -------------------------------------------------------------------------------- /server/apps/pictures/logic/usecases/pictures_fetch.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | import attr 4 | 5 | from server.apps.pictures.infrastructure.services import placeholder 6 | from server.common.django.types import Settings 7 | 8 | 9 | @final 10 | @attr.dataclass(slots=True, frozen=True) 11 | class PicturesFetch(object): 12 | """Fetch :term:`picture` items from :term:`Placeholder API`.""" 13 | 14 | _settings: Settings 15 | 16 | def __call__(self, limit: int = 10) -> list[placeholder.PictureResponse]: 17 | """Update existing user in the remote api.""" 18 | return self._fetch_pictures(limit) 19 | 20 | def _fetch_pictures(self, limit: int) -> list[placeholder.PictureResponse]: 21 | return placeholder.PicturesFetch( 22 | api_url=self._settings.PLACEHOLDER_API_URL, 23 | api_timeout=self._settings.PLACEHOLDER_API_TIMEOUT, 24 | )(limit=limit) 25 | -------------------------------------------------------------------------------- /server/apps/pictures/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2023-02-24 19:05 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | """Initial migration.""" 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='FavouritePicture', 19 | fields=[ 20 | ( 21 | 'id', 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name='ID', 27 | ), 28 | ), 29 | ('created_at', models.DateTimeField(auto_now_add=True)), 30 | ('updated_at', models.DateTimeField(auto_now=True)), 31 | ('foreign_id', models.IntegerField()), 32 | ('url', models.URLField()), 33 | ( 34 | 'user', 35 | models.ForeignKey( 36 | on_delete=models.CASCADE, 37 | related_name='pictures', 38 | to=settings.AUTH_USER_MODEL, 39 | ), 40 | ), 41 | ], 42 | options={ 43 | 'abstract': False, 44 | }, 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /server/apps/pictures/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/apps/pictures/migrations/__init__.py -------------------------------------------------------------------------------- /server/apps/pictures/models.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | 6 | from server.common.django.models import TimedMixin 7 | 8 | 9 | @final 10 | class FavouritePicture(TimedMixin, models.Model): 11 | """Represents a :term:`picture` saved in :term:`favourites`.""" 12 | 13 | # Linking: 14 | user = models.ForeignKey( 15 | settings.AUTH_USER_MODEL, 16 | related_name='pictures', 17 | on_delete=models.CASCADE, 18 | ) 19 | 20 | # Data: 21 | foreign_id = models.IntegerField() 22 | url = models.URLField() 23 | 24 | def __str__(self) -> str: 25 | """Beautiful representation.""" 26 | return ''.format(self.foreign_id, self.user_id) 27 | -------------------------------------------------------------------------------- /server/apps/pictures/templates/pictures/pages/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'common/_base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block title %} 6 | Testing Homework 7 | {% endblock title %} 8 | {% block content %} 9 |
10 |
11 |

Профиль

12 |
    13 |
  • 14 | ФИО: 15 | {{ user.first_name }} {{ user.last_name }} 16 |
  • 17 |
  • 18 | Дата рождения: 19 | {{ user.date_of_birth|stringformat:'s' }} 20 |
  • 21 |
  • 22 | Страна, город: 23 | {{ user.address }} 24 |
  • 25 |
  • 26 | Должность: 27 | {{ user.job_title }} 28 |
  • 29 |
  • 30 | Телефон: 31 | {{ user.phone }} 32 |
  • 33 |
  • 34 | Электронная почта: 35 | {{ user.email }} 36 |
  • 37 |
38 | Изменить 39 |
40 |
41 |
42 | {% include 'common/includes/messages.html' with messages=messages %} 43 | {{ form.errors }} 44 |
45 | {% for picture in pictures %} 46 |
47 | Картинка {{ picture.id }} 48 |
49 | {% csrf_token %} 50 | 51 | 52 | 53 |
54 |
55 |
56 | {% endfor %} 57 |
58 |
59 | {% endblock content %} 60 | -------------------------------------------------------------------------------- /server/apps/pictures/templates/pictures/pages/favourites.html: -------------------------------------------------------------------------------- 1 | {% extends 'common/_base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block title %} 6 | Запись на консультацию с основателем 7 | {% endblock title %} 8 | {% block content %} 9 |
10 |

Список любимых картинок

11 | {% for picture in object_list %} 12 |
13 |

Номер {{ picture.foreign_id }}

14 | Картинка {{ picture.foreign_id }} 15 |
16 | {% endfor %} 17 |
18 | {% endblock content %} 19 | -------------------------------------------------------------------------------- /server/apps/pictures/templates/pictures/pages/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'common/_base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block title %} 6 | Привет! 7 | {% endblock title %} 8 | {% block content %} 9 |
10 |

Добро пожаловать в домашнее задание в курсе по тестированию.

11 |

В чем суть?

12 |

13 | Тут мы делаем простое приложение: 14 |

    15 |
  1. Регистрация и логин пользователя
  2. 16 |
  3. Получение картинок из стороннего ресурса
  4. 17 |
  5. Возможность локально сохранять ссылки на самые любимые
  6. 18 |
19 |

20 |
21 | {% endblock content %} 22 | -------------------------------------------------------------------------------- /server/apps/pictures/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from server.apps.pictures.views import DashboardView, FavouritePicturesView 4 | 5 | app_name = 'pictures' 6 | 7 | urlpatterns = [ 8 | path('dashboard', DashboardView.as_view(), name='dashboard'), 9 | path('favourites', FavouritePicturesView.as_view(), name='favourites'), 10 | ] 11 | -------------------------------------------------------------------------------- /server/apps/pictures/views.py: -------------------------------------------------------------------------------- 1 | from typing import Any, final 2 | 3 | from django.contrib import messages 4 | from django.contrib.auth.decorators import login_required 5 | from django.db.models import QuerySet 6 | from django.http import HttpResponse 7 | from django.urls import reverse_lazy 8 | from django.views.generic import CreateView, ListView, TemplateView 9 | 10 | from server.apps.pictures.container import container 11 | from server.apps.pictures.infrastructure.django.forms import FavouritesForm 12 | from server.apps.pictures.logic.usecases import favourites_list, pictures_fetch 13 | from server.apps.pictures.models import FavouritePicture 14 | from server.common.django.decorators import dispatch_decorator 15 | 16 | 17 | @final 18 | class IndexView(TemplateView): 19 | """ 20 | View the :term:`laning`. 21 | 22 | It is a main page open for everyone. 23 | """ 24 | 25 | template_name = 'pictures/pages/index.html' 26 | 27 | 28 | @final 29 | @dispatch_decorator(login_required) 30 | class DashboardView(CreateView[FavouritePicture, FavouritesForm]): 31 | """ 32 | View the :term:`dashboard`. 33 | 34 | It is a main page of the whole application. 35 | This is where we show :term:`pictures` to be saved in :term:`favourites`. 36 | """ 37 | 38 | form_class = FavouritesForm 39 | template_name = 'pictures/pages/dashboard.html' 40 | success_url = reverse_lazy('pictures:dashboard') 41 | 42 | def get_context_data(self, **kwargs: Any) -> dict[str, Any]: 43 | """Inject extra context to template rendering.""" 44 | fetch_pictures = container.instantiate(pictures_fetch.PicturesFetch) 45 | 46 | context = super().get_context_data(**kwargs) 47 | context['pictures'] = fetch_pictures() # sync http call, may hang 48 | return context 49 | 50 | def get_form_kwargs(self) -> dict[str, Any]: 51 | """Add current user to the context.""" 52 | base_kwargs = super().get_form_kwargs() 53 | base_kwargs['user'] = self.request.user 54 | return base_kwargs 55 | 56 | def form_valid(self, form: FavouritesForm) -> HttpResponse: 57 | """Data is valid: show a message about it.""" 58 | messages.success(self.request, 'Добавлено') 59 | return super().form_valid(form) 60 | 61 | 62 | @final 63 | @dispatch_decorator(login_required) 64 | class FavouritePicturesView(ListView[FavouritePicture]): 65 | """View the :term:`favourites`.""" 66 | 67 | template_name = 'pictures/pages/favourites.html' 68 | 69 | def get_queryset(self) -> QuerySet[FavouritePicture]: 70 | """Return matching pictures.""" 71 | list_favourites = container.instantiate(favourites_list.FavouritesList) 72 | return list_favourites(self.request.user.id) 73 | -------------------------------------------------------------------------------- /server/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/common/__init__.py -------------------------------------------------------------------------------- /server/common/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/common/django/__init__.py -------------------------------------------------------------------------------- /server/common/django/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class TimeReadOnlyMixin(object): 5 | """Utility class to represent readonly dates in the admin panel.""" 6 | 7 | readonly_fields: Any = ('created_at', 'updated_at') 8 | -------------------------------------------------------------------------------- /server/common/django/decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, TypeVar 2 | 3 | from django.utils.decorators import method_decorator 4 | 5 | _Type = TypeVar('_Type', bound=type) 6 | 7 | 8 | def dispatch_decorator(func: Callable[..., Any]) -> Callable[[_Type], _Type]: 9 | """Special helper to decorate class-based view's `dispatch` method.""" 10 | return method_decorator(func, name='dispatch') 11 | -------------------------------------------------------------------------------- /server/common/django/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class DateWidget(forms.DateInput): 5 | """Date input in the proper date format.""" 6 | 7 | input_type = 'date' 8 | format = '%Y-%m-%d' # noqa: WPS323 9 | -------------------------------------------------------------------------------- /server/common/django/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TimedMixin(models.Model): 5 | """Adding utility time fields for different models.""" 6 | 7 | created_at = models.DateTimeField(auto_now_add=True) 8 | updated_at = models.DateTimeField(auto_now=True) 9 | 10 | class Meta(object): 11 | abstract = True 12 | -------------------------------------------------------------------------------- /server/common/django/templates/common/_base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %} 10 | {% endblock title %} 11 | 12 | 13 | 14 | 15 |
16 | {% include 'common/includes/header.html' %} 17 | {% block content %} 18 | {% endblock content %} 19 |
20 | {% include 'common/includes/footer.html' %} 21 | 22 | 23 | -------------------------------------------------------------------------------- /server/common/django/templates/common/includes/field.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 |
4 | 5 | 15 | {% if field.errors %}
{{ field.errors }}
{% endif %} 16 |
17 | -------------------------------------------------------------------------------- /server/common/django/templates/common/includes/footer.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 |
4 |

Have fun!

5 |
6 | -------------------------------------------------------------------------------- /server/common/django/templates/common/includes/header.html: -------------------------------------------------------------------------------- 1 |
2 | 27 |
28 | -------------------------------------------------------------------------------- /server/common/django/templates/common/includes/messages.html: -------------------------------------------------------------------------------- 1 | {% if messages %} 2 | {% for message in messages %} 3 | {# We don't have any non-successful messages just yet #} 4 |

{{ message }}

5 | {% endfor %} 6 | {% endif %} 7 | -------------------------------------------------------------------------------- /server/common/django/templates/common/txt/humans.txt: -------------------------------------------------------------------------------- 1 | # The humans responsible & technology colophon 2 | # http://humanstxt.org/ 3 | 4 | 5 | ## wemake.services 6 | 7 | Team: 8 | - Architect: Nikita Sobolev 9 | 10 | 11 | ## Technologies 12 | 13 | Language: English 14 | Doctype: HTML5 15 | Technologies: Python, Django 16 | -------------------------------------------------------------------------------- /server/common/django/templates/common/txt/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /server/common/django/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is only required for better typing. 3 | 4 | Most of the things here are fixes / missing features in `django-stubs`. 5 | It is better to have links for open bug reports / feature requests 6 | near each type, so we can easily track them and refactor 7 | our code when new versions are released. 8 | """ 9 | 10 | from typing import Protocol 11 | 12 | 13 | # TODO: bug in django-stubs with settings 14 | class Settings(Protocol): 15 | """Our plugin cannot resolve some settings during type checking.""" 16 | 17 | PLACEHOLDER_API_URL: str 18 | PLACEHOLDER_API_TIMEOUT: int 19 | -------------------------------------------------------------------------------- /server/common/pydantic_model.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | 4 | class BaseModel(pydantic.BaseModel): 5 | """Base immutable model for our internal use.""" 6 | 7 | model_config = pydantic.ConfigDict(frozen=True) 8 | -------------------------------------------------------------------------------- /server/common/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/server/common/services/__init__.py -------------------------------------------------------------------------------- /server/common/services/http.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | from urllib.parse import urljoin 3 | 4 | from attr import dataclass 5 | 6 | 7 | @dataclass(frozen=True, slots=True) 8 | class BaseFetcher(object): 9 | """Base class for our HTTP actions.""" 10 | 11 | #: Dependencies: 12 | _api_url: str 13 | _api_timeout: int 14 | 15 | #: This must be defined in all subclasses: 16 | _url_path: ClassVar[str] 17 | 18 | def url_path(self) -> str: 19 | """Full URL for the request.""" 20 | return urljoin(self._api_url, self._url_path) 21 | -------------------------------------------------------------------------------- /server/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a django-split-settings main file. 3 | 4 | For more information read this: 5 | https://github.com/sobolevn/django-split-settings 6 | https://sobolevn.me/2017/04/managing-djangos-settings 7 | 8 | To change settings file: 9 | `DJANGO_ENV=production python manage.py runserver` 10 | """ 11 | 12 | from os import environ 13 | 14 | import django_stubs_ext 15 | from split_settings.tools import include, optional 16 | 17 | # Monkeypatching Django, so stubs will work for all generics, 18 | # see: https://github.com/typeddjango/django-stubs 19 | django_stubs_ext.monkeypatch() 20 | 21 | # Managing environment via `DJANGO_ENV` variable: 22 | environ.setdefault('DJANGO_ENV', 'development') 23 | _ENV = environ['DJANGO_ENV'] 24 | 25 | _base_settings = ( 26 | 'components/common.py', 27 | 'components/identity.py', 28 | 'components/logging.py', 29 | 'components/csp.py', 30 | 'components/caches.py', 31 | 'components/placeholder.py', 32 | 33 | # Select the right env: 34 | 'environments/{0}.py'.format(_ENV), 35 | 36 | # Optionally override some settings: 37 | optional('environments/local.py'), 38 | ) 39 | 40 | # Include settings: 41 | include(*_base_settings) 42 | -------------------------------------------------------------------------------- /server/settings/components/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from decouple import AutoConfig 4 | 5 | # Build paths inside the project like this: BASE_DIR.joinpath('some') 6 | # `pathlib` is better than writing: dirname(dirname(dirname(__file__))) 7 | BASE_DIR = Path(__file__).parent.parent.parent.parent 8 | 9 | # Loading `.env` files 10 | # See docs: https://gitlab.com/mkleehammer/autoconfig 11 | config = AutoConfig(search_path=BASE_DIR.joinpath('config')) 12 | -------------------------------------------------------------------------------- /server/settings/components/caches.py: -------------------------------------------------------------------------------- 1 | # Caching 2 | # https://docs.djangoproject.com/en/4.2/topics/cache/ 3 | 4 | CACHES = { 5 | 'default': { 6 | # TODO: use some other cache in production, 7 | # like https://github.com/jazzband/django-redis 8 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 9 | }, 10 | } 11 | 12 | 13 | # django-axes 14 | # https://django-axes.readthedocs.io/en/latest/4_configuration.html#configuring-caches 15 | 16 | AXES_CACHE = 'default' 17 | -------------------------------------------------------------------------------- /server/settings/components/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for server project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/4.2/topics/settings/ 6 | 7 | For the full list of settings and their config, see 8 | https://docs.djangoproject.com/en/4.2/ref/settings/ 9 | """ 10 | 11 | from django.utils.translation import gettext_lazy as _ 12 | 13 | from server.settings.components import BASE_DIR, config 14 | 15 | # Quick-start development settings - unsuitable for production 16 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 17 | 18 | SECRET_KEY = config('DJANGO_SECRET_KEY') 19 | 20 | # Application definition: 21 | 22 | INSTALLED_APPS: tuple[str, ...] = ( 23 | # Your apps go here: 24 | 'server.apps.pictures', 25 | 'server.apps.identity', 26 | 27 | # Default django apps: 28 | 'django.contrib.auth', 29 | 'django.contrib.contenttypes', 30 | 'django.contrib.sessions', 31 | 'django.contrib.messages', 32 | 'django.contrib.staticfiles', 33 | 34 | # django-admin: 35 | 'django.contrib.admin', 36 | 'django.contrib.admindocs', 37 | 38 | # Security: 39 | 'axes', 40 | 41 | # Health checks: 42 | # You may want to enable other checks as well, 43 | # see: https://github.com/KristianOellegaard/django-health-check 44 | 'health_check', 45 | 'health_check.db', 46 | 'health_check.cache', 47 | 'health_check.storage', 48 | ) 49 | 50 | MIDDLEWARE: tuple[str, ...] = ( 51 | # Logging: 52 | 'server.settings.components.logging.LoggingContextVarsMiddleware', 53 | 54 | # Content Security Policy: 55 | 'csp.middleware.CSPMiddleware', 56 | 57 | # Django: 58 | 'django.middleware.security.SecurityMiddleware', 59 | # django-permissions-policy 60 | 'django_permissions_policy.PermissionsPolicyMiddleware', 61 | 'django.contrib.sessions.middleware.SessionMiddleware', 62 | 'django.middleware.locale.LocaleMiddleware', 63 | 'django.middleware.common.CommonMiddleware', 64 | 'django.middleware.csrf.CsrfViewMiddleware', 65 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 66 | 'django.contrib.messages.middleware.MessageMiddleware', 67 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 68 | 69 | # Axes: 70 | 'axes.middleware.AxesMiddleware', 71 | 72 | # Django HTTP Referrer Policy: 73 | 'django_http_referrer_policy.middleware.ReferrerPolicyMiddleware', 74 | ) 75 | 76 | ROOT_URLCONF = 'server.urls' 77 | 78 | WSGI_APPLICATION = 'server.wsgi.application' 79 | 80 | 81 | # Database 82 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 83 | 84 | DATABASES = { 85 | 'default': { 86 | 'ENGINE': 'django.db.backends.postgresql', 87 | 'NAME': config('POSTGRES_DB'), 88 | 'USER': config('POSTGRES_USER'), 89 | 'PASSWORD': config('POSTGRES_PASSWORD'), 90 | 'HOST': config('DJANGO_DATABASE_HOST'), 91 | 'PORT': config('DJANGO_DATABASE_PORT', cast=int), 92 | 'CONN_MAX_AGE': config('CONN_MAX_AGE', cast=int, default=60), 93 | 'OPTIONS': { 94 | 'connect_timeout': 10, 95 | 'options': '-c statement_timeout=15000ms', 96 | }, 97 | }, 98 | } 99 | 100 | 101 | # Internationalization 102 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 103 | 104 | LANGUAGE_CODE = 'ru' 105 | 106 | USE_I18N = True 107 | USE_L10N = True 108 | 109 | LANGUAGES = ( 110 | ('ru', _('Russian')), 111 | ) 112 | 113 | LOCALE_PATHS = ( 114 | 'locale/', 115 | ) 116 | 117 | USE_TZ = True 118 | TIME_ZONE = 'UTC' 119 | 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 123 | 124 | STATIC_URL = 'static/' 125 | 126 | 127 | # Templates 128 | # https://docs.djangoproject.com/en/4.2/ref/templates/api 129 | 130 | TEMPLATES = [{ 131 | 'APP_DIRS': True, 132 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 133 | 'DIRS': [ 134 | # Contains plain text templates, like `robots.txt`: 135 | BASE_DIR.joinpath('server', 'common', 'django', 'templates'), 136 | ], 137 | 'OPTIONS': { 138 | 'context_processors': [ 139 | # Default template context processors: 140 | 'django.contrib.auth.context_processors.auth', 141 | 'django.template.context_processors.debug', 142 | 'django.template.context_processors.i18n', 143 | 'django.template.context_processors.media', 144 | 'django.contrib.messages.context_processors.messages', 145 | 'django.template.context_processors.request', 146 | ], 147 | }, 148 | }] 149 | 150 | 151 | # Media files 152 | # Media root dir is commonly changed in production 153 | # (see development.py and production.py). 154 | # https://docs.djangoproject.com/en/4.2/topics/files/ 155 | 156 | MEDIA_URL = '/media/' 157 | MEDIA_ROOT = BASE_DIR.joinpath('media') 158 | 159 | 160 | # Security 161 | # https://docs.djangoproject.com/en/4.2/topics/security/ 162 | 163 | SESSION_COOKIE_HTTPONLY = True 164 | CSRF_COOKIE_HTTPONLY = True 165 | SECURE_CONTENT_TYPE_NOSNIFF = True 166 | SECURE_BROWSER_XSS_FILTER = True 167 | 168 | X_FRAME_OPTIONS = 'DENY' 169 | 170 | # https://github.com/DmytroLitvinov/django-http-referrer-policy 171 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy 172 | REFERRER_POLICY = 'same-origin' 173 | 174 | # https://github.com/adamchainz/django-permissions-policy#setting 175 | PERMISSIONS_POLICY: dict[str, str | list[str]] = {} # noqa: WPS234 176 | 177 | 178 | # Default primary key field type 179 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 180 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 181 | -------------------------------------------------------------------------------- /server/settings/components/csp.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains a definition for Content-Security-Policy headers. 3 | 4 | Read more about it: 5 | https://developer.mozilla.org/ru/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | We are using `django-csp` to provide these headers. 8 | Docs: https://github.com/mozilla/django-csp 9 | """ 10 | 11 | # These settings are overriden during development: 12 | CSP_SCRIPT_SRC: tuple[str, ...] = ("'self'",) 13 | CSP_IMG_SRC: tuple[str, ...] = ("'self'", 'https://via.placeholder.com') 14 | CSP_FONT_SRC: tuple[str, ...] = ("'self'",) 15 | CSP_STYLE_SRC: tuple[str, ...] = ("'self'", 'https://cdn.simplecss.org') 16 | CSP_DEFAULT_SRC: tuple[str, ...] = ("'none'",) 17 | CSP_CONNECT_SRC: tuple[str, ...] = () 18 | -------------------------------------------------------------------------------- /server/settings/components/identity.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse_lazy 2 | 3 | # Django authentication system 4 | # https://docs.djangoproject.com/en/4.2/topics/auth/ 5 | 6 | AUTH_USER_MODEL = 'identity.User' 7 | 8 | AUTHENTICATION_BACKENDS = ( 9 | 'axes.backends.AxesBackend', 10 | 'django.contrib.auth.backends.ModelBackend', 11 | ) 12 | 13 | PASSWORD_HASHERS = [ 14 | 'django.contrib.auth.hashers.Argon2PasswordHasher', 15 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 16 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 17 | 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 18 | ] 19 | 20 | 21 | # Login settings 22 | # https://docs.djangoproject.com/en/4.2/ref/settings/ 23 | 24 | LOGIN_URL = reverse_lazy('identity:login') 25 | LOGIN_REDIRECT_URL = reverse_lazy('pictures:dashboard') 26 | LOGOUT_REDIRECT_URL = reverse_lazy('index') 27 | 28 | 29 | # django-ratelimit 30 | # https://django-ratelimit.readthedocs.io/en/stable/ 31 | 32 | RATELIMIT_ENABLE = True 33 | 34 | 35 | # django-axes 36 | # https://django-axes.readthedocs.io/ 37 | 38 | AXES_LOCKOUT_PARAMETERS = ['username', 'user_agent'] 39 | AXES_RESET_ON_SUCCESS = True 40 | AXES_FAILURE_LIMIT = 5 41 | 42 | 43 | # django-password-reset 44 | # https://django-password-reset.readthedocs.io 45 | 46 | PASSWORD_RESET_TOKEN_EXPIRES = 3600 # one hour 47 | RECOVER_ONLY_ACTIVE_USERS = True 48 | -------------------------------------------------------------------------------- /server/settings/components/logging.py: -------------------------------------------------------------------------------- 1 | # Logging 2 | # https://docs.djangoproject.com/en/4.2/topics/logging/ 3 | 4 | # See also: 5 | # 'Do not log' by Nikita Sobolev (@sobolevn) 6 | # https://sobolevn.me/2020/03/do-not-log 7 | 8 | from typing import TYPE_CHECKING, Callable, final 9 | 10 | import structlog 11 | 12 | if TYPE_CHECKING: 13 | from django.http import HttpRequest, HttpResponse 14 | 15 | LOGGING = { 16 | 'version': 1, 17 | 'disable_existing_loggers': False, 18 | 19 | # We use these formatters in our `'handlers'` configuration. 20 | # Probably, you won't need to modify these lines. 21 | # Unless, you know what you are doing. 22 | 'formatters': { 23 | 'json_formatter': { 24 | '()': structlog.stdlib.ProcessorFormatter, 25 | 'processor': structlog.processors.JSONRenderer(), 26 | }, 27 | 'console': { 28 | '()': structlog.stdlib.ProcessorFormatter, 29 | 'processor': structlog.processors.KeyValueRenderer( 30 | key_order=['timestamp', 'level', 'event', 'logger'], 31 | ), 32 | }, 33 | }, 34 | 35 | # You can easily swap `key/value` (default) output and `json` ones. 36 | # Use `'json_console'` if you need `json` logs. 37 | 'handlers': { 38 | 'console': { 39 | 'class': 'logging.StreamHandler', 40 | 'formatter': 'console', 41 | }, 42 | 'json_console': { 43 | 'class': 'logging.StreamHandler', 44 | 'formatter': 'json_formatter', 45 | }, 46 | }, 47 | 48 | # These loggers are required by our app: 49 | # - django is required when using `logger.getLogger('django')` 50 | # - security is required by `axes` 51 | 'loggers': { 52 | 'django': { 53 | 'handlers': ['console'], 54 | 'propagate': True, 55 | 'level': 'INFO', 56 | }, 57 | 'security': { 58 | 'handlers': ['console'], 59 | 'level': 'ERROR', 60 | 'propagate': False, 61 | }, 62 | }, 63 | } 64 | 65 | 66 | @final 67 | class LoggingContextVarsMiddleware(object): 68 | """Used to reset ContextVars in structlog on each request.""" 69 | 70 | def __init__( 71 | self, 72 | get_response: 'Callable[[HttpRequest], HttpResponse]', 73 | ) -> None: 74 | """Django's API-compatible constructor.""" 75 | self.get_response = get_response 76 | 77 | def __call__(self, request: 'HttpRequest') -> 'HttpResponse': 78 | """ 79 | Handle requests. 80 | 81 | Add your logging metadata here. 82 | Example: https://github.com/jrobichaud/django-structlog 83 | """ 84 | response = self.get_response(request) 85 | structlog.contextvars.clear_contextvars() 86 | return response 87 | 88 | 89 | if not structlog.is_configured(): 90 | structlog.configure( 91 | processors=[ 92 | structlog.contextvars.merge_contextvars, 93 | structlog.stdlib.filter_by_level, 94 | structlog.processors.TimeStamper(fmt='iso'), 95 | structlog.stdlib.add_logger_name, 96 | structlog.stdlib.add_log_level, 97 | structlog.stdlib.PositionalArgumentsFormatter(), 98 | structlog.processors.StackInfoRenderer(), 99 | structlog.processors.format_exc_info, 100 | structlog.processors.UnicodeDecoder(), 101 | structlog.processors.ExceptionPrettyPrinter(), 102 | structlog.stdlib.ProcessorFormatter.wrap_for_formatter, 103 | ], 104 | logger_factory=structlog.stdlib.LoggerFactory(), 105 | wrapper_class=structlog.stdlib.BoundLogger, 106 | cache_logger_on_first_use=True, 107 | ) 108 | -------------------------------------------------------------------------------- /server/settings/components/placeholder.py: -------------------------------------------------------------------------------- 1 | # Custom settings for Placeholder API integration. 2 | # All settings must be documented! 3 | 4 | from server.settings.components import config 5 | 6 | # API url we use to fetch data, can be switched from real Placeholder API 7 | # to your custom one, that can be defined in `docker/placeholder`: 8 | PLACEHOLDER_API_URL = config('DJANGO_PLACEHOLDER_API_URL') 9 | 10 | # API default timeout in seconds: 11 | PLACEHOLDER_API_TIMEOUT = config('DJANGO_PLACEHOLDER_API_TIMEOUT', cast=int) 12 | -------------------------------------------------------------------------------- /server/settings/environments/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """Overriding settings based on the environment.""" 3 | -------------------------------------------------------------------------------- /server/settings/environments/development.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains all the settings that defines the development server. 3 | 4 | SECURITY WARNING: don't run with debug turned on in production! 5 | """ 6 | 7 | import logging 8 | import socket 9 | from typing import TYPE_CHECKING 10 | 11 | from server.settings.components import config 12 | from server.settings.components.common import ( 13 | DATABASES, 14 | INSTALLED_APPS, 15 | MIDDLEWARE, 16 | ) 17 | from server.settings.components.csp import ( 18 | CSP_CONNECT_SRC, 19 | CSP_IMG_SRC, 20 | CSP_SCRIPT_SRC, 21 | ) 22 | 23 | if TYPE_CHECKING: 24 | from django.http import HttpRequest 25 | 26 | # Setting the development status: 27 | 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [ 31 | config('DOMAIN_NAME'), 32 | 'localhost', 33 | '0.0.0.0', # noqa: S104 34 | '127.0.0.1', 35 | '[::1]', 36 | ] 37 | 38 | 39 | # Installed apps for development only: 40 | 41 | INSTALLED_APPS += ( 42 | # Better debug: 43 | 'debug_toolbar', 44 | 'nplusone.ext.django', 45 | 46 | # Linting migrations: 47 | 'django_migration_linter', 48 | 49 | # django-test-migrations: 50 | 'django_test_migrations.contrib.django_checks.AutoNames', 51 | # This check might be useful in production as well, 52 | # so it might be a good idea to move `django-test-migrations` 53 | # to prod dependencies and use this check in the main `settings.py`. 54 | # This will check that your database is configured properly, 55 | # when you run `python manage.py check` before deploy. 56 | 'django_test_migrations.contrib.django_checks.DatabaseConfiguration', 57 | 58 | # django-extra-checks: 59 | 'extra_checks', 60 | ) 61 | 62 | 63 | # Django debug toolbar: 64 | # https://django-debug-toolbar.readthedocs.io 65 | 66 | MIDDLEWARE += ( 67 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 68 | 69 | # https://github.com/bradmontgomery/django-querycount 70 | # Prints how many queries were executed, useful for the APIs. 71 | 'querycount.middleware.QueryCountMiddleware', 72 | ) 73 | 74 | # https://django-debug-toolbar.readthedocs.io/en/stable/installation.html#configure-internal-ips 75 | # This might fail on some OS 76 | try: # pragma: no cover 77 | INTERNAL_IPS = [ 78 | '{0}.1'.format(ip[:ip.rfind('.')]) 79 | for ip in socket.gethostbyname_ex(socket.gethostname())[2] 80 | ] 81 | except socket.error: # pragma: no cover 82 | INTERNAL_IPS = [] 83 | INTERNAL_IPS += ['127.0.0.1', '10.0.2.2'] 84 | 85 | 86 | def _custom_show_toolbar(request: 'HttpRequest') -> bool: 87 | """Only show the debug toolbar to users with the superuser flag.""" 88 | return DEBUG and request.user.is_superuser 89 | 90 | 91 | DEBUG_TOOLBAR_CONFIG = { 92 | 'SHOW_TOOLBAR_CALLBACK': 93 | 'server.settings.environments.development._custom_show_toolbar', 94 | } 95 | 96 | # This will make debug toolbar to work with django-csp, 97 | # since `ddt` loads some scripts from `ajax.googleapis.com`: 98 | CSP_SCRIPT_SRC += ('ajax.googleapis.com',) 99 | CSP_IMG_SRC += ('data:',) 100 | CSP_CONNECT_SRC += ("'self'",) 101 | 102 | 103 | # nplusone 104 | # https://github.com/jmcarp/nplusone 105 | 106 | # Should be the first in line: 107 | MIDDLEWARE = ( # noqa: WPS440 108 | 'nplusone.ext.django.NPlusOneMiddleware', 109 | ) + MIDDLEWARE 110 | 111 | # Logging N+1 requests: 112 | NPLUSONE_RAISE = True # comment out if you want to allow N+1 requests 113 | NPLUSONE_LOGGER = logging.getLogger('django') 114 | NPLUSONE_LOG_LEVEL = logging.WARN 115 | NPLUSONE_WHITELIST = [ 116 | {'model': 'admin.*'}, 117 | ] 118 | 119 | 120 | # django-test-migrations 121 | # https://github.com/wemake-services/django-test-migrations 122 | 123 | # Set of badly named migrations to ignore: 124 | DTM_IGNORED_MIGRATIONS = frozenset(( 125 | ('axes', '*'), 126 | )) 127 | 128 | 129 | # django-migration-linter 130 | # https://github.com/3YOURMIND/django-migration-linter 131 | 132 | MIGRATION_LINTER_OPTIONS = { 133 | 'exclude_apps': ['axes'], 134 | 'exclude_migration_tests': ['CREATE_INDEX', 'CREATE_INDEX_EXCLUSIVE'], 135 | } 136 | 137 | 138 | # django-extra-checks 139 | # https://github.com/kalekseev/django-extra-checks 140 | 141 | EXTRA_CHECKS = { 142 | 'checks': [ 143 | # Forbid `unique_together`: 144 | 'no-unique-together', 145 | # Use the indexes option instead: 146 | 'no-index-together', 147 | # Each model must be registered in admin: 148 | 'model-admin', 149 | # FileField/ImageField must have non empty `upload_to` argument: 150 | 'field-file-upload-to', 151 | # Text fields shouldn't use `null=True`: 152 | 'field-text-null', 153 | # Don't pass `null=False` to model fields (this is django default) 154 | 'field-null', 155 | # ForeignKey fields must specify db_index explicitly if used in 156 | # other indexes: 157 | {'id': 'field-foreign-key-db-index', 'when': 'indexes'}, 158 | # If field nullable `(null=True)`, 159 | # then default=None argument is redundant and should be removed: 160 | 'field-default-null', 161 | # Fields with choices must have companion CheckConstraint 162 | # to enforce choices on database level 163 | 'field-choices-constraint', 164 | ], 165 | } 166 | 167 | # Disable persistent DB connections 168 | # https://docs.djangoproject.com/en/4.2/ref/databases/#caveats 169 | DATABASES['default']['CONN_MAX_AGE'] = 0 170 | -------------------------------------------------------------------------------- /server/settings/environments/local.py.template: -------------------------------------------------------------------------------- 1 | """Override any custom settings here.""" 2 | -------------------------------------------------------------------------------- /server/settings/environments/production.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains all the settings used in production. 3 | 4 | This file is required and if development.py is present these 5 | values are overridden. 6 | """ 7 | 8 | from server.settings.components import config 9 | 10 | # Production flags: 11 | # https://docs.djangoproject.com/en/4.2/howto/deployment/ 12 | 13 | DEBUG = False 14 | 15 | ALLOWED_HOSTS = [ 16 | # TODO: check production hosts 17 | config('DOMAIN_NAME'), 18 | 19 | # We need this value for `healthcheck` to work: 20 | 'localhost', 21 | ] 22 | 23 | 24 | # Staticfiles 25 | # https://docs.djangoproject.com/en/4.2/ref/contrib/staticfiles/ 26 | 27 | # This is a hack to allow a special flag to be used with `--dry-run` 28 | # to test things locally. 29 | _COLLECTSTATIC_DRYRUN = config( 30 | 'DJANGO_COLLECTSTATIC_DRYRUN', cast=bool, default=False, 31 | ) 32 | # Adding STATIC_ROOT to collect static files via 'collectstatic': 33 | STATIC_ROOT = '.static' if _COLLECTSTATIC_DRYRUN else '/var/www/django/static' 34 | 35 | STATICFILES_STORAGE = ( 36 | # This is a string, not a tuple, 37 | # but it does not fit into 80 characters rule. 38 | 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' 39 | ) 40 | 41 | 42 | # Media files 43 | # https://docs.djangoproject.com/en/4.2/topics/files/ 44 | 45 | MEDIA_ROOT = '/var/www/django/media' 46 | 47 | 48 | # Password validation 49 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 50 | 51 | _PASS = 'django.contrib.auth.password_validation' # noqa: S105 52 | AUTH_PASSWORD_VALIDATORS = [ 53 | {'NAME': '{0}.UserAttributeSimilarityValidator'.format(_PASS)}, 54 | {'NAME': '{0}.MinimumLengthValidator'.format(_PASS)}, 55 | {'NAME': '{0}.CommonPasswordValidator'.format(_PASS)}, 56 | {'NAME': '{0}.NumericPasswordValidator'.format(_PASS)}, 57 | ] 58 | 59 | 60 | # Security 61 | # https://docs.djangoproject.com/en/4.2/topics/security/ 62 | 63 | SECURE_HSTS_SECONDS = 31536000 # the same as Caddy has 64 | SECURE_HSTS_INCLUDE_SUBDOMAINS = True 65 | SECURE_HSTS_PRELOAD = True 66 | 67 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 68 | SECURE_SSL_REDIRECT = True 69 | SECURE_REDIRECT_EXEMPT = [ 70 | # This is required for healthcheck to work: 71 | '^health/', 72 | ] 73 | 74 | SESSION_COOKIE_SECURE = True 75 | CSRF_COOKIE_SECURE = True 76 | -------------------------------------------------------------------------------- /server/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main URL mapping configuration file. 3 | 4 | Include other URLConfs from external apps using method `include()`. 5 | 6 | It is also a good practice to keep a single URL to the root index page. 7 | 8 | This examples uses Django's default media 9 | files serving technique in development. 10 | """ 11 | 12 | from django.conf import settings 13 | from django.contrib import admin 14 | from django.contrib.admindocs import urls as admindocs_urls 15 | from django.urls import include, path 16 | from django.views.generic import TemplateView 17 | from health_check import urls as health_urls 18 | 19 | from server.apps.identity import urls as identity_urls 20 | from server.apps.pictures import urls as pictures_urls 21 | from server.apps.pictures.views import IndexView 22 | 23 | admin.autodiscover() 24 | 25 | urlpatterns = [ 26 | # Apps: 27 | path('pictures/', include(pictures_urls, namespace='pictures')), 28 | path('identity/', include(identity_urls, namespace='identity')), 29 | 30 | # Health checks: 31 | path('health/', include(health_urls)), 32 | 33 | # django-admin: 34 | path('admin/doc/', include(admindocs_urls)), 35 | path('admin/', admin.site.urls), 36 | 37 | # Text and xml static files: 38 | path('robots.txt', TemplateView.as_view( 39 | template_name='common/txt/robots.txt', 40 | content_type='text/plain', 41 | )), 42 | path('humans.txt', TemplateView.as_view( 43 | template_name='common/txt/humans.txt', 44 | content_type='text/plain', 45 | )), 46 | 47 | # It is a good practice to have an explicit index view: 48 | path('', IndexView.as_view(), name='index'), 49 | ] 50 | 51 | if settings.DEBUG: # pragma: no cover 52 | import debug_toolbar # noqa: WPS433 53 | from django.conf.urls.static import static # noqa: WPS433 54 | 55 | urlpatterns = [ 56 | # URLs specific only to django-debug-toolbar: 57 | path('__debug__/', include(debug_toolbar.urls)), 58 | *urlpatterns, 59 | # Serving media files in development only: 60 | *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), 61 | ] 62 | -------------------------------------------------------------------------------- /server/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for server project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # All configuration for plugins and other utils is defined here. 2 | # Read more about `setup.cfg`: 3 | # https://docs.python.org/3/distutils/configfile.html 4 | 5 | 6 | [flake8] 7 | # flake8 configuration: 8 | # https://flake8.pycqa.org/en/latest/user/configuration.html 9 | format = wemake 10 | show-source = true 11 | statistics = false 12 | doctests = true 13 | enable-extensions = G 14 | 15 | # darglint configuration: 16 | # https://github.com/terrencepreilly/darglint 17 | strictness = long 18 | docstring-style = numpy 19 | 20 | # Flake plugins: 21 | max-line-length = 80 22 | max-complexity = 6 23 | max-imports = 14 24 | 25 | # Excluding some directories: 26 | exclude = .git,__pycache__,.venv,.eggs,*.egg,frontend,landing 27 | 28 | # Disable some pydocstyle checks: 29 | ignore = D100, D104, D106, D401, X100, W504, RST303, RST304, DAR103, DAR203 30 | 31 | # Docs: https://github.com/snoack/flake8-per-file-ignores 32 | # You can completely or partially disable our custom checks, 33 | # to do so you have to ignore `WPS` letters for all python files: 34 | per-file-ignores = 35 | # Allow upper-case constants in classes, because it is settings: 36 | server/common/django/types.py: WPS115 37 | # Allow `__init__.py` with logic for configuration: 38 | server/settings/*.py: WPS226, WPS407, WPS412, WPS432 39 | # Allow to have magic numbers and wrong module names inside migrations: 40 | server/*/migrations/*.py: WPS102, WPS114, WPS432 41 | # Tests have some more freedom: 42 | tests/*.py: S101, WPS201, WPS202, WPS218, WPS226, WPS436, WPS442 43 | 44 | 45 | [isort] 46 | # isort configuration: 47 | # https://github.com/PyCQA/isort/wiki/isort-Settings 48 | profile = wemake 49 | 50 | 51 | [tool:pytest] 52 | # pytest configuration: 53 | # https://docs.pytest.org/en/stable/customize.html 54 | 55 | # pytest-django configuration: 56 | # https://pytest-django.readthedocs.io/en/latest/ 57 | DJANGO_SETTINGS_MODULE = server.settings 58 | 59 | # Timeout for tests, so they can not take longer 60 | # than this amount of seconds. 61 | # You should adjust this value to be as low as possible. 62 | # Configuration: 63 | # https://pypi.org/project/pytest-timeout/ 64 | timeout = 5 65 | 66 | # Strict `@xfail` by default: 67 | xfail_strict = true 68 | 69 | # Directories that are not visited by pytest collector: 70 | norecursedirs = *.egg .eggs dist build docs .tox .git __pycache__ frontend landing 71 | 72 | # You will need to measure your tests speed with `-n auto` and without it, 73 | # so you can see whether it gives you any performance gain, or just gives 74 | # you an overhead. See `docs/template/development-process.rst`. 75 | addopts = 76 | --strict-markers 77 | --strict-config 78 | --doctest-modules 79 | --fail-on-template-vars 80 | # Output: 81 | --tb=short 82 | # Parallelism: 83 | # -n auto 84 | # --boxed 85 | # Coverage: 86 | --cov=server 87 | --cov=tests 88 | --cov-branch 89 | --cov-report=term-missing:skip-covered 90 | --cov-report=html 91 | --cov-report=xml 92 | --cov-fail-under=0 93 | 94 | filterwarnings = 95 | # TODO: get rid of these warnings in our code: 96 | ignore::django.utils.deprecation.RemovedInDjango50Warning 97 | ignore::django.utils.deprecation.RemovedInDjango51Warning 98 | # Some dependencies have deprecation warnings, we don't want to see them, 99 | # but, we want to list them here: 100 | ignore::DeprecationWarning:password_reset.*: 101 | ignore::DeprecationWarning:pytest_freezegun: 102 | 103 | 104 | [coverage:run] 105 | # Coverage configuration: 106 | # https://coverage.readthedocs.io/en/latest/config.html 107 | plugins = 108 | # Docs: https://github.com/nedbat/django_coverage_plugin 109 | django_coverage_plugin 110 | # Docs: https://pypi.org/project/covdefaults 111 | covdefaults 112 | 113 | omit = 114 | # Is not reported, because is imported during setup: 115 | server/settings/components/logging.py 116 | 117 | 118 | [mypy] 119 | # Mypy configuration: 120 | # https://mypy.readthedocs.io/en/latest/config_file.html 121 | enable_error_code = 122 | truthy-bool, 123 | truthy-iterable, 124 | redundant-expr, 125 | unused-awaitable, 126 | ignore-without-code, 127 | possibly-undefined, 128 | redundant-self, 129 | 130 | extra_checks = true 131 | 132 | disable_error_code = 133 | literal-required, 134 | 135 | enable_incomplete_feature = 136 | Unpack, 137 | 138 | allow_redefinition = false 139 | check_untyped_defs = true 140 | disallow_untyped_decorators = true 141 | disallow_any_explicit = false 142 | disallow_any_generics = true 143 | disallow_untyped_calls = true 144 | disallow_incomplete_defs = true 145 | explicit_package_bases = true 146 | ignore_errors = false 147 | ignore_missing_imports = true 148 | implicit_reexport = false 149 | local_partial_types = true 150 | strict_optional = true 151 | strict_equality = true 152 | no_implicit_optional = true 153 | warn_unused_ignores = true 154 | warn_redundant_casts = true 155 | warn_unused_configs = true 156 | warn_unreachable = true 157 | warn_no_return = true 158 | 159 | plugins = 160 | mypy_django_plugin.main, 161 | pydantic.mypy 162 | 163 | [mypy-server.apps.*.migrations.*] 164 | # Django migrations should not produce any errors (they are tested anyway): 165 | ignore_errors = true 166 | 167 | [mypy.plugins.django-stubs] 168 | # Docs: https://github.com/typeddjango/django-stubs 169 | django_settings_module = server.settings 170 | strict_settings = false 171 | 172 | 173 | [doc8] 174 | # doc8 configuration: 175 | # https://github.com/pycqa/doc8 176 | ignore-path = docs/_build 177 | max-line-length = 80 178 | sphinx = true 179 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is used to provide configuration, fixtures, and plugins for pytest. 3 | 4 | It may be also used for extending doctest's context: 5 | 1. https://docs.python.org/3/library/doctest.html 6 | 2. https://docs.pytest.org/en/latest/doctest.html 7 | """ 8 | 9 | pytest_plugins = [ 10 | # Should be the first custom one: 11 | 'plugins.django_settings', 12 | 13 | # TODO: add your own plugins here! 14 | ] 15 | -------------------------------------------------------------------------------- /tests/plugins/django_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import LazySettings 3 | from django.core.cache import BaseCache, caches 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def _media_root( 8 | settings: LazySettings, 9 | tmpdir_factory: pytest.TempPathFactory, 10 | ) -> None: 11 | """Forces django to save media files into temp folder.""" 12 | settings.MEDIA_ROOT = tmpdir_factory.mktemp('media', numbered=True) 13 | 14 | 15 | @pytest.fixture(autouse=True) 16 | def _password_hashers(settings: LazySettings) -> None: 17 | """Forces django to use fast password hashers for tests.""" 18 | settings.PASSWORD_HASHERS = [ 19 | 'django.contrib.auth.hashers.MD5PasswordHasher', 20 | ] 21 | 22 | 23 | @pytest.fixture(autouse=True) 24 | def _auth_backends(settings: LazySettings) -> None: 25 | """Deactivates security backend from Axes app.""" 26 | settings.AUTHENTICATION_BACKENDS = ( 27 | 'django.contrib.auth.backends.ModelBackend', 28 | ) 29 | 30 | 31 | @pytest.fixture(autouse=True) 32 | def _debug(settings: LazySettings) -> None: 33 | """Sets proper DEBUG and TEMPLATE debug mode for coverage.""" 34 | settings.DEBUG = False 35 | for template in settings.TEMPLATES: 36 | template['OPTIONS']['debug'] = True 37 | 38 | 39 | @pytest.fixture(autouse=True) 40 | def cache(settings: LazySettings) -> BaseCache: 41 | """Modifies how cache is used in Django tests.""" 42 | test_cache = 'test' 43 | 44 | # Patching cache settings: 45 | settings.CACHES[test_cache] = { 46 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 47 | } 48 | settings.RATELIMIT_USE_CACHE = test_cache 49 | settings.AXES_CACHE = test_cache 50 | 51 | # Clearing cache: 52 | caches[test_cache].clear() 53 | return caches[test_cache] 54 | -------------------------------------------------------------------------------- /tests/plugins/identity/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/tests/plugins/identity/.gitkeep -------------------------------------------------------------------------------- /tests/plugins/pictures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/tests/plugins/pictures/.gitkeep -------------------------------------------------------------------------------- /tests/test_apps/test_identity/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/tests/test_apps/test_identity/.gitkeep -------------------------------------------------------------------------------- /tests/test_apps/test_pictures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tough-dev-school/python-testing-homework/352aa9b27d17d25f9aa6d67ddb4bec03503ae326/tests/test_apps/test_pictures/.gitkeep -------------------------------------------------------------------------------- /tests/test_server/test_urls.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | from django.test import Client 5 | 6 | 7 | @pytest.mark.django_db() 8 | def test_health_check(client: Client) -> None: 9 | """This test ensures that health check is accessible.""" 10 | response = client.get('/health/') 11 | 12 | assert response.status_code == HTTPStatus.OK 13 | 14 | 15 | def test_admin_unauthorized(client: Client) -> None: 16 | """This test ensures that admin panel requires auth.""" 17 | response = client.get('/admin/') 18 | 19 | assert response.status_code == HTTPStatus.FOUND 20 | 21 | 22 | @pytest.mark.django_db() 23 | def test_admin_authorized(admin_client: Client) -> None: 24 | """This test ensures that admin panel is accessible.""" 25 | response = admin_client.get('/admin/') 26 | 27 | assert response.status_code == HTTPStatus.OK 28 | 29 | 30 | def test_admin_docs_unauthorized(client: Client) -> None: 31 | """This test ensures that admin panel docs requires auth.""" 32 | response = client.get('/admin/doc/') 33 | 34 | assert response.status_code == HTTPStatus.FOUND 35 | 36 | 37 | @pytest.mark.django_db() 38 | def test_admin_docs_authorized(admin_client: Client) -> None: 39 | """This test ensures that admin panel docs are accessible.""" 40 | response = admin_client.get('/admin/doc/') 41 | 42 | assert response.status_code == HTTPStatus.OK 43 | assert b'docutils' not in response.content 44 | 45 | 46 | @pytest.mark.parametrize('page', [ 47 | '/robots.txt', 48 | '/humans.txt', 49 | ]) 50 | def test_specials_txt(client: Client, page: str) -> None: 51 | """This test ensures that special `txt` files are accessible.""" 52 | response = client.get(page) 53 | 54 | assert response.status_code == HTTPStatus.OK 55 | assert response.get('Content-Type') == 'text/plain' 56 | --------------------------------------------------------------------------------