├── .dockerignore ├── .editorconfig ├── .env ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── docs.yml ├── .gitignore ├── Caddyfile ├── Dockerfile ├── LICENSE ├── README.md ├── docker-cmd.sh ├── docker-compose.caddy.yml ├── docker-compose.debug.yml ├── docker-compose.tls.yml ├── docker-compose.yml ├── docker-entrypoint.sh ├── docs ├── README.md ├── images │ ├── favicon.svg │ └── logo.svg └── stylesheets │ └── extra.css ├── mkdocs.yml ├── requirements.txt └── website ├── .coveragerc ├── manage.py ├── polls ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_question_upload.py │ └── __init__.py ├── models.py ├── static │ └── polls │ │ └── style.css ├── templates │ └── polls │ │ ├── detail.html │ │ ├── index.html │ │ └── results.html ├── tests.py ├── urls.py └── views.py ├── pytest.ini ├── pytest.sh ├── static └── empty ├── templates └── admin │ └── base_site.html └── website ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py /.dockerignore: -------------------------------------------------------------------------------- 1 | ### Django ### 2 | *.log 3 | *.pot 4 | *.pyc 5 | *.sqlite3 6 | *.sqlite3-journal 7 | __pycache__/ 8 | local_settings.py 9 | media 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [{Dockerfile,Caddyfile,Makefile,*.Makefile,go.mod,go.sum,*.go,.gitmodules}] 13 | indent_style = tab 14 | indent_size = 4 15 | 16 | [{*.md,*.py,*j.s}] 17 | indent_size = 4 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=django_user 2 | POSTGRES_PASSWORD=django_user_password 3 | POSTGRES_DB=django_db 4 | POSTGRES_HOST=postgres 5 | POSTGRES_PORT=5432 6 | 7 | GUNICORN_PORT=8000 8 | GUNICORN_WORKERS=2 9 | GUNICORN_TIMEOUT=60 10 | GUNICORN_LOG_LEVEL=info 11 | 12 | # DJANGO_SECRET_KEY= 13 | DJANGO_DEBUG=false 14 | DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost 15 | DJANGO_LANGUAGE_CODE=en-us 16 | DJANGO_TIME_ZONE=UTC 17 | DJANGO_CSRF_TRUSTED_ORIGINS= 18 | DJANGO_EMAIL_HOST=localhost 19 | DJANGO_EMAIL_PORT=25 20 | DJANGO_EMAIL_HOST_USER= 21 | DJANGO_EMAIL_HOST_PASSWORD= 22 | DJANGO_EMAIL_USE_TLS=false 23 | DJANGO_SERVER_EMAIL=root@localhost 24 | DJANGO_DEFAULT_FROM_EMAIL=webmaster@localhost 25 | DJANGO_ADMIN_NAME= 26 | DJANGO_ADMIN_EMAIL= 27 | DJANGO_SUPERUSER_USERNAME=admin 28 | DJANGO_SUPERUSER_PASSWORD=admin 29 | DJANGO_SUPERUSER_EMAIL=admin@example.com 30 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | paths-ignore: 10 | - '*.md' 11 | pull_request: 12 | paths-ignore: 13 | - '*.md' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Docker Buildx 24 | id: buildx 25 | uses: docker/setup-buildx-action@v2 26 | 27 | - name: Test and submit coverage report 28 | run: | 29 | docker build -t django-docker-template:master . 30 | docker run --rm -v $(pwd)/website/:/usr/src/website django-docker-template:master ./pytest.sh 31 | pip install coveralls 32 | cd website && coveralls --service=github 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | permissions: 8 | contents: write 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Configure Git Credentials 15 | run: | 16 | git config user.name github-actions[bot] 17 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.x 21 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 22 | - uses: actions/cache@v4 23 | with: 24 | key: mkdocs-material-${{ env.cache_id }} 25 | path: .cache 26 | restore-keys: | 27 | mkdocs-material- 28 | - run: pip install mkdocs-material 29 | - run: mkdocs gh-deploy --force 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,django 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,django 3 | 4 | ### Django ### 5 | *.log 6 | *.pot 7 | *.pyc 8 | *.sqlite3 9 | *.sqlite3-journal 10 | __pycache__/ 11 | local_settings.py 12 | media 13 | 14 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 15 | # in your Git repository. Update and uncomment the following line accordingly. 16 | # /staticfiles/ 17 | 18 | ### Django.Python Stack ### 19 | # Byte-compiled / optimized / DLL files 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | cover/ 70 | 71 | # Translations 72 | *.mo 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # pyenv 78 | # For a library or package, you might want to ignore these files since the code is 79 | # intended to run in multiple environments; otherwise, check them in: 80 | .python-version 81 | 82 | # pipenv 83 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 84 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 85 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 86 | # install all needed dependencies. 87 | Pipfile.lock 88 | 89 | # pdm 90 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 91 | #pdm.lock 92 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 93 | # in version control. 94 | # https://pdm.fming.dev/#use-with-ide 95 | .pdm.toml 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | .dmypy.json 110 | dmypy.json 111 | 112 | # Pyre type checker 113 | .pyre/ 114 | 115 | # pytype static type analyzer 116 | .pytype/ 117 | 118 | # Cython debug symbols 119 | cython_debug/ 120 | 121 | # PyCharm 122 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 123 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 124 | # and can be added to the global gitignore or merged into this file. For a more nuclear 125 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 126 | .idea/ 127 | 128 | # VSCode 129 | .vscode 130 | 131 | # System 132 | .DS_Store 133 | 134 | # Let's Encrypt (Traefik) 135 | letsencrypt/ 136 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | {$MY_DOMAIN} { 2 | 3 | handle_path /static/* { 4 | root * /usr/share/caddy/static 5 | file_server 6 | } 7 | 8 | handle_path /media/* { 9 | root * /usr/share/caddy/media 10 | file_server 11 | } 12 | 13 | handle { 14 | reverse_proxy django:{$GUNICORN_PORT} 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine as base 2 | 3 | FROM base as builder 4 | 5 | RUN apk update && apk --no-cache add python3-dev libpq-dev && mkdir /install 6 | WORKDIR /install 7 | COPY requirements.txt ./ 8 | RUN pip install --no-cache-dir --prefix=/install -r ./requirements.txt 9 | 10 | FROM base 11 | 12 | ARG USER=user 13 | ARG USER_UID=1001 14 | ARG PROJECT_NAME=website 15 | ARG GUNICORN_PORT=8000 16 | ARG GUNICORN_WORKERS=2 17 | # the value is in seconds 18 | ARG GUNICORN_TIMEOUT=60 19 | ARG GUNICORN_LOG_LEVEL=info 20 | ARG DJANGO_BASE_DIR=/usr/src/$PROJECT_NAME 21 | ARG DJANGO_STATIC_ROOT=/var/www/static 22 | ARG DJANGO_MEDIA_ROOT=/var/www/media 23 | ARG DJANGO_SQLITE_DIR=/sqlite 24 | # The superuser with the data below will be created only if there are no users in the database! 25 | ARG DJANGO_SUPERUSER_USERNAME=admin 26 | ARG DJANGO_SUPERUSER_PASSWORD=admin 27 | ARG DJANGO_SUPERUSER_EMAIL=admin@example.com 28 | ARG DJANGO_DEV_SERVER_PORT=8000 29 | 30 | 31 | ENV \ 32 | USER=$USER \ 33 | USER_UID=$USER_UID \ 34 | PROJECT_NAME=$PROJECT_NAME \ 35 | GUNICORN_PORT=$GUNICORN_PORT \ 36 | GUNICORN_WORKERS=$GUNICORN_WORKERS \ 37 | GUNICORN_TIMEOUT=$GUNICORN_TIMEOUT \ 38 | GUNICORN_LOG_LEVEL=$GUNICORN_LOG_LEVEL \ 39 | DJANGO_BASE_DIR=$DJANGO_BASE_DIR \ 40 | DJANGO_STATIC_ROOT=$DJANGO_STATIC_ROOT \ 41 | DJANGO_MEDIA_ROOT=$DJANGO_MEDIA_ROOT \ 42 | DJANGO_SQLITE_DIR=$DJANGO_SQLITE_DIR \ 43 | DJANGO_SUPERUSER_USERNAME=$DJANGO_SUPERUSER_USERNAME \ 44 | DJANGO_SUPERUSER_PASSWORD=$DJANGO_SUPERUSER_PASSWORD \ 45 | DJANGO_SUPERUSER_EMAIL=$DJANGO_SUPERUSER_EMAIL \ 46 | DJANGO_DEV_SERVER_PORT=$DJANGO_DEV_SERVER_PORT 47 | 48 | 49 | COPY --from=builder /install /usr/local 50 | COPY docker-entrypoint.sh / 51 | COPY docker-cmd.sh / 52 | COPY $PROJECT_NAME $DJANGO_BASE_DIR 53 | 54 | # User 55 | RUN chmod +x /docker-entrypoint.sh /docker-cmd.sh && \ 56 | apk --no-cache add su-exec libpq-dev && \ 57 | mkdir -p $DJANGO_STATIC_ROOT $DJANGO_MEDIA_ROOT $DJANGO_SQLITE_DIR && \ 58 | adduser -s /bin/sh -D -u $USER_UID $USER && \ 59 | chown -R $USER:$USER $DJANGO_BASE_DIR $DJANGO_STATIC_ROOT $DJANGO_MEDIA_ROOT $DJANGO_SQLITE_DIR 60 | 61 | WORKDIR $DJANGO_BASE_DIR 62 | ENTRYPOINT ["/docker-entrypoint.sh"] 63 | CMD ["/docker-cmd.sh"] 64 | 65 | EXPOSE $GUNICORN_PORT 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 amerkurev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django + Docker = ❤️ 2 |
3 | 4 | [![Build](https://github.com/amerkurev/django-docker-template/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/amerkurev/django-docker-template/actions/workflows/ci.yml) 5 | [![Coverage Status](https://coveralls.io/repos/github/amerkurev/django-docker-template/badge.svg)](https://coveralls.io/github/amerkurev/django-docker-template) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/amerkurev/django-docker-template/blob/master/LICENSE) 7 |
8 | 9 | This simple Django project is an excellent template for your future projects. 10 | It includes everything you need to quickly set up a quality technology stack and start developing your web application's business logic, skipping all the complex deployment issues at an early stage. 11 | 12 | ## See in Action 13 | Deploying a Django Project in Production with Automatic Let's Encrypt HTTPS in Just 55 Seconds... 🏎️💨 ...🏁 14 | 15 | image 16 | 17 | *[Star this project if it's what you were looking for!](https://github.com/amerkurev/django-docker-template) ⭐️* 18 | 19 | ## Technology stack 20 | The technology stack used includes: 21 | 22 | - [`Python`](https://www.python.org) ver. 3.11 23 | - [`Django`](https://www.djangoproject.com) ver. 4.2 24 | - [`PostgreSQL`](https://www.postgresql.org) ver. 15 25 | - [`Gunicorn`](https://gunicorn.org) ver. 22.0 26 | - [`Traefik`](https://traefik.io/traefik/) ver. 2.9 27 | - [`Caddy`](https://caddyserver.com) ver. 2.7 *(instead of Traefik if you wish)* 28 | - [`Docker`](https://docs.docker.com/get-docker/) and [`Docker Compose`](https://docs.docker.com/compose/) 29 | 30 | Nothing extra, only the essentials! You can easily add everything else yourself by expanding the existing configuration files: 31 | 32 | - [requirements.txt](https://github.com/amerkurev/django-docker-template/blob/master/requirements.txt) 33 | - [docker-compose.yml](https://github.com/amerkurev/django-docker-template/blob/master/docker-compose.yml) 34 | - [pytest.ini](https://github.com/amerkurev/django-docker-template/blob/master/website/pytest.ini) 35 | - and others... 36 | 37 | > This project includes a simple Django application from the official Django tutorial - ["a basic poll application"](https://docs.djangoproject.com/en/4.2/intro/tutorial01/). 38 | > You can safely delete this application at any time. This application is present in the project as an example, used for testing and debugging. 39 | 40 | So, what do you get by using this project as a template for your project? Let's take a look. 41 | 42 | ## Features 43 | - A well-configured Django project, with individual settings that can be changed using environment variables 44 | - Building and debugging a Django project in Docker 45 | - Integrated [pytest](https://docs.pytest.org) and [coverage](https://coverage.readthedocs.io) for robust testing and code quality assurance ✅ 46 | - A ready-made docker-compose file that brings together Postgres - Django - Gunicorn - Traefik (or Caddy) 47 | - Serving static files (and user-uploaded files) with Nginx 48 | - Automatic database migration and static file collection when starting or restarting the Django container 49 | - Automatic creation of the first user in Django with a default login and password 50 | - Automatic creation and renewal of Let's Encrypt certificate 🔥 51 | - Minimal dependencies 52 | - Everything is set up as simply as possible - just a couple of commands in the terminal, and you have a working project 🚀 53 | 54 | ## How to use 55 | 56 | ### For development on your computer 57 | 58 | 1. Clone the repository to your computer and go to the `django-docker-template` directory: 59 | ```console 60 | git clone https://github.com/amerkurev/django-docker-template.git 61 | cd django-docker-template 62 | ``` 63 | 64 | 2. Build the Docker container image with Django: 65 | ```console 66 | docker build -t django-docker-template:master . 67 | ``` 68 | 69 | 3. Create the first superuser: 70 | ```console 71 | docker run -it --rm -v sqlite:/sqlite django-docker-template:master python manage.py createsuperuser 72 | ``` 73 | 74 | 4. Run the Django development server inside the Django container: 75 | ```console 76 | docker run -it --rm -p 8000:8000 -v sqlite:/sqlite -v $(pwd)/website:/usr/src/website django-docker-template:master python manage.py runserver 0.0.0.0:8000 77 | ``` 78 | 79 | Now you can go to [http://127.0.0.1:8000/admin/](http://127.0.0.1:8000/admin/) in your browser. Go to the Django admin panel and try updating the server code "on the fly". 80 | Everything works just like if you were running the Django development server outside the container. 81 | 82 | > Note that we mount the directory with your source code inside the container, so you can work with the project in your IDE, and changes will be visible inside the container, and the Django development server will restart itself. 83 | 84 |
85 | SQLite Usage Details 86 | 87 | > Another important point is the use of SQLite3 instead of Postgres, because Postgres is not deployed until Django is run within a Docker Compose environment. 88 | > In our example, we add a volume named `sqlite`. This data is stored persistently and does not disappear between restarts of the Django development server. 89 | > However, if you have a second similar project, it would be better to change the volume name from `sqlite` to something else so that the second project uses its own copy of the database. For example: 90 | > 91 | ```console 92 | docker run -it --rm -p 8000:8000 -v another_sqlite:/sqlite -v $(pwd)/website:/usr/src/website django-docker-template:master python manage.py runserver 0.0.0.0:8000 93 | ``` 94 | > 95 | > To better understand how volumes work in Docker, refer to the official [documentation](https://docs.docker.com/storage/volumes/). 96 |
97 | 98 | 5. Run tests with pytest and coverage ✅: 99 | ```console 100 | docker run --rm django-docker-template:master ./pytest.sh 101 | ``` 102 | The [pytest.sh](https://github.com/amerkurev/django-docker-template/blob/master/website/pytest.sh) script runs tests using pytest and coverage. As a result, you will see an output like this in the terminal: 103 | ```console 104 | ================== test session starts ===================================== 105 | platform linux -- Python 3.11.7, pytest-7.4.4, pluggy-1.3.0 106 | django: version: 4.2.9, settings: website.settings (from ini) 107 | rootdir: /usr/src/website 108 | configfile: pytest.ini 109 | plugins: django-4.7.0 110 | collected 10 items 111 | 112 | polls/tests.py .......... [100%] 113 | 114 | ================== 10 passed in 0.19s ====================================== 115 | Name Stmts Miss Cover Missing 116 | ------------------------------------------------------------------------ 117 | polls/__init__.py 0 0 100% 118 | polls/admin.py 12 0 100% 119 | polls/apps.py 4 0 100% 120 | polls/migrations/0001_initial.py 6 0 100% 121 | polls/migrations/0002_question_upload.py 4 0 100% 122 | polls/migrations/__init__.py 0 0 100% 123 | polls/models.py 20 2 90% 15, 33 124 | polls/tests.py 57 0 100% 125 | polls/urls.py 4 0 100% 126 | polls/views.py 28 8 71% 39-58 127 | website/__init__.py 6 0 100% 128 | website/settings.py 52 2 96% 94, 197 129 | website/urls.py 6 0 100% 130 | ------------------------------------------------------------------------ 131 | TOTAL 199 12 94% 132 | ``` 133 | 134 | > If you don't want to use pytest (for some reason), you can run the tests without pytest using the command below: 135 | ```console 136 | docker run --rm django-docker-template:master python manage.py test 137 | ``` 138 | 139 | 6. Interactive shell with the Django project environment: 140 | ```console 141 | docker run -it --rm -v sqlite:/sqlite django-docker-template:master python manage.py shell 142 | ``` 143 | 144 | 7. Start all services locally (Postgres, Gunicorn, Traefik) using docker-compose: 145 | ```console 146 | docker compose -f docker-compose.debug.yml up 147 | ``` 148 | 149 | Enjoy watching the lines run in the terminal 🖥️ 150 | And after a few seconds, open your browser at [http://127.0.0.1/admin/](http://127.0.0.1/admin/). The superuser with the login and password `admin/admin` is already created, welcome to the Django admin panel. 151 | 152 | Django is still in Debug mode! You can work in your IDE, write code, and immediately see changes inside the container. However, you are currently using Traefik and Postgres. 153 | You can also add Redis or MongoDB, and all of this will work in your development environment. This is very convenient. 154 | 155 | > Between Docker Compose restarts, your database data and media files uploaded to the server will be preserved because they are stored in special volumes that are not deleted when containers are restarted. 156 | 157 | Want to delete everything? No problem, the command below will stop all containers, remove them and their images. 158 | ```console 159 | docker compose down --remove-orphans --rmi local 160 | ``` 161 | 162 | To delete the Postgre database as well, add the `-v` flag to the command: 163 | ```console 164 | docker compose down --remove-orphans --rmi local -v 165 | ``` 166 | 167 | #### Django settings 168 | 169 | Some Django settings from the [`settings.py`](https://github.com/amerkurev/django-docker-template/blob/master/website/website/settings.py) file are stored in environment variables. 170 | You can easily change these settings in the [`.env`](https://github.com/amerkurev/django-docker-template/blob/master/.env) file. 171 | This file does not contain all the necessary settings, but many of them. Add additional settings to environment variables if needed. 172 | 173 | > It is important to note the following: **never store sensitive settings such as DJANGO_SECRET_KEY or DJANGO_EMAIL_HOST_PASSWORD in your repository!** 174 | > Docker allows you to override environment variable values from additional files, the command line, or the current session. Store passwords and other sensitive information separately from the code and only connect this information at system startup. 175 | 176 | ### For deployment on a server 177 | 178 | #### Prerequisite 179 | 180 | For the Let's Encrypt HTTP challenge you will need: 181 | 182 | - A publicly accessible host allowing connections on port `80` & `443` with docker & docker-compose installed. A virtual machine in any cloud provider can be used as a host. 183 | - A DNS record with the domain you want to expose pointing to this host. 184 | 185 | #### Steps on a server 186 | 187 | 1. Clone the repository on your host and go to the `django-docker-template` directory: 188 | ```console 189 | git clone https://github.com/amerkurev/django-docker-template.git 190 | cd django-docker-template 191 | ``` 192 | 193 | 2. Configure as described in the [Django settings](#django-settings) section or leave everything as is. 194 | 195 | 3. Run, specifying your domain: 196 | ```console 197 | MY_DOMAIN=your.domain.com docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d 198 | ``` 199 | 200 | It will take a few seconds to start the database, migrate, collect static files, and obtain a Let's Encrypt certificate. So wait a little and open https://your.domain.com in your browser. Your server is ready to work 🏆 201 | 202 | > Don't worry about renewing the Let's Encrypt certificate, it will happen automatically. 203 | 204 | 4. After running the containers, you can execute [manage.py commands](https://docs.djangoproject.com/en/4.2/ref/django-admin/#available-commands) using this format: 205 | ```console 206 | docker compose exec django python manage.py check --deploy 207 | 208 | docker compose exec django python manage.py shell 209 | ``` 210 | 211 | ### Using Caddy Server Instead of Traefik 212 | 213 | Traefik is a great edge router, but it doesn't serve static files, which is why we pair it with [Nginx](https://github.com/amerkurev/django-docker-template/blob/master/docker-compose.yml#L26) in our setup. 214 | If you prefer a single tool that can handle everything, you might want to try [Caddy](https://caddyserver.com). 215 | 216 | Caddy can automatically handle the creation and renewal of Let's Encrypt certificates and also serve static files, which allows you to use just one server instead of two. 217 | 218 | Here's how to set up Caddy with your project: 219 | 220 | 1. Ensure you have a [`Caddyfile`](https://github.com/amerkurev/django-docker-template/blob/master/Caddyfile) in your project directory. This file will tell Caddy how to deliver static and media files and how to forward other requests to your Django app. 221 | 222 | 2. Swap out the `docker-compose.yml` and `docker-compose.tls.yml` with a single [`docker-compose.caddy.yml`](https://github.com/amerkurev/django-docker-template/blob/master/docker-compose.caddy.yml). This file is designed to set up Caddy with Django and Postgres, and it doesn't include Nginx, which makes the file shorter and easier to understand. 223 | 224 | 3. To get your Django project up with Caddy, run the following command, making sure to replace `your.domain.com` with your actual domain: 225 | 226 | ```console 227 | MY_DOMAIN=your.domain.com docker compose -f docker-compose.caddy.yml up -d 228 | ``` 229 | 230 | Choosing Caddy simplifies your setup by combining the functionalities of Traefik and Nginx into one. It's straightforward and takes care of HTTPS certificates for you automatically. 231 | Enjoy the ease of deployment with Caddy! 232 | 233 | ## What's next? 234 | 235 | Now that you have a working project, you can extend it as you like, adding [dashboards for monitoring service health](https://doc.traefik.io/traefik/operations/dashboard/), [centralized log collection](https://www.fluentd.org), [secret storage](https://www.vaultproject.io), and of course, your own Django applications. 236 | All of this is beyond the scope of the current description, as the idea of this project is minimalism and providing only the essentials. Good luck! 237 | 238 | ## License 239 | 240 | [MIT](https://github.com/amerkurev/django-docker-template/blob/master/LICENSE) 241 | -------------------------------------------------------------------------------- /docker-cmd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # vim:sw=4:ts=4:et 3 | 4 | su-exec "$USER" python manage.py collectstatic --noinput 5 | 6 | # Creating the first user in the system 7 | USER_EXISTS="from django.contrib.auth import get_user_model; User = get_user_model(); exit(User.objects.exists())" 8 | su-exec "$USER" python manage.py shell -c "$USER_EXISTS" && su-exec "$USER" python manage.py createsuperuser --noinput 9 | 10 | if [ "$1" = "--debug" ]; then 11 | # Django development server 12 | exec su-exec "$USER" python manage.py runserver "0.0.0.0:$DJANGO_DEV_SERVER_PORT" 13 | else 14 | # Gunicorn 15 | exec su-exec "$USER" gunicorn "$PROJECT_NAME.wsgi:application" \ 16 | --bind "0.0.0.0:$GUNICORN_PORT" \ 17 | --workers "$GUNICORN_WORKERS" \ 18 | --timeout "$GUNICORN_TIMEOUT" \ 19 | --log-level "$GUNICORN_LOG_LEVEL" 20 | fi 21 | -------------------------------------------------------------------------------- /docker-compose.caddy.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | postgres: 4 | image: postgres:15 5 | env_file: .env 6 | restart: unless-stopped 7 | volumes: 8 | - "postgres-data:/var/lib/postgresql/data/" 9 | 10 | django: 11 | build: . 12 | image: django-docker 13 | env_file: .env 14 | environment: 15 | - "DJANGO_ALLOWED_HOSTS=${MY_DOMAIN}" 16 | - "DJANGO_CSRF_TRUSTED_ORIGINS=https://${MY_DOMAIN}" 17 | - "DJANGO_SESSION_COOKIE_SECURE=true" 18 | - "DJANGO_CSRF_COOKIE_SECURE=true" 19 | - "DJANGO_SECURE_SSL_REDIRECT=true" 20 | restart: unless-stopped 21 | volumes: 22 | - "staticfiles-data:/var/www/static" 23 | - "media-data:/var/www/media" 24 | depends_on: 25 | - postgres 26 | 27 | caddy: 28 | image: caddy:2.7-alpine 29 | env_file: .env 30 | environment: 31 | - "MY_DOMAIN=${MY_DOMAIN}" 32 | restart: unless-stopped 33 | ports: 34 | - "80:80" 35 | - "443:443" 36 | volumes: 37 | - "./Caddyfile:/etc/caddy/Caddyfile:ro" 38 | - "caddy-data:/data" 39 | - "caddy-config:/config" 40 | - type: volume 41 | source: media-data 42 | target: /usr/share/caddy/media 43 | read_only: true 44 | volume: 45 | nocopy: true 46 | - type: volume 47 | source: staticfiles-data 48 | target: /usr/share/caddy/static 49 | read_only: true 50 | volume: 51 | nocopy: true 52 | 53 | volumes: 54 | caddy-data: 55 | caddy-config: 56 | media-data: 57 | postgres-data: 58 | staticfiles-data: 59 | -------------------------------------------------------------------------------- /docker-compose.debug.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | postgres: 4 | image: postgres:15 5 | env_file: .env 6 | restart: unless-stopped 7 | volumes: 8 | - "postgres-data:/var/lib/postgresql/data/" 9 | 10 | django: 11 | build: . 12 | image: django-docker 13 | env_file: .env 14 | environment: 15 | - "DJANGO_DEBUG=true" 16 | restart: unless-stopped 17 | command: ["/docker-cmd.sh", "--debug"] 18 | volumes: 19 | - "media-data:/var/www/media" 20 | - "./website:/usr/src/website" # mount the source code for watching changes 21 | depends_on: 22 | - postgres 23 | labels: 24 | - "traefik.enable=true" 25 | - "traefik.http.routers.development.rule=Host(`127.0.0.1`) || Host(`localhost`)" 26 | - "traefik.http.routers.development.entrypoints=web" 27 | 28 | reverse-proxy: 29 | image: traefik:v2.9 30 | env_file: .env 31 | restart: unless-stopped 32 | command: 33 | #- "--log.level=DEBUG" 34 | - "--api.insecure=true" 35 | - "--providers.docker=true" 36 | - "--providers.docker.exposedbydefault=false" 37 | - "--entrypoints.web.address=:80" 38 | - "--entrypoints.websecure.address=:443" 39 | - "--certificatesresolvers.le-resolver.acme.httpchallenge=true" 40 | - "--certificatesresolvers.le-resolver.acme.httpchallenge.entrypoint=web" 41 | - "--certificatesresolvers.le-resolver.acme.email=admin@yourdomain.com" 42 | - "--certificatesresolvers.le-resolver.acme.storage=/letsencrypt/acme.json" 43 | ports: 44 | - "80:80" 45 | - "443:443" 46 | #- "8080:8080" 47 | volumes: 48 | - "./letsencrypt:/letsencrypt" 49 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 50 | 51 | volumes: 52 | postgres-data: 53 | media-data: 54 | -------------------------------------------------------------------------------- /docker-compose.tls.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | django: 4 | environment: 5 | - "DJANGO_ALLOWED_HOSTS=${MY_DOMAIN}" 6 | - "DJANGO_CSRF_TRUSTED_ORIGINS=https://${MY_DOMAIN}" 7 | - "DJANGO_SESSION_COOKIE_SECURE=true" 8 | - "DJANGO_CSRF_COOKIE_SECURE=true" 9 | - "DJANGO_SECURE_SSL_REDIRECT=true" 10 | labels: 11 | - "traefik.http.routers.production.rule=Host(`${MY_DOMAIN}`)" 12 | - "traefik.http.routers.production.entrypoints=websecure" 13 | - "traefik.http.routers.production.tls.certresolver=le-resolver" 14 | - "traefik.http.routers.production.priority=1" 15 | 16 | nginx: 17 | labels: 18 | - "traefik.http.routers.staticfiles.entrypoints=websecure" 19 | - "traefik.http.routers.staticfiles.tls.certresolver=le-resolver" 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | postgres: 4 | image: postgres:15 5 | env_file: .env 6 | restart: unless-stopped 7 | volumes: 8 | - "postgres-data:/var/lib/postgresql/data/" 9 | 10 | django: 11 | build: . 12 | image: django-docker 13 | env_file: .env 14 | restart: unless-stopped 15 | volumes: 16 | - "staticfiles-data:/var/www/static" 17 | - "media-data:/var/www/media" 18 | depends_on: 19 | - postgres 20 | labels: 21 | - "traefik.enable=true" 22 | - "traefik.http.routers.development.rule=Host(`127.0.0.1`) || Host(`localhost`)" 23 | - "traefik.http.routers.development.entrypoints=web" 24 | - "traefik.http.routers.development.priority=1" 25 | 26 | nginx: 27 | image: nginx:1.23-alpine 28 | env_file: .env 29 | restart: unless-stopped 30 | volumes: 31 | - type: volume 32 | source: media-data 33 | target: /usr/share/nginx/html/media 34 | read_only: true 35 | volume: 36 | nocopy: true 37 | - type: volume 38 | source: staticfiles-data 39 | target: /usr/share/nginx/html/static 40 | read_only: true 41 | volume: 42 | nocopy: true 43 | depends_on: 44 | - django 45 | labels: 46 | - "traefik.enable=true" 47 | - "traefik.http.routers.staticfiles.rule=PathPrefix(`/static/`) || PathPrefix(`/media/`)" 48 | - "traefik.http.routers.staticfiles.entrypoints=web" 49 | - "traefik.http.routers.staticfiles.priority=2" 50 | 51 | reverse-proxy: 52 | image: traefik:v2.9 53 | env_file: .env 54 | restart: unless-stopped 55 | command: 56 | #- "--log.level=DEBUG" 57 | - "--api.insecure=true" 58 | - "--providers.docker=true" 59 | - "--providers.docker.exposedbydefault=false" 60 | - "--entrypoints.web.address=:80" 61 | - "--entrypoints.websecure.address=:443" 62 | - "--certificatesresolvers.le-resolver.acme.httpchallenge=true" 63 | - "--certificatesresolvers.le-resolver.acme.httpchallenge.entrypoint=web" 64 | - "--certificatesresolvers.le-resolver.acme.email=admin@yourdomain.com" 65 | - "--certificatesresolvers.le-resolver.acme.storage=/letsencrypt/acme.json" 66 | ports: 67 | - "80:80" 68 | - "443:443" 69 | #- "8080:8080" 70 | volumes: 71 | - "./letsencrypt:/letsencrypt" 72 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 73 | 74 | volumes: 75 | postgres-data: 76 | staticfiles-data: 77 | media-data: 78 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # vim:sw=4:ts=4:et 3 | 4 | set -e 5 | 6 | if [ -z ${POSTGRES_DB+x} ]; then 7 | echo "SQLite will be used."; 8 | else 9 | wait-for-it -s "$POSTGRES_HOST:$POSTGRES_PORT" -t 60 10 | fi 11 | # You can comment out this line if you want to migrate manually 12 | su-exec "$USER" python manage.py migrate --noinput 13 | 14 | exec "$@" 15 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" 2 | -------------------------------------------------------------------------------- /docs/images/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | article { 2 | max-width: 800px !important; 3 | } 4 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json 2 | 3 | # https://www.mkdocs.org/user-guide/configuration/ 4 | site_name: Django Docker Template 5 | site_url: https://django-docker.dev/ 6 | site_description: Dockerized Django with Postgres, Gunicorn, and Traefik or Caddy (with auto renew Let's Encrypt) 7 | site_author: https://github.com/amerkurev 8 | 9 | repo_url: https://github.com/amerkurev/django-docker-template 10 | repo_name: django-docker-template 11 | 12 | edit_uri: edit/master/ 13 | 14 | theme: 15 | name: material 16 | # https://icon-sets.iconify.design/?query=django 17 | logo: images/logo.svg 18 | favicon: images/favicon.svg 19 | icon: 20 | repo: fontawesome/brands/github 21 | features: 22 | - toc.integrate 23 | - content.code.copy 24 | - content.code.annotate 25 | # - navigation.instant 26 | # - navigation.instant.progress 27 | # - navigation.tabs 28 | - search.suggest 29 | - search.highlight 30 | - search.share 31 | - navigation.footer 32 | - content.action.edit 33 | # - content.action.view 34 | - content.tabs.link 35 | - content.tooltips 36 | - navigation.top 37 | # - navigation.expand 38 | # - navigation.tracking 39 | # https://squidfunk.github.io/mkdocs-material/setup/changing-the-colors/#system-preference 40 | palette: 41 | # Palette toggle for automatic mode 42 | - media: "(prefers-color-scheme)" 43 | toggle: 44 | icon: material/brightness-auto 45 | name: Switch to light mode 46 | 47 | # Palette toggle for light mode 48 | - media: "(prefers-color-scheme: light)" 49 | scheme: default 50 | toggle: 51 | icon: material/brightness-7 52 | name: Switch to dark mode 53 | 54 | # Palette toggle for dark mode 55 | - media: "(prefers-color-scheme: dark)" 56 | scheme: slate 57 | toggle: 58 | icon: material/brightness-4 59 | name: Switch to system preference 60 | 61 | # noinspection YAMLSchemaValidation 62 | markdown_extensions: 63 | - smarty 64 | - sane_lists 65 | # https://squidfunk.github.io/mkdocs-material/setup/extensions/python-markdown/ 66 | - abbr 67 | - admonition 68 | - attr_list 69 | - def_list 70 | - md_in_html 71 | - tables 72 | - toc: 73 | permalink: true 74 | 75 | # https://squidfunk.github.io/mkdocs-material/setup/extensions/python-markdown-extensions/ 76 | - pymdownx.caret 77 | - pymdownx.mark 78 | - pymdownx.tilde 79 | - pymdownx.betterem 80 | - pymdownx.details 81 | - pymdownx.snippets 82 | - pymdownx.highlight 83 | - pymdownx.inlinehilite 84 | 85 | # noinspection YAMLSchemaValidation 86 | plugins: 87 | - search 88 | 89 | extra_css: 90 | - stylesheets/extra.css 91 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage==7.4.0 # for testing 2 | django~=4.2.20 3 | gunicorn==22.0.0 4 | psycopg2-binary==2.9.9 5 | pytest-django==4.7.0 # for testing 6 | pytest==7.4.4 # for testing 7 | wait-for-it==2.2.2 8 | -------------------------------------------------------------------------------- /website/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | relative_files = True 3 | omit = 4 | */migrations/* 5 | 6 | [report] 7 | exclude_lines = 8 | pragma: no cover 9 | def __repr__ 10 | -------------------------------------------------------------------------------- /website/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /website/polls/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amerkurev/django-docker-template/e20efc254dc5992c352e9d4c4dfa07eaa2cc099b/website/polls/__init__.py -------------------------------------------------------------------------------- /website/polls/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Question, Choice 4 | 5 | 6 | class ChoiceInline(admin.TabularInline): 7 | model = Choice 8 | extra = 3 9 | 10 | 11 | class QuestionAdmin(admin.ModelAdmin): 12 | fieldsets = [ 13 | (None, {"fields": ["question_text"]}), 14 | ("Date information", {"fields": ["pub_date"], "classes": ["expand"]}), 15 | ("Files", {"fields": ["upload"], "classes": ["expand"]}), 16 | ] 17 | 18 | inlines = [ChoiceInline] 19 | 20 | list_display = ["question_text", "pub_date", "was_published_recently"] 21 | 22 | list_filter = ["pub_date"] 23 | 24 | search_fields = ["question_text"] 25 | 26 | 27 | admin.site.register(Question, QuestionAdmin) 28 | -------------------------------------------------------------------------------- /website/polls/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PollsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "polls" 7 | -------------------------------------------------------------------------------- /website/polls/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-13 10:03 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Question", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("question_text", models.CharField(max_length=200)), 27 | ("pub_date", models.DateTimeField(verbose_name="date published")), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name="Choice", 32 | fields=[ 33 | ( 34 | "id", 35 | models.BigAutoField( 36 | auto_created=True, 37 | primary_key=True, 38 | serialize=False, 39 | verbose_name="ID", 40 | ), 41 | ), 42 | ("choice_text", models.CharField(max_length=200)), 43 | ("votes", models.IntegerField(default=0)), 44 | ( 45 | "question", 46 | models.ForeignKey( 47 | on_delete=django.db.models.deletion.CASCADE, to="polls.question" 48 | ), 49 | ), 50 | ], 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /website/polls/migrations/0002_question_upload.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-20 09:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("polls", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="question", 15 | name="upload", 16 | field=models.FileField( 17 | blank=True, null=True, upload_to="uploads/%Y/%m/%d/" 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /website/polls/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amerkurev/django-docker-template/e20efc254dc5992c352e9d4c4dfa07eaa2cc099b/website/polls/migrations/__init__.py -------------------------------------------------------------------------------- /website/polls/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.db import models 4 | from django.utils import timezone 5 | from django.contrib import admin 6 | 7 | 8 | class Question(models.Model): 9 | question_text = models.CharField(max_length=200) 10 | pub_date = models.DateTimeField("date published") 11 | # file will be saved to MEDIA_ROOT/uploads/2023/04/20 12 | upload = models.FileField(upload_to="uploads/%Y/%m/%d/", null=True, blank=True) 13 | 14 | def __str__(self): 15 | return self.question_text 16 | 17 | @admin.display( 18 | boolean=True, 19 | ordering="pub_date", 20 | description="Published recently?", 21 | ) 22 | def was_published_recently(self): 23 | now = timezone.now() 24 | return now - datetime.timedelta(days=1) <= self.pub_date <= now 25 | 26 | 27 | class Choice(models.Model): 28 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 29 | choice_text = models.CharField(max_length=200) 30 | votes = models.IntegerField(default=0) 31 | 32 | def __str__(self): 33 | return self.choice_text 34 | -------------------------------------------------------------------------------- /website/polls/static/polls/style.css: -------------------------------------------------------------------------------- 1 | li a { 2 | color: green; 3 | } 4 | -------------------------------------------------------------------------------- /website/polls/templates/polls/detail.html: -------------------------------------------------------------------------------- 1 |
2 | {% csrf_token %} 3 |
4 |

{{ question.question_text }}

5 | {% if error_message %}

{{ error_message }}

{% endif %} 6 | {% for choice in question.choice_set.all %} 7 | 8 |
9 | {% endfor %} 10 |
11 | 12 |
13 | -------------------------------------------------------------------------------- /website/polls/templates/polls/index.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | {% if latest_question_list %} 6 | 11 | {% else %} 12 |

No polls are available.

13 | {% endif %} 14 | -------------------------------------------------------------------------------- /website/polls/templates/polls/results.html: -------------------------------------------------------------------------------- 1 |

{{ question.question_text }}

2 | 3 | 8 | 9 | Vote again? 10 | -------------------------------------------------------------------------------- /website/polls/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.test import TestCase 4 | from django.utils import timezone 5 | from django.urls import reverse 6 | 7 | from .models import Question 8 | 9 | 10 | class QuestionModelTests(TestCase): 11 | 12 | def test_was_published_recently_with_future_question(self): 13 | """ 14 | was_published_recently() returns False for questions whose pub_date 15 | is in the future. 16 | """ 17 | time = timezone.now() + datetime.timedelta(days=30) 18 | future_question = Question(pub_date=time) 19 | self.assertIs(future_question.was_published_recently(), False) 20 | 21 | def test_was_published_recently_with_old_question(self): 22 | """ 23 | was_published_recently() returns False for questions whose pub_date 24 | is older than 1 day. 25 | """ 26 | time = timezone.now() - datetime.timedelta(days=1, seconds=1) 27 | old_question = Question(pub_date=time) 28 | self.assertIs(old_question.was_published_recently(), False) 29 | 30 | def test_was_published_recently_with_recent_question(self): 31 | """ 32 | was_published_recently() returns True for questions whose pub_date 33 | is within the last day. 34 | """ 35 | time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59) 36 | recent_question = Question(pub_date=time) 37 | self.assertIs(recent_question.was_published_recently(), True) 38 | 39 | 40 | class QuestionIndexViewTests(TestCase): 41 | 42 | def test_no_questions(self): 43 | """ 44 | If no questions exist, an appropriate message is displayed. 45 | """ 46 | response = self.client.get(reverse("polls:index")) 47 | self.assertEqual(response.status_code, 200) 48 | self.assertContains(response, "No polls are available.") 49 | self.assertQuerySetEqual(response.context["latest_question_list"], []) 50 | 51 | def test_past_question(self): 52 | """ 53 | Questions with a pub_date in the past are displayed on the 54 | index page. 55 | """ 56 | question = create_question(question_text="Past question.", days=-30) 57 | response = self.client.get(reverse("polls:index")) 58 | self.assertQuerySetEqual( 59 | response.context["latest_question_list"], 60 | [question], 61 | ) 62 | 63 | def test_future_question(self): 64 | """ 65 | Questions with a pub_date in the future aren't displayed on 66 | the index page. 67 | """ 68 | create_question(question_text="Future question.", days=30) 69 | response = self.client.get(reverse("polls:index")) 70 | self.assertContains(response, "No polls are available.") 71 | self.assertQuerySetEqual(response.context["latest_question_list"], []) 72 | 73 | def test_future_question_and_past_question(self): 74 | """ 75 | Even if both past and future questions exist, only past questions 76 | are displayed. 77 | """ 78 | question = create_question(question_text="Past question.", days=-30) 79 | create_question(question_text="Future question.", days=30) 80 | response = self.client.get(reverse("polls:index")) 81 | self.assertQuerySetEqual( 82 | response.context["latest_question_list"], 83 | [question], 84 | ) 85 | 86 | def test_two_past_questions(self): 87 | """ 88 | The questions index page may display multiple questions. 89 | """ 90 | question1 = create_question(question_text="Past question 1.", days=-30) 91 | question2 = create_question(question_text="Past question 2.", days=-5) 92 | response = self.client.get(reverse("polls:index")) 93 | self.assertQuerySetEqual( 94 | response.context["latest_question_list"], 95 | [question2, question1], 96 | ) 97 | 98 | 99 | class QuestionDetailViewTests(TestCase): 100 | 101 | def test_future_question(self): 102 | """ 103 | The detail view of a question with a pub_date in the future 104 | returns a 404 not found. 105 | """ 106 | future_question = create_question(question_text="Future question.", days=5) 107 | url = reverse("polls:detail", args=(future_question.id,)) 108 | response = self.client.get(url) 109 | self.assertEqual(response.status_code, 404) 110 | 111 | def test_past_question(self): 112 | """ 113 | The detail view of a question with a pub_date in the past 114 | displays the question's text. 115 | """ 116 | past_question = create_question(question_text="Past Question.", days=-5) 117 | url = reverse("polls:detail", args=(past_question.id,)) 118 | response = self.client.get(url) 119 | self.assertContains(response, past_question.question_text) 120 | 121 | 122 | def create_question(question_text, days): 123 | """ 124 | Create a question with the given `question_text` and published the 125 | given number of `days` offset to now (negative for questions published 126 | in the past, positive for questions that have yet to be published). 127 | """ 128 | time = timezone.now() + datetime.timedelta(days=days) 129 | return Question.objects.create(question_text=question_text, pub_date=time) 130 | -------------------------------------------------------------------------------- /website/polls/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "polls" 6 | urlpatterns = [ 7 | path("", views.IndexView.as_view(), name="index"), 8 | path("/", views.DetailView.as_view(), name="detail"), 9 | path("/results/", views.ResultsView.as_view(), name="results"), 10 | path("/vote/", views.vote, name="vote"), 11 | ] 12 | -------------------------------------------------------------------------------- /website/polls/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponseRedirect 2 | from django.shortcuts import get_object_or_404, render 3 | from django.urls import reverse 4 | from django.views import generic 5 | from django.utils import timezone 6 | 7 | from .models import Question, Choice 8 | 9 | 10 | class IndexView(generic.ListView): 11 | template_name = "polls/index.html" 12 | context_object_name = "latest_question_list" 13 | 14 | def get_queryset(self): 15 | """ 16 | Return the last five published questions (not including those set to be 17 | published in the future). 18 | """ 19 | return Question.objects.filter(pub_date__lte=timezone.now()).order_by( 20 | "-pub_date" 21 | )[:5] 22 | 23 | 24 | class DetailView(generic.DetailView): 25 | model = Question 26 | template_name = "polls/detail.html" 27 | 28 | def get_queryset(self): 29 | """ 30 | Excludes any questions that aren't published yet. 31 | """ 32 | return Question.objects.filter(pub_date__lte=timezone.now()) 33 | 34 | 35 | class ResultsView(generic.DetailView): 36 | model = Question 37 | template_name = "polls/results.html" 38 | 39 | 40 | def vote(request, question_id): 41 | question = get_object_or_404(Question, pk=question_id) 42 | try: 43 | selected_choice = question.choice_set.get(pk=request.POST["choice"]) 44 | except (KeyError, Choice.DoesNotExist): 45 | # Redisplay the question voting form. 46 | return render( 47 | request, 48 | "polls/detail.html", 49 | { 50 | "question": question, 51 | "error_message": "You didn't select a choice.", 52 | }, 53 | ) 54 | else: 55 | selected_choice.votes += 1 56 | selected_choice.save() 57 | # Always return an HttpResponseRedirect after successfully dealing 58 | # with POST data. This prevents data from being posted twice if a 59 | # user hits the Back button. 60 | return HttpResponseRedirect(reverse("polls:results", args=(question.id,))) 61 | -------------------------------------------------------------------------------- /website/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = website.settings 3 | # -- recommended but optional: 4 | python_files = tests.py test_*.py *_tests.py 5 | -------------------------------------------------------------------------------- /website/pytest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # run pytest with coverage and check the exit code of pytest 4 | if ! coverage run -m pytest; 5 | then 6 | echo "Tests failed" 7 | exit 1 8 | fi 9 | 10 | coverage report -m 11 | -------------------------------------------------------------------------------- /website/static/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amerkurev/django-docker-template/e20efc254dc5992c352e9d4c4dfa07eaa2cc099b/website/static/empty -------------------------------------------------------------------------------- /website/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} 4 | 5 | {% block branding %} 6 |

{{ site_header|default:_('Polls administration') }}

7 | {% if user.is_anonymous %} 8 | {% include "admin/color_theme_toggle.html" %} 9 | {% endif %} 10 | {% endblock %} 11 | 12 | {% block nav-global %}{% endblock %} 13 | -------------------------------------------------------------------------------- /website/website/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | TRUE = ("1", "true", "True", "TRUE", "on", "yes") 4 | 5 | 6 | def is_true(val: Optional[str]) -> bool: 7 | return val in TRUE 8 | 9 | 10 | def split_with_comma(val: str) -> List[str]: 11 | return list(filter(None, map(str.strip, val.split(",")))) 12 | -------------------------------------------------------------------------------- /website/website/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for project. 3 | 4 | It exposes the ASGI 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/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /website/website/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | from pathlib import Path 16 | from website import is_true, split_with_comma 17 | 18 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 19 | BASE_DIR = Path(__file__).resolve().parent.parent 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | INSECURE_KEY = "django-insecure-0eikswwglid=ukts4l2_b=676m!-q_%154%2z@&l3)n6)cp3#c" 26 | SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", INSECURE_KEY) 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = is_true(os.getenv("DJANGO_DEBUG", "true")) 30 | 31 | ALLOWED_HOSTS = split_with_comma( 32 | os.getenv("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost") 33 | ) 34 | 35 | INTERNAL_IPS = ["127.0.0.1"] 36 | 37 | if DEBUG: 38 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#configure-internal-ips 39 | import socket 40 | 41 | hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) 42 | INTERNAL_IPS = [ip[:-1] + "1" for ip in ips] + ["127.0.0.1", "10.0.2.2"] 43 | 44 | 45 | # Application definition 46 | 47 | INSTALLED_APPS = [ 48 | "django.contrib.admin", 49 | "django.contrib.auth", 50 | "django.contrib.contenttypes", 51 | "django.contrib.sessions", 52 | "django.contrib.messages", 53 | "django.contrib.staticfiles", 54 | "polls.apps.PollsConfig", 55 | ] 56 | 57 | MIDDLEWARE = [ 58 | "django.middleware.security.SecurityMiddleware", 59 | "django.contrib.sessions.middleware.SessionMiddleware", 60 | "django.middleware.common.CommonMiddleware", 61 | "django.middleware.csrf.CsrfViewMiddleware", 62 | "django.contrib.auth.middleware.AuthenticationMiddleware", 63 | "django.contrib.messages.middleware.MessageMiddleware", 64 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 65 | ] 66 | 67 | ROOT_URLCONF = "website.urls" 68 | 69 | TEMPLATES = [ 70 | { 71 | "BACKEND": "django.template.backends.django.DjangoTemplates", 72 | "DIRS": [BASE_DIR / "templates"], 73 | "APP_DIRS": True, 74 | "OPTIONS": { 75 | "context_processors": [ 76 | "django.template.context_processors.debug", 77 | "django.template.context_processors.request", 78 | "django.contrib.auth.context_processors.auth", 79 | "django.contrib.messages.context_processors.messages", 80 | ], 81 | }, 82 | }, 83 | ] 84 | 85 | WSGI_APPLICATION = "website.wsgi.application" 86 | 87 | 88 | # Database 89 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 90 | DATABASES = { 91 | "default": { 92 | "ENGINE": "django.db.backends.sqlite3", 93 | "NAME": Path(os.getenv("DJANGO_SQLITE_DIR", ".")) / "db.sqlite3", 94 | } 95 | } 96 | # If POSTGRES_DB is truthy, use PostgreSQL. https://docs.python.org/3/library/stdtypes.html#truth-value-testing 97 | if bool(os.getenv("POSTGRES_DB")): 98 | DATABASES["default"] = { 99 | "ENGINE": "django.db.backends.postgresql", 100 | "NAME": os.environ["POSTGRES_DB"], 101 | "USER": os.environ["POSTGRES_USER"], 102 | "PASSWORD": os.environ["POSTGRES_PASSWORD"], 103 | "HOST": os.environ["POSTGRES_HOST"], 104 | "PORT": os.environ["POSTGRES_PORT"], 105 | } 106 | 107 | 108 | # Password validation 109 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 110 | 111 | AUTH_PASSWORD_VALIDATORS = [ 112 | { 113 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 114 | }, 115 | { 116 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 117 | }, 118 | { 119 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 120 | }, 121 | { 122 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 123 | }, 124 | ] 125 | 126 | 127 | # Internationalization 128 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 129 | 130 | LANGUAGE_CODE = os.getenv("DJANGO_LANGUAGE_CODE", "en-us") 131 | 132 | TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "UTC") 133 | 134 | USE_I18N = True 135 | 136 | # The USE_L10N setting is deprecated. Starting with Django 5.0, localized formatting of data will always be enabled. 137 | # For example Django will display numbers and dates using the format of the current locale. 138 | # USE_L10N = True 139 | 140 | USE_TZ = True 141 | 142 | 143 | # Absolute filesystem path to the directory that will hold user-uploaded files. 144 | # Example: "/var/www/example.com/media/" 145 | MEDIA_ROOT = os.getenv("DJANGO_MEDIA_ROOT", "") 146 | 147 | # URL that handles the media served from MEDIA_ROOT. 148 | # Examples: "http://example.com/media/", "http://media.example.com/" 149 | MEDIA_URL = os.getenv("DJANGO_MEDIA_URL", "media/") 150 | 151 | 152 | # Static files (CSS, JavaScript, Images) 153 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 154 | 155 | STATIC_URL = "static/" 156 | 157 | STATIC_ROOT = os.getenv("DJANGO_STATIC_ROOT") 158 | 159 | STATICFILES_DIRS = [BASE_DIR / "static"] 160 | 161 | 162 | # Default primary key field type 163 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 164 | 165 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 166 | 167 | 168 | # Sessions 169 | SESSION_COOKIE_SECURE = is_true(os.getenv("DJANGO_SESSION_COOKIE_SECURE")) 170 | 171 | # Settings for CSRF cookie. 172 | CSRF_COOKIE_SECURE = is_true(os.getenv("DJANGO_CSRF_COOKIE_SECURE")) 173 | CSRF_TRUSTED_ORIGINS = split_with_comma(os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "")) 174 | 175 | # Security Middleware (manage.py check --deploy) 176 | SECURE_HSTS_INCLUDE_SUBDOMAINS = True 177 | SECURE_HSTS_PRELOAD = True 178 | SECURE_HSTS_SECONDS = 60 * 60 * 24 * 7 * 2 # 2 weeks, default - 0 179 | SECURE_SSL_REDIRECT = is_true(os.getenv("DJANGO_SECURE_SSL_REDIRECT")) 180 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 181 | 182 | 183 | # Email settings 184 | EMAIL_HOST = os.getenv("DJANGO_EMAIL_HOST", "localhost") 185 | EMAIL_PORT = int(os.getenv("DJANGO_EMAIL_PORT", 25)) 186 | EMAIL_HOST_USER = os.getenv("DJANGO_EMAIL_HOST_USER", "") 187 | EMAIL_HOST_PASSWORD = os.getenv("DJANGO_EMAIL_HOST_PASSWORD", "") 188 | EMAIL_USE_TLS = is_true(os.getenv("DJANGO_EMAIL_USE_TLS")) 189 | 190 | # Email address that error messages come from. 191 | SERVER_EMAIL = os.getenv("DJANGO_SERVER_EMAIL", "root@localhost") 192 | 193 | # Default email address to use for various automated correspondence from the site managers. 194 | DEFAULT_FROM_EMAIL = os.getenv("DJANGO_DEFAULT_FROM_EMAIL", "webmaster@localhost") 195 | 196 | # People who get code error notifications. In the format 197 | # [('Full Name', 'email@example.com'), ('Full Name', 'anotheremail@example.com')] 198 | ADMIN_NAME = os.getenv("DJANGO_ADMIN_NAME", "") 199 | ADMIN_EMAIL = os.getenv("DJANGO_ADMIN_EMAIL") 200 | if ADMIN_EMAIL: 201 | ADMINS = [(ADMIN_NAME, ADMIN_EMAIL)] 202 | 203 | 204 | # Log settings 205 | LOGGING = { 206 | "version": 1, 207 | "disable_existing_loggers": False, 208 | "formatters": { 209 | "verbose": { 210 | # https://docs.python.org/3/library/logging.html#logrecord-attributes 211 | "format": "{levelname} [{asctime}] -- {message}", 212 | "style": "{", 213 | } 214 | }, 215 | "handlers": { 216 | "console": { 217 | "level": "INFO", 218 | "class": "logging.StreamHandler", 219 | }, 220 | "mail_admins": { 221 | "level": "ERROR", 222 | "class": "django.utils.log.AdminEmailHandler", 223 | }, 224 | }, 225 | "loggers": { 226 | "django": { 227 | "handlers": ["console"], 228 | "level": "INFO", 229 | "propagate": True, 230 | }, 231 | "django.request": { 232 | "handlers": ["mail_admins", "console"] if not DEBUG else ["console"], 233 | "level": "ERROR", 234 | "propagate": False, 235 | }, 236 | }, 237 | } 238 | -------------------------------------------------------------------------------- /website/website/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import include, path 20 | from django.conf.urls.static import static 21 | from django.conf import settings 22 | 23 | 24 | urlpatterns = [ 25 | path("admin/", admin.site.urls), 26 | path("polls/", include("polls.urls")), 27 | ] 28 | 29 | # Serve media files from MEDIA_ROOT. It will only work when DEBUG=True is set. 30 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 31 | -------------------------------------------------------------------------------- /website/website/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for 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", "website.settings") 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------