├── .coveragerc ├── .dockerignore ├── .drone.yml ├── .env.dist ├── .github └── workflows │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── Dockerfile ├── Dockerfile-static ├── README.md ├── deploy.sh ├── docker-compose-swarm.yml ├── docker-compose.yml ├── docker ├── entrypoint-beat.sh ├── entrypoint-manage.sh ├── entrypoint-queue.sh └── entrypoint-web.sh ├── manage.py ├── media └── .gitkeep ├── nginx.conf ├── pre-commit.example ├── pyproject.toml ├── requirements ├── dev.txt └── prod.txt └── src ├── __init__.py ├── common ├── __init__.py ├── apps.py ├── constants.py ├── helpers.py ├── serializers.py ├── signals.py ├── social_pipeline │ └── user.py └── tasks.py ├── config ├── __init__.py ├── celery.py ├── common.py ├── local.py ├── production.py └── stage.py ├── files ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests.py ├── urls.py ├── validators.py └── views.py ├── notifications ├── __init__.py ├── admin.py ├── apps.py ├── channels │ └── email.py ├── migrations │ └── __init__.py ├── models.py ├── services.py ├── test │ └── test_channels_email.py ├── tests.py └── views.py ├── social ├── __init__.py ├── apps.py ├── serializers.py └── views.py ├── urls.py ├── users ├── __init__.py ├── admin.py ├── apps.py ├── backends.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20171227_2246.py │ ├── 0003_user_profile_picture.py │ ├── 0004_auto_20210317_0720.py │ └── __init__.py ├── models.py ├── permissions.py ├── serializers.py ├── templates │ └── emails │ │ └── user_reset_password.html ├── test │ ├── __init__.py │ ├── factories.py │ ├── test_serializers.py │ └── test_views.py ├── urls.py └── views.py └── wsgi.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = *migrations*, 4 | *urls*, 5 | *test*, 6 | *admin*, 7 | ./manage.py, 8 | ./src/config/*, 9 | ./src/wsgi.py, 10 | *__init__* 11 | 12 | [report] 13 | exclude_lines = 14 | # Have to re-enable the standard pragma 15 | pragma: no cover 16 | 17 | # Don't complain about missing debug-only code: 18 | def __repr__ 19 | if self\.debug 20 | 21 | # Don't complain if tests don't hit defensive assertion code: 22 | raise AssertionError 23 | raise NotImplementedError 24 | 25 | # Don't complain if non-runnable code isn't run: 26 | if 0: 27 | if __name__ == .__main__.: 28 | omit = *migrations*, 29 | *urls*, 30 | *test*, 31 | *admin*, 32 | ./manage.py, 33 | ./src/config/*, 34 | ./src/wsgi.py, 35 | *__init__* 36 | show_missing = True 37 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.coveragerc 3 | !.env 4 | 5 | .git 6 | .gitignore 7 | 8 | deploy.sh 9 | docker-compose.yml 10 | docker-compose-swarm.yml 11 | Dockerfile 12 | .dockerignore 13 | .drone.yml 14 | 15 | README.md 16 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: 3 | 4 | trigger: 5 | branch: 6 | - stage 7 | event: 8 | - push 9 | 10 | volumes: 11 | - name: docker_daemon 12 | host: 13 | path: /var/run/docker.sock 14 | - name: docker_cache 15 | host: 16 | path: /mnt/drone-docker 17 | 18 | steps: 19 | - name: build web 20 | image: docker:dind 21 | environment: 22 | REGISTRY_USER: 23 | from_secret: docker_username 24 | REGISTRY_PASS: 25 | from_secret: docker_password 26 | volumes: 27 | - name: docker_cache 28 | path: /var/lib/docker 29 | - name: docker_daemon 30 | path: /var/run/docker.sock 31 | commands: 32 | - docker login -u $REGISTRY_USER -p $REGISTRY_PASS registry.vivifyideas.com 33 | - docker build -f Dockerfile -t registry.vivifyideas.com//web:${DRONE_BRANCH} --build-arg REQUIREMENTS_FILE=prod.txt --pull=true . 34 | - docker push registry.vivifyideas.com//web:${DRONE_BRANCH} 35 | - docker build -f Dockerfile-static -t registry.vivifyideas.com//static:${DRONE_BRANCH} --pull=true . 36 | - docker push registry.vivifyideas.com//static:${DRONE_BRANCH} 37 | - docker image prune -f 38 | 39 | - name: deploy 40 | image: alpine 41 | environment: 42 | BRANCH: ${DRONE_BRANCH} 43 | commands: 44 | - apk add --no-cache curl 45 | - sh deploy.sh 46 | 47 | - name: slack 48 | image: plugins/slack 49 | when: 50 | status: [ success, failure ] 51 | settings: 52 | webhook: 53 | from_secret: slack_webhook 54 | channel: 55 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | # TEMPORARY SECRET - DO NOT USE THIS 2 | DJANGO_SETTINGS_MODULE=src.config.local 3 | DJANGO_SECRET_KEY='#p7&kxb7y^yq8ahfw5%$xh=f8=&1y*5+a5($8w_f7kw!-qig(j' 4 | DJANGO_DEBUG=True 5 | 6 | DB_NAME=database 7 | DB_USER=user 8 | DB_PASSWORD=password 9 | DB_HOST=db 10 | DB_PORT=5432 11 | 12 | BROKER_URL=redis://redis:6379 13 | CELERY_RESULT_BACKEND=redis://redis:6379 14 | 15 | EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend 16 | EMAIL_HOST=mailhog 17 | EMAIL_PORT=1025 18 | EMAIL_FROM=noreply@somehost.local 19 | 20 | FACEBOOK_KEY= 21 | FACEBOOK_SECRET= 22 | 23 | SENTRY_DSN= 24 | 25 | SITE_URL=http://localhost:8001 26 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Black Linter 2 | 3 | on: [push] 4 | 5 | jobs: 6 | black: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Setup Python 3.8 10 | uses: actions/setup-python@v1 11 | with: 12 | python-version: 3.8 13 | - name: Checkout 14 | uses: actions/checkout@master 15 | - name: Lint 16 | uses: lgeiger/black-action@master 17 | with: 18 | args: "src --check" 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | services: 10 | db: 11 | image: postgres:13.3 12 | env: 13 | POSTGRES_DB: db 14 | POSTGRES_USER: admin 15 | POSTGRES_PASSWORD: admin 16 | ports: 17 | - 5432:5432 18 | options: --health-cmd="pg_isready -U postgres" --health-interval=10s --health-timeout=5s --health-retries=3 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 3.8 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: 3.8 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements/dev.txt 30 | - name: Run Tests 31 | run: python manage.py test 32 | env: 33 | DB_NAME: db 34 | DB_USER: admin 35 | DB_PASSWORD: admin 36 | DB_HOST: 127.0.0.1 37 | DB_PORT: 5432 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 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 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | .static_storage/ 56 | .media/ 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | celerybeat.pid 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # django staticfiles 105 | /static 106 | 107 | # mypy 108 | .mypy_cache/ 109 | 110 | # media files 111 | media/* 112 | !media/.gitkeep 113 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 21.9b0 10 | hooks: 11 | - id: black 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "env/bin/python3", 3 | "python.linting.pylintEnabled": false, 4 | "python.linting.enabled": true, 5 | "python.linting.cwd": "src", 6 | "python.formatting.provider": "black", 7 | "files.exclude": { 8 | "**/.git": true, 9 | "**/.svn": true, 10 | "**/.hg": true, 11 | "**/CVS": true, 12 | "**/.DS_Store": true, 13 | "**/*.pyc": true 14 | }, 15 | "editor.rulers": [130], 16 | "prettier.printWidth": 130 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | ARG REQUIREMENTS_FILE 4 | 5 | WORKDIR /app 6 | EXPOSE 80 7 | ENV PYTHONUNBUFFERED 1 8 | 9 | RUN set -x && \ 10 | apt-get update && \ 11 | apt -f install && \ 12 | apt-get -qy install netcat && \ 13 | rm -rf /var/lib/apt/lists/* && \ 14 | wget -O /wait-for https://raw.githubusercontent.com/eficode/wait-for/master/wait-for && \ 15 | chmod +x /wait-for 16 | 17 | CMD ["sh", "/entrypoint-web.sh"] 18 | COPY ./docker/ / 19 | 20 | COPY ./requirements/ ./requirements 21 | RUN pip install -r ./requirements/${REQUIREMENTS_FILE} 22 | 23 | COPY . ./ 24 | -------------------------------------------------------------------------------- /Dockerfile-static: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | 3 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Rest Framework boilerplate 2 | 3 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 4 | 5 | This is boilerplate for starting fresh new DRF projects. It's built using [cookiecutter-django-rest](https://github.com/agconti/cookiecutter-django-rest). 6 | 7 | ## Highlights 8 | 9 | - Modern Python development with Python 3.8+ 10 | - Bleeding edge Django 3.1+ 11 | - Fully dockerized, local development via docker-compose. 12 | - PostgreSQL 13 | - Full test coverage, continuous integration, and continuous deployment. 14 | - Celery tasks 15 | 16 | ### Features built-in 17 | 18 | - JSON Web Token authentication using [Simple JWT](https://django-rest-framework-simplejwt.readthedocs.io/en/latest/) 19 | - Social (FB + G+) signup/sigin 20 | - API Throttling enabled 21 | - Password reset endpoints 22 | - User model with profile picture field using Easy Thumbnails 23 | - Files management (thumbnails generated automatically for images) 24 | - Sentry setup 25 | - Swagger API docs out-of-the-box 26 | - Code formatter [black](https://black.readthedocs.io/en/stable/) 27 | - Tests (with mocking and factories) with code-coverage support 28 | 29 | ## API Docs 30 | 31 | API documentation is automatically generated using Swagger. You can view documention by visiting this [link](http://localhost:8000/swagger). 32 | 33 | ## Prerequisites 34 | 35 | If you are familiar with Docker, then you just need [Docker](https://docs.docker.com/docker-for-mac/install/). If you don't want to use Docker, then you just need Python3 and Postgres installed. 36 | 37 | ## Local Development with Docker 38 | 39 | Start the dev server for local development: 40 | 41 | ```bash 42 | cp .env.dist .env 43 | docker-compose up 44 | ``` 45 | 46 | Run a command inside the docker container: 47 | 48 | ```bash 49 | docker-compose run --rm web [command] 50 | ``` 51 | 52 | ## Local Development without Docker 53 | 54 | ### Install 55 | 56 | ```bash 57 | python3 -m venv env && source env/bin/activate # activate venv 58 | cp .env.dist .env # create .env file and fill-in DB info 59 | pip install -r requirements.txt # install py requirements 60 | ./manage.py migrate # run migrations 61 | ./manage.py collectstatic --noinput # collect static files 62 | redis-server # run redis locally for celery 63 | celery -A src.config worker --beat --loglevel=debug 64 | --pidfile="./celerybeat.pid" 65 | --scheduler django_celery_beat.schedulers:DatabaseScheduler # run celery beat and worker 66 | ``` 67 | 68 | ### Run dev server 69 | 70 | This will run server on [http://localhost:8000](http://localhost:8000) 71 | 72 | ```bash 73 | ./manage.py runserver 74 | ``` 75 | 76 | ### Create superuser 77 | 78 | If you want, you can create initial super-user with next commad: 79 | 80 | ```bash 81 | ./manage.py createsuperuser 82 | ``` 83 | 84 | ### Running Tests 85 | 86 | To run all tests with code-coverate report, simple run: 87 | 88 | ```bash 89 | ./manage.py test 90 | ``` 91 | 92 | 93 | You're now ready to ROCK! ✨ 💅 🛳 94 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | function deploy() { 6 | echo "Starting deploy" 7 | url=$1 8 | services=$2 9 | output=$(curl -sSL -X POST "${url}/api/deploy" -H "Content-Type: application/json" -d ${services}) 10 | task_id=$(echo $output | grep "task_id" | cut -d '"' -f 8) 11 | 12 | while true; do 13 | output=$(curl -sSL -X GET "${url}/api/tasks/${task_id}" -H "Accept: application/json") 14 | if ! echo $output | grep "Task is still running" >/dev/null; then 15 | break 16 | fi 17 | echo "Waiting for the deploy to finish" 18 | sleep 5 19 | done 20 | 21 | if ! echo "$output" | grep "Successfully updated all services" >/dev/null; then 22 | echo $output 23 | echo "Deploy failed" 24 | exit 1 25 | fi 26 | 27 | echo "Deploy finished" 28 | } 29 | 30 | if [ ${BRANCH} = "stage" ]; then 31 | deploy 'https://' '[]' 32 | else 33 | echo "Branch ${BRANCH} is not deployable" 34 | exit 1 35 | fi 36 | -------------------------------------------------------------------------------- /docker-compose-swarm.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | db: 4 | image: postgres:13.3 5 | deploy: 6 | restart_policy: 7 | condition: any 8 | environment: 9 | POSTGRES_DB: ${DB_NAME} 10 | POSTGRES_USER: ${DB_USER} 11 | POSTGRES_PASSWORD: ${DB_PASSWORD} 12 | PGDATA: /data/postgres 13 | volumes: 14 | - db-data:/data/postgres 15 | 16 | static: 17 | image: static-image 18 | deploy: 19 | restart_policy: 20 | condition: any 21 | ports: 22 | - 8000:80 23 | volumes: 24 | - static-files:/app/static 25 | 26 | manage: 27 | image: test 28 | deploy: 29 | restart_policy: 30 | condition: none 31 | env_file: .env 32 | entrypoint: /entrypoint-manage.sh 33 | volumes: 34 | - ./.env:/app/.env 35 | - static-files:/app/static 36 | - media-files:/app/media 37 | 38 | web: 39 | image: test 40 | deploy: 41 | mode: replicated 42 | replicas: 2 43 | restart_policy: 44 | condition: any 45 | env_file: .env 46 | command: sh /entrypoint-web.sh 47 | ports: 48 | - 8001:8000 49 | volumes: 50 | - ./.env:/app/src/.env 51 | - static-files:/app/static 52 | 53 | queue: 54 | image: test 55 | deploy: 56 | restart_policy: 57 | condition: any 58 | env_file: .env 59 | command: sh /entrypoint-queue.sh 60 | volumes: 61 | - ./.env:/app/.env 62 | - static-files:/app/static 63 | 64 | beat: 65 | image: test 66 | deploy: 67 | restart_policy: 68 | condition: any 69 | env_file: .env 70 | command: sh /entrypoint-beat.sh 71 | volumes: 72 | - ./.env:/app/.env 73 | - static-files:/app/static 74 | 75 | volumes: 76 | db-data: 77 | static-files: 78 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | db: 4 | image: postgres:14.2 5 | ports: 6 | - 5432:5432 7 | environment: 8 | POSTGRES_DB: ${DB_NAME} 9 | POSTGRES_USER: ${DB_USER} 10 | POSTGRES_PASSWORD: ${DB_PASSWORD} 11 | volumes: 12 | - db-data:/data/postgres 13 | 14 | web: 15 | build: 16 | context: . 17 | args: 18 | REQUIREMENTS_FILE: dev.txt 19 | restart: always 20 | ports: 21 | - 8001:8000 22 | env_file: .env 23 | command: 'sh -c "cp pre-commit.example .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit && ./manage.py migrate && ./manage.py runserver 0.0.0.0:8000"' 24 | volumes: 25 | - ./:/app 26 | depends_on: 27 | - db 28 | 29 | queue: 30 | build: 31 | context: . 32 | args: 33 | REQUIREMENTS_FILE: dev.txt 34 | restart: unless-stopped 35 | env_file: .env 36 | command: sh /entrypoint-queue.sh 37 | volumes: 38 | - ./:/app 39 | 40 | beat: 41 | build: 42 | context: . 43 | args: 44 | REQUIREMENTS_FILE: dev.txt 45 | restart: unless-stopped 46 | env_file: .env 47 | command: sh /entrypoint-beat.sh 48 | volumes: 49 | - ./:/app 50 | 51 | redis: 52 | image: redis:alpine 53 | restart: unless-stopped 54 | ports: 55 | - 6379:6379 56 | 57 | mailhog: 58 | image: mailhog/mailhog:latest 59 | restart: always 60 | ports: 61 | - 1025:1025 62 | - 8025:8025 63 | 64 | volumes: 65 | db-data: 66 | -------------------------------------------------------------------------------- /docker/entrypoint-beat.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | celery -A src.config beat --loglevel=debug --scheduler django_celery_beat.schedulers:DatabaseScheduler 6 | -------------------------------------------------------------------------------- /docker/entrypoint-manage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ./manage.py migrate 6 | ./manage.py collectstatic --noinput 7 | 8 | exec tail -f /dev/null 9 | -------------------------------------------------------------------------------- /docker/entrypoint-queue.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | celery -A src.config worker --loglevel=debug --concurrency=4 6 | -------------------------------------------------------------------------------- /docker/entrypoint-web.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | gunicorn --bind 0.0.0.0:8000 -w 4 --limit-request-line 6094 --access-logfile - src.wsgi:application 6 | # newrelic-admin run-program gunicorn --bind 0.0.0.0:8000 --access-logfile - src.wsgi:application 7 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.config.local") 7 | 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as exc: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | "available on your PYTHONPATH environment variable? Did you " 14 | "forget to activate a virtual environment?" 15 | ) from exc 16 | raise 17 | execute_from_command_line(sys.argv) 18 | -------------------------------------------------------------------------------- /media/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/media/.gitkeep -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name _; 4 | 5 | location /static { 6 | root /app; 7 | proxy_set_header X-Forwarded-Proto https; 8 | } 9 | 10 | location / { 11 | proxy_set_header X-Forwarded-Proto https; 12 | return 404; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pre-commit.example: -------------------------------------------------------------------------------- 1 | cd $(git rev-parse --show-toplevel) 2 | 3 | NAME=$(basename `git rev-parse --show-toplevel`)_web_1 4 | docker ps | grep $NAME &> /dev/null 5 | CONTAINER_EXISTS=$? 6 | 7 | if [[ CONTAINER_EXISTS -eq 0 ]]; then 8 | docker exec $NAME pre-commit run --all-files 9 | else 10 | echo "Please run first docker-compose up before you try to commit." 11 | exit 1 12 | fi 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 130 3 | target-version = ['py38'] 4 | skip-string-normalization = true 5 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r prod.txt 2 | 3 | # Developer Tools 4 | ipdb==0.13.8 5 | black==21.9b0 6 | django-extensions==3.1.3 7 | pre-commit==2.15.0 8 | 9 | # Testing 10 | factory-boy==3.2.0 11 | django-nose==1.4.7 12 | nose-progressive==1.5.2 13 | coverage==5.5 14 | -------------------------------------------------------------------------------- /requirements/prod.txt: -------------------------------------------------------------------------------- 1 | # Core 2 | pytz==2021.1 3 | Django==3.2.12 4 | gunicorn==20.1.0 5 | newrelic==6.4.0.157 6 | django-dotenv==1.4.2 7 | django-3-jet==1.0.8 8 | celery==4.4.7 9 | 10 | # For the persistence stores 11 | psycopg2==2.8.6 12 | redis==3.5.3 13 | 14 | # Model Tools 15 | django-model-utils==4.1.1 16 | django_unique_upload==0.2.1 17 | django-summernote==0.8.11.6 18 | django-celery-beat==2.0.0 19 | django-activity-stream==0.10.0 20 | django-money==2.0 21 | 22 | # Rest apis 23 | djangorestframework==3.12.4 24 | djangorestframework-simplejwt==4.7.1 25 | Markdown==3.3.4 26 | drf-yasg==1.20.0 27 | django-filter==2.4.0 28 | django-cors-headers==3.7.0 29 | django-rest-passwordreset==1.1.0 30 | django-rest-swagger==2.2.0 31 | easy-thumbnails==2.7.1 32 | django-auto-prefetching==0.1.10 33 | 34 | # Social login 35 | social-auth-core==4.1.0 36 | social-auth-app-django==4.0.0 37 | 38 | # Developer Tools 39 | ipython==7.24.0 40 | sentry-sdk==1.1.0 41 | django-inlinecss==0.3.0 42 | 43 | # Static and Media Storage 44 | django-storages==1.11.1 45 | boto3==1.17.84 46 | 47 | django-health-check==3.16.4 48 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/src/__init__.py -------------------------------------------------------------------------------- /src/common/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'src.common.apps.CommonConfig' 2 | -------------------------------------------------------------------------------- /src/common/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommonConfig(AppConfig): 5 | name = 'src.common' 6 | -------------------------------------------------------------------------------- /src/common/constants.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/src/common/constants.py -------------------------------------------------------------------------------- /src/common/helpers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def build_absolute_uri(path): 5 | return f'{settings.SITE_URL}{path}' 6 | -------------------------------------------------------------------------------- /src/common/serializers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.serializers import ImageField as ApiImageField 3 | from easy_thumbnails.files import get_thumbnailer 4 | 5 | THUMBNAIL_ALIASES = getattr(settings, 'THUMBNAIL_ALIASES', {}) 6 | 7 | 8 | def get_url(request, instance, alias_obj, alias=None): 9 | if alias is not None: 10 | return request.build_absolute_uri(get_thumbnailer(instance).get_thumbnail(alias_obj[alias]).url) 11 | elif alias is None: 12 | return request.build_absolute_uri(instance.url) 13 | else: 14 | raise TypeError('Unsupported field type') 15 | 16 | 17 | def image_sizes(request, instance, alias_obj): 18 | i_sizes = list(alias_obj.keys()) 19 | return {'original': get_url(request, instance, alias_obj), **{k: get_url(request, instance, alias_obj, k) for k in i_sizes}} 20 | 21 | 22 | class ThumbnailerJSONSerializer(ApiImageField): 23 | def __init__(self, alias_target, **kwargs): 24 | self.alias_target = THUMBNAIL_ALIASES.get(alias_target) 25 | super(ThumbnailerJSONSerializer, self).__init__(**kwargs) 26 | 27 | def to_representation(self, instance): 28 | if instance: 29 | return image_sizes(self.context['request'], instance, self.alias_target) 30 | return None 31 | -------------------------------------------------------------------------------- /src/common/signals.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from django.db.models import signals 3 | 4 | 5 | class DisableSignals(object): 6 | """ 7 | A class used to disable signals on the enclosed block of code. 8 | Usage: 9 | with DisableSignals(): 10 | ... 11 | """ 12 | 13 | def __init__(self, disabled_signals=None): 14 | self.stashed_signals = defaultdict(list) 15 | self.disabled_signals = disabled_signals or [ 16 | signals.pre_init, 17 | signals.post_init, 18 | signals.pre_save, 19 | signals.post_save, 20 | signals.pre_delete, 21 | signals.post_delete, 22 | signals.pre_migrate, 23 | signals.post_migrate, 24 | signals.m2m_changed, 25 | ] 26 | 27 | def __enter__(self): 28 | for signal in self.disabled_signals: 29 | self.disconnect(signal) 30 | 31 | def __exit__(self, exc_type, exc_val, exc_tb): 32 | for signal in list(self.stashed_signals): 33 | self.reconnect(signal) 34 | 35 | def disconnect(self, signal): 36 | self.stashed_signals[signal] = signal.receivers 37 | signal.receivers = [] 38 | 39 | def reconnect(self, signal): 40 | signal.receivers = self.stashed_signals.get(signal, []) 41 | del self.stashed_signals[signal] 42 | -------------------------------------------------------------------------------- /src/common/social_pipeline/user.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import login 2 | 3 | 4 | def social_user(backend, uid, user=None, *args, **kwargs): 5 | provider = backend.name 6 | social = backend.strategy.storage.user.get_social_auth(provider, uid) 7 | 8 | user = social.user if social else None 9 | 10 | return {'social': social, 'user': user, 'is_new': user is None, 'new_association': social is None} 11 | 12 | 13 | def login_user(strategy, backend, user=None, *args, **kwargs): 14 | login(backend.strategy.request, user, backend='src.users.backends.EmailOrUsernameModelBackend') 15 | -------------------------------------------------------------------------------- /src/common/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import task 2 | from django.core.mail import EmailMultiAlternatives 3 | 4 | 5 | @task(name='SendEmailTask') 6 | def send_email_task(subject, to, default_from, email_html_message): 7 | msg = EmailMultiAlternatives( 8 | subject, 9 | email_html_message, 10 | default_from, 11 | to, 12 | alternatives=((email_html_message, 'text/html'),), 13 | ) 14 | msg.send() 15 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from .celery import app as celery_app 4 | 5 | __all__ = ('celery_app',) 6 | -------------------------------------------------------------------------------- /src/config/celery.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | 5 | from celery import Celery 6 | from django.conf import settings 7 | 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.config.local") 9 | 10 | app = Celery('src.config') 11 | 12 | app.config_from_object('django.conf:settings') 13 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 14 | 15 | # tasks can be added below 16 | -------------------------------------------------------------------------------- /src/config/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sentry_sdk 3 | import sys 4 | import dotenv 5 | 6 | from datetime import timedelta 7 | from sentry_sdk.integrations.django import DjangoIntegration 8 | from os.path import join 9 | 10 | TESTING = sys.argv[1:2] == ['test'] 11 | 12 | ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 13 | 14 | if not TESTING: 15 | dotenv.read_dotenv(ROOT_DIR) 16 | 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | SITE_URL = os.getenv('SITE_URL', 'http://localhost:8000') 20 | 21 | 22 | INSTALLED_APPS = ( 23 | 'django.contrib.auth', 24 | 'django.contrib.contenttypes', 25 | 'django.contrib.sessions', 26 | 'django.contrib.messages', 27 | 'django.contrib.staticfiles', 28 | 'jet', 29 | 'django.contrib.admin', 30 | # Third party apps 31 | 'rest_framework', # utilities for rest apis 32 | 'rest_framework.authtoken', # token authentication 33 | 'django_filters', # for filtering rest endpoints 34 | 'django_rest_passwordreset', # for reset password endpoints 35 | 'drf_yasg', # swagger api 36 | 'easy_thumbnails', # image lib 37 | 'social_django', # social login 38 | 'corsheaders', # cors handling 39 | 'django_inlinecss', # inline css in templates 40 | 'django_summernote', # text editor 41 | 'django_celery_beat', # task scheduler 42 | 'djmoney', # money object 43 | 'health_check', 44 | 'health_check.db', # stock Django health checkers 45 | 'health_check.cache', 46 | 'health_check.storage', 47 | 'health_check.contrib.migrations', 48 | 'health_check.contrib.celery_ping', # requires celery 49 | # Your apps 50 | 'src.notifications', 51 | 'src.users', 52 | 'src.social', 53 | 'src.files', 54 | 'src.common', 55 | # Third party optional apps 56 | # app must be placed somewhere after all the apps that are going to be generating activities 57 | # 'actstream', # activity stream 58 | ) 59 | 60 | # https://docs.djangoproject.com/en/2.0/topics/http/middleware/ 61 | MIDDLEWARE = ( 62 | 'django.middleware.security.SecurityMiddleware', 63 | 'django.contrib.sessions.middleware.SessionMiddleware', 64 | 'corsheaders.middleware.CorsMiddleware', 65 | 'django.middleware.common.CommonMiddleware', 66 | 'django.middleware.csrf.CsrfViewMiddleware', 67 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 68 | 'django.contrib.messages.middleware.MessageMiddleware', 69 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 70 | 'social_django.middleware.SocialAuthExceptionMiddleware', 71 | ) 72 | 73 | SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', '#p7&kxb7y^yq8ahfw5%$xh=f8=&1y*5+a5($8w_f7kw!-qig(j') 74 | ALLOWED_HOSTS = ["*"] 75 | ROOT_URLCONF = 'src.urls' 76 | WSGI_APPLICATION = 'src.wsgi.application' 77 | 78 | # Email 79 | EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend') 80 | EMAIL_HOST = os.getenv('EMAIL_HOST', 'localhost') 81 | EMAIL_PORT = os.getenv('EMAIL_PORT', 1025) 82 | EMAIL_FROM = os.getenv('EMAIL_FROM', 'noreply@somehost.local') 83 | 84 | # Celery 85 | BROKER_URL = os.getenv('BROKER_URL', 'redis://redis:6379') 86 | CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://redis:6379') 87 | 88 | ADMINS = () 89 | 90 | # Sentry 91 | sentry_sdk.init(dsn=os.getenv('SENTRY_DSN', ''), integrations=[DjangoIntegration()]) 92 | 93 | # CORS 94 | CORS_ORIGIN_ALLOW_ALL = True 95 | 96 | # CELERY 97 | CELERY_ACCEPT_CONTENT = ['application/json'] 98 | CELERY_TASK_SERIALIZER = 'json' 99 | CELERY_RESULT_SERIALIZER = 'json' 100 | CELERY_TIMEZONE = 'UTC' 101 | 102 | # Postgres 103 | DATABASES = { 104 | 'default': { 105 | 'ENGINE': 'django.db.backends.postgresql', 106 | 'NAME': os.getenv('DB_NAME'), 107 | 'USER': os.getenv('DB_USER'), 108 | 'PASSWORD': os.getenv('DB_PASSWORD'), 109 | 'HOST': os.getenv('DB_HOST', 'db'), 110 | 'PORT': os.getenv('DB_PORT'), 111 | } 112 | } 113 | 114 | # General 115 | APPEND_SLASH = True 116 | TIME_ZONE = 'UTC' 117 | LANGUAGE_CODE = 'en-us' 118 | # If you set this to False, Django will make some optimizations so as not 119 | # to load the internationalization machinery. 120 | USE_I18N = False 121 | USE_L10N = True 122 | USE_TZ = True 123 | LOGIN_REDIRECT_URL = '/' 124 | 125 | # Static files (CSS, JavaScript, Images) 126 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 127 | STATIC_ROOT = os.path.normpath(join(os.path.dirname(BASE_DIR), 'static')) 128 | STATICFILES_DIRS = [] 129 | STATIC_URL = '/static/' 130 | STATICFILES_FINDERS = ( 131 | 'django.contrib.staticfiles.finders.FileSystemFinder', 132 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 133 | ) 134 | 135 | # Media files 136 | MEDIA_ROOT = join(os.path.dirname(BASE_DIR), 'media') 137 | MEDIA_URL = '/media/' 138 | 139 | # Headers 140 | USE_X_FORWARDED_HOST = True 141 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 142 | 143 | TEMPLATES = [ 144 | { 145 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 146 | 'DIRS': STATICFILES_DIRS, 147 | 'APP_DIRS': True, 148 | 'OPTIONS': { 149 | 'context_processors': [ 150 | 'django.template.context_processors.debug', 151 | 'django.template.context_processors.request', 152 | 'django.contrib.auth.context_processors.auth', 153 | 'django.contrib.messages.context_processors.messages', 154 | 'social_django.context_processors.backends', 155 | 'social_django.context_processors.login_redirect', 156 | ], 157 | }, 158 | }, 159 | ] 160 | 161 | # Set DEBUG to False as a default for safety 162 | # https://docs.djangoproject.com/en/dev/ref/settings/#debug 163 | DEBUG = os.getenv('DJANGO_DEBUG', False) == 'True' 164 | 165 | # Password Validation 166 | # https://docs.djangoproject.com/en/2.0/topics/auth/passwords/#module-django.contrib.auth.password_validation 167 | AUTH_PASSWORD_VALIDATORS = [ 168 | { 169 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 170 | }, 171 | { 172 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 173 | }, 174 | { 175 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 176 | }, 177 | { 178 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 179 | }, 180 | ] 181 | 182 | # Logging 183 | LOGGING = { 184 | 'version': 1, 185 | 'disable_existing_loggers': False, 186 | 'formatters': { 187 | 'django.server': { 188 | '()': 'django.utils.log.ServerFormatter', 189 | 'format': '[%(server_time)s] %(message)s', 190 | }, 191 | 'verbose': {'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'}, 192 | 'simple': {'format': '%(levelname)s %(message)s'}, 193 | }, 194 | 'filters': { 195 | 'require_debug_true': { 196 | '()': 'django.utils.log.RequireDebugTrue', 197 | }, 198 | }, 199 | 'handlers': { 200 | 'django.server': { 201 | 'level': 'INFO', 202 | 'class': 'logging.StreamHandler', 203 | 'formatter': 'django.server', 204 | }, 205 | 'console': {'level': 'DEBUG', 'class': 'logging.StreamHandler', 'formatter': 'simple'}, 206 | 'mail_admins': {'level': 'ERROR', 'class': 'django.utils.log.AdminEmailHandler'}, 207 | }, 208 | 'loggers': { 209 | 'django': { 210 | 'handlers': ['console'], 211 | 'propagate': True, 212 | }, 213 | 'django.server': { 214 | 'handlers': ['django.server'], 215 | 'level': 'INFO', 216 | 'propagate': False, 217 | }, 218 | 'django.request': { 219 | 'handlers': ['mail_admins', 'console'], 220 | 'level': 'ERROR', 221 | 'propagate': False, 222 | }, 223 | 'django.db.backends': {'handlers': ['console'], 'level': 'INFO'}, 224 | }, 225 | } 226 | 227 | # Custom user app 228 | AUTH_USER_MODEL = 'users.User' 229 | 230 | # Social login 231 | AUTHENTICATION_BACKENDS = ( 232 | 'social_core.backends.facebook.FacebookOAuth2', 233 | 'social_core.backends.twitter.TwitterOAuth', 234 | 'src.users.backends.EmailOrUsernameModelBackend', 235 | 'django.contrib.auth.backends.ModelBackend', 236 | ) 237 | for key in ['GOOGLE_OAUTH2_KEY', 'GOOGLE_OAUTH2_SECRET', 'FACEBOOK_KEY', 'FACEBOOK_SECRET', 'TWITTER_KEY', 'TWITTER_SECRET']: 238 | exec("SOCIAL_AUTH_{key} = os.environ.get('{key}', '')".format(key=key)) 239 | 240 | # FB 241 | SOCIAL_AUTH_FACEBOOK_SCOPE = ['email'] 242 | SOCIAL_AUTH_FACEBOOK_PROFILE_EXTRA_PARAMS = {'fields': 'id, name, email'} 243 | SOCIAL_AUTH_FACEBOOK_API_VERSION = '5.0' 244 | 245 | # Twitter 246 | SOCIAL_AUTH_TWITTER_SCOPE = ['email'] 247 | 248 | SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ['email', 'profile'] 249 | SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = ['username', 'first_name', 'email'] 250 | # If this is not set, PSA constructs a plausible username from the first portion of the 251 | # user email, plus some random disambiguation characters if necessary. 252 | SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL = True 253 | SOCIAL_AUTH_PIPELINE = ( 254 | 'social_core.pipeline.social_auth.social_details', 255 | 'social_core.pipeline.social_auth.social_uid', 256 | 'social_core.pipeline.social_auth.auth_allowed', 257 | 'social_core.pipeline.social_auth.social_user', 258 | 'social_core.pipeline.user.get_username', 259 | 'social_core.pipeline.social_auth.associate_by_email', 260 | 'social_core.pipeline.user.create_user', 261 | 'social_core.pipeline.social_auth.associate_user', 262 | 'social_core.pipeline.social_auth.load_extra_data', 263 | 'social_core.pipeline.user.user_details', 264 | ) 265 | 266 | SOCIAL_AUTH_TWITTER_PIPELINE = ( 267 | 'social_core.pipeline.social_auth.social_details', 268 | 'social_core.pipeline.social_auth.social_uid', 269 | 'social_core.pipeline.social_auth.auth_allowed', 270 | # 'social_core.pipeline.social_auth.social_user', 271 | 'src.common.social_pipeline.user.social_user', 272 | 'social_core.pipeline.user.get_username', 273 | 'social_core.pipeline.social_auth.associate_by_email', 274 | 'social_core.pipeline.user.create_user', 275 | 'social_core.pipeline.social_auth.associate_user', 276 | 'social_core.pipeline.social_auth.load_extra_data', 277 | 'social_core.pipeline.user.user_details', 278 | 'src.common.social_pipeline.user.login_user', # login correct user at the end 279 | ) 280 | 281 | SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/complete/twitter/' 282 | 283 | THUMBNAIL_ALIASES = { 284 | 'src.users': { 285 | 'thumbnail': {'size': (100, 100), 'crop': True}, 286 | 'medium_square_crop': {'size': (400, 400), 'crop': True}, 287 | 'small_square_crop': {'size': (50, 50), 'crop': True}, 288 | }, 289 | } 290 | 291 | # Django Rest Framework 292 | 293 | # Django Rest Framework 294 | REST_FRAMEWORK = { 295 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 296 | 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend', 'rest_framework.filters.OrderingFilter'], 297 | 'PAGE_SIZE': int(os.getenv('DJANGO_PAGINATION_LIMIT', 18)), 298 | 'DATETIME_FORMAT': '%Y-%m-%dT%H:%M:%S.%fZ', 299 | 'DEFAULT_RENDERER_CLASSES': ( 300 | 'rest_framework.renderers.JSONRenderer', 301 | 'rest_framework.renderers.BrowsableAPIRenderer', 302 | ), 303 | 'DEFAULT_PERMISSION_CLASSES': [ 304 | 'rest_framework.permissions.IsAuthenticated', 305 | ], 306 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 307 | 'rest_framework.authentication.SessionAuthentication', 308 | 'rest_framework_simplejwt.authentication.JWTAuthentication', 309 | ), 310 | 'DEFAULT_THROTTLE_CLASSES': [ 311 | 'rest_framework.throttling.AnonRateThrottle', 312 | 'rest_framework.throttling.UserRateThrottle', 313 | 'rest_framework.throttling.ScopedRateThrottle', 314 | ], 315 | 'DEFAULT_THROTTLE_RATES': {'anon': '100/second', 'user': '1000/second', 'subscribe': '60/minute'}, 316 | 'TEST_REQUEST_DEFAULT_FORMAT': 'json', 317 | } 318 | 319 | # JWT configuration 320 | SIMPLE_JWT = { 321 | 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), 322 | 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 323 | 'ROTATE_REFRESH_TOKENS': False, 324 | 'BLACKLIST_AFTER_ROTATION': True, 325 | 'UPDATE_LAST_LOGIN': False, 326 | 'ALGORITHM': 'HS256', 327 | 'SIGNING_KEY': SECRET_KEY, 328 | 'VERIFYING_KEY': None, 329 | 'AUDIENCE': None, 330 | 'ISSUER': None, 331 | 'AUTH_HEADER_TYPES': ('Bearer',), 332 | 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', 333 | 'USER_ID_FIELD': 'id', 334 | 'USER_ID_CLAIM': 'user_id', 335 | 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), 336 | 'TOKEN_TYPE_CLAIM': 'token_type', 337 | 'JTI_CLAIM': 'jti', 338 | 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', 339 | 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5), 340 | 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), 341 | } 342 | 343 | # summernote configuration 344 | SUMMERNOTE_CONFIG = { 345 | 'summernote': { 346 | 'toolbar': [ 347 | ['style', ['style']], 348 | ['font', ['bold', 'underline', 'clear']], 349 | ['fontname', ['fontname']], 350 | ['color', ['color']], 351 | ['para', ['ul', 'ol', 'paragraph', 'smallTagButton']], 352 | ['table', ['table']], 353 | ['insert', ['link', 'video']], 354 | ['view', ['fullscreen', 'codeview', 'help']], 355 | ] 356 | } 357 | } 358 | 359 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 360 | -------------------------------------------------------------------------------- /src/config/local.py: -------------------------------------------------------------------------------- 1 | from src.config.common import * # noqa 2 | 3 | # Testing 4 | INSTALLED_APPS += ('django_nose',) # noqa 5 | TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' 6 | NOSE_ARGS = ['-s', '--nologcapture', '--with-progressive', '--with-fixture-bundling'] 7 | -------------------------------------------------------------------------------- /src/config/production.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .common import * # noqa 3 | 4 | 5 | # Site 6 | # https://docs.djangoproject.com/en/2.0/ref/settings/#allowed-hosts 7 | ALLOWED_HOSTS = ["*"] 8 | INSTALLED_APPS += ( 9 | "gunicorn", 10 | "storages", 11 | ) # noqa 12 | 13 | # Static files (CSS, JavaScript, Images) 14 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 15 | # http://django-storages.readthedocs.org/en/latest/index.html 16 | DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' 17 | STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' 18 | AWS_ACCESS_KEY_ID = os.getenv('DJANGO_AWS_ACCESS_KEY_ID') 19 | AWS_SECRET_ACCESS_KEY = os.getenv('DJANGO_AWS_SECRET_ACCESS_KEY') 20 | AWS_STORAGE_BUCKET_NAME = os.getenv('DJANGO_AWS_STORAGE_BUCKET_NAME') 21 | # By default files with the same name will overwrite each other. 22 | # Set this to False to have extra characters appended. 23 | AWS_S3_FILE_OVERWRITE = False 24 | AWS_DEFAULT_ACL = 'public-read' 25 | AWS_AUTO_CREATE_BUCKET = True 26 | AWS_QUERYSTRING_AUTH = False 27 | MEDIA_URL = f'https://s3.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}/' 28 | 29 | # https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#cache-control 30 | # Response can be cached by browser and any intermediary caches (i.e. it is "public") for up to 1 day 31 | # 86400 = (60 seconds x 60 minutes x 24 hours) 32 | AWS_HEADERS = { 33 | 'Cache-Control': 'max-age=86400, s-maxage=86400, must-revalidate', 34 | } 35 | 36 | # Social 37 | SOCIAL_AUTH_REDIRECT_IS_HTTPS = True 38 | 39 | # easy thumbnails lib & S3 40 | THUMBNAIL_DEFAULT_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' 41 | -------------------------------------------------------------------------------- /src/config/stage.py: -------------------------------------------------------------------------------- 1 | from .common import * # noqa 2 | 3 | 4 | # Site 5 | # https://docs.djangoproject.com/en/2.0/ref/settings/#allowed-hosts 6 | ALLOWED_HOSTS = ["*"] 7 | INSTALLED_APPS += ("gunicorn",) # noqa 8 | 9 | # Social 10 | SOCIAL_AUTH_REDIRECT_IS_HTTPS = True 11 | -------------------------------------------------------------------------------- /src/files/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/src/files/__init__.py -------------------------------------------------------------------------------- /src/files/admin.py: -------------------------------------------------------------------------------- 1 | # from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/files/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FilesConfig(AppConfig): 5 | name = 'src.files' 6 | -------------------------------------------------------------------------------- /src/files/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-17 07:20 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.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='File', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('file', models.FileField(upload_to='')), 22 | ('thumbnail', models.ImageField(blank=True, null=True, upload_to='')), 23 | ('created_at', models.DateTimeField(auto_now_add=True)), 24 | ( 25 | 'author', 26 | models.ForeignKey( 27 | on_delete=django.db.models.deletion.DO_NOTHING, related_name='files', to=settings.AUTH_USER_MODEL 28 | ), 29 | ), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /src/files/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/src/files/migrations/__init__.py -------------------------------------------------------------------------------- /src/files/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.signals import post_delete, post_save 3 | from django.dispatch import receiver 4 | from easy_thumbnails.exceptions import EasyThumbnailsError 5 | from easy_thumbnails.files import get_thumbnailer 6 | from PIL import UnidentifiedImageError 7 | 8 | 9 | class File(models.Model): 10 | THUMBNAIL_SIZE = (360, 360) 11 | 12 | file = models.FileField(blank=False, null=False) 13 | thumbnail = models.ImageField(blank=True, null=True) 14 | author = models.ForeignKey('users.User', related_name='files', on_delete=models.DO_NOTHING) 15 | created_at = models.DateTimeField(auto_now_add=True) 16 | 17 | 18 | @receiver(post_delete, sender=File) 19 | def auto_delete_file_on_delete(sender, instance, **kwargs): 20 | if instance.file: 21 | instance.file.delete() 22 | 23 | if instance.thumbnail: 24 | instance.thumbnail.delete() 25 | 26 | 27 | @receiver(post_save, sender=File) 28 | def generate_thumbnail(sender, instance=None, created=False, **kwargs): 29 | # avoid recursion 30 | if created is False: 31 | return 32 | 33 | thumbnailer = get_thumbnailer(instance.file.name, relative_name='thumbnail') 34 | try: 35 | thumbnail = thumbnailer.get_thumbnail({'size': File.THUMBNAIL_SIZE}, save=False) 36 | except (UnidentifiedImageError, EasyThumbnailsError): 37 | return 38 | else: 39 | instance.thumbnail.save(name=f'small_{instance.file.name}', content=thumbnail) 40 | -------------------------------------------------------------------------------- /src/files/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import File 4 | 5 | 6 | class FileSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = File 9 | fields = ('file', 'thumbnail', 'created_at', 'id') 10 | 11 | def create(self, validated_data): 12 | user = self.context['request'].user 13 | validated_data['author_id'] = user.id 14 | 15 | return super().create(validated_data) 16 | -------------------------------------------------------------------------------- /src/files/tests.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/files/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from .views import FilesViewset 4 | 5 | files_router = SimpleRouter() 6 | 7 | files_router.register(r'files', FilesViewset) 8 | -------------------------------------------------------------------------------- /src/files/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | 3 | MAX_FILESIZE = 10100000 # 10MB 4 | 5 | 6 | def validate_file_size(value): 7 | filesize = value.size 8 | if filesize > MAX_FILESIZE: 9 | raise ValidationError('The maximum file size that can be uploaded is 10MB') 10 | else: 11 | return value 12 | -------------------------------------------------------------------------------- /src/files/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets, mixins 2 | from rest_framework.parsers import MultiPartParser, FormParser 3 | from rest_framework.permissions import IsAuthenticated 4 | 5 | from .serializers import FileSerializer 6 | from .models import File 7 | 8 | 9 | class FilesViewset(mixins.CreateModelMixin, viewsets.GenericViewSet): 10 | # MultiPartParser AND FormParser 11 | # https://www.django-rest-framework.org/api-guide/parsers/#multipartparser 12 | # "You will typically want to use both FormParser and MultiPartParser 13 | # together in order to fully support HTML form data." 14 | parser_classes = (MultiPartParser, FormParser) 15 | queryset = File.objects.all() 16 | serializer_class = FileSerializer 17 | permissions = {'default': (IsAuthenticated,)} 18 | 19 | def create(self, request, *args, **kwargs): 20 | """ 21 | Create a MyModel 22 | --- 23 | parameters: 24 | - name: file 25 | description: file 26 | required: True 27 | type: file 28 | responseMessages: 29 | - code: 201 30 | message: Created 31 | """ 32 | return super().create(request, *args, **kwargs) 33 | -------------------------------------------------------------------------------- /src/notifications/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/src/notifications/__init__.py -------------------------------------------------------------------------------- /src/notifications/admin.py: -------------------------------------------------------------------------------- 1 | # from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/notifications/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NotificationsConfig(AppConfig): 5 | name = 'src.notifications' 6 | -------------------------------------------------------------------------------- /src/notifications/channels/email.py: -------------------------------------------------------------------------------- 1 | from django.core.mail import EmailMultiAlternatives 2 | from django.template.loader import render_to_string 3 | from django.conf import settings 4 | 5 | 6 | class EmailChannel: 7 | @staticmethod 8 | def send(context, html_template, subject, to): 9 | if isinstance(to, str): 10 | to = [to] 11 | 12 | email_html_message = render_to_string(html_template, context) 13 | 14 | if settings.TESTING: 15 | msg = EmailMultiAlternatives( 16 | subject, 17 | email_html_message, 18 | settings.EMAIL_FROM, 19 | to, 20 | alternatives=((email_html_message, 'text/html'),), 21 | ) 22 | return msg.send() 23 | 24 | from src.common.tasks import send_email_task 25 | 26 | send_email_task.delay(subject, to, settings.EMAIL_FROM, email_html_message) 27 | return 28 | -------------------------------------------------------------------------------- /src/notifications/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/src/notifications/migrations/__init__.py -------------------------------------------------------------------------------- /src/notifications/models.py: -------------------------------------------------------------------------------- 1 | # from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /src/notifications/services.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from actstream import action 3 | 4 | from src.notifications.channels.email import EmailChannel 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | ACTIVITY_USER_RESETS_PASS = 'started password reset process' 9 | 10 | NOTIFICATIONS = { 11 | ACTIVITY_USER_RESETS_PASS: { 12 | 'email': { 13 | 'email_subject': 'Password Reset', 14 | 'email_html_template': 'emails/user_reset_password.html', 15 | } 16 | } 17 | } 18 | 19 | 20 | def _send_email(email_notification_config, context, to): 21 | email_html_template = email_notification_config.get('email_html_template') 22 | email_subject = email_notification_config.get('email_subject') 23 | 24 | EmailChannel.send(context=context, html_template=email_html_template, subject=email_subject, to=to) 25 | 26 | 27 | def notify(verb, **kwargs): 28 | notification_config = NOTIFICATIONS.get(verb) 29 | 30 | if notification_config and notification_config.get('email'): 31 | email_notification_config = notification_config.get('email') 32 | context = kwargs.get('context', {}) 33 | email_to = kwargs.get('email_to', []) 34 | 35 | if not email_to: 36 | logger.debug('Please provide list of emails (email_to argument).') 37 | 38 | _send_email(email_notification_config, context, email_to) 39 | 40 | 41 | # Use only with actstream activated 42 | def send_action(sender, verb, action_object, target, **kwargs): 43 | action.send(sender=sender, verb=verb, action_object=action_object, target=target) 44 | notify(verb, **kwargs) 45 | -------------------------------------------------------------------------------- /src/notifications/test/test_channels_email.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.template.exceptions import TemplateDoesNotExist 3 | 4 | from src.notifications.channels.email import EmailChannel 5 | 6 | 7 | class TestEmailChannel(TestCase): 8 | subject = 'subject line' 9 | to = 'to@example.com' 10 | 11 | def test_raise_exception_when_template_does_not_exist(self): 12 | with self.assertRaises(TemplateDoesNotExist): 13 | EmailChannel.send({}, 'template_does_not_exist', self.subject, self.to) 14 | 15 | def test_send_mail(self): 16 | assert EmailChannel.send({}, 'emails/user_reset_password.html', self.subject, self.to) == 1 17 | -------------------------------------------------------------------------------- /src/notifications/tests.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/notifications/views.py: -------------------------------------------------------------------------------- 1 | # from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /src/social/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'src.social.apps.SocialConfig' 2 | -------------------------------------------------------------------------------- /src/social/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SocialConfig(AppConfig): 5 | name = 'src.social' 6 | -------------------------------------------------------------------------------- /src/social/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | class SocialSerializer(serializers.Serializer): 5 | """ 6 | Serializer which accepts an OAuth2 access token. 7 | """ 8 | 9 | access_token = serializers.CharField( 10 | allow_blank=False, 11 | trim_whitespace=True, 12 | ) 13 | -------------------------------------------------------------------------------- /src/social/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework import status 3 | from rest_framework.decorators import api_view, permission_classes 4 | from rest_framework.permissions import AllowAny 5 | from rest_framework.response import Response 6 | from requests.exceptions import HTTPError 7 | from social_django.utils import psa 8 | from django.shortcuts import redirect 9 | 10 | from .serializers import SocialSerializer 11 | 12 | 13 | @api_view(http_method_names=['GET']) 14 | @permission_classes([AllowAny]) 15 | def complete_twitter_login(request, *args, **kwargs): 16 | tokens = request.user.get_tokens() 17 | access_token = tokens['access'] 18 | refresh_token = tokens['refresh'] 19 | return redirect(settings.TWITTER_FE_URL + f'?access_token={access_token}&refresh_token={refresh_token}') 20 | 21 | 22 | @api_view(http_method_names=['POST']) 23 | @permission_classes([AllowAny]) 24 | @psa() 25 | def exchange_token(request, backend): 26 | """ 27 | Exchange an OAuth2 access token for one for this site. 28 | This simply defers the entire OAuth2 process to the front end. 29 | The front end becomes responsible for handling the entirety of the 30 | OAuth2 process; we just step in at the end and use the access token 31 | to populate some user identity. 32 | Using that example, you could call this endpoint using i.e. 33 | POST API_ROOT + 'social/facebook/' 34 | POST API_ROOT + 'social/google-oauth2/' 35 | ## Request format 36 | Requests must include the following field 37 | - `access_token`: The OAuth2 access token provided by the provider 38 | """ 39 | serializer = SocialSerializer(data=request.data) 40 | if serializer.is_valid(raise_exception=True): 41 | # set up non-field errors key 42 | # http://www.django-rest-framework.org/api-guide/exceptions/#exception-handling-in-rest-framework-views 43 | try: 44 | nfe = settings.NON_FIELD_ERRORS_KEY 45 | except AttributeError: 46 | nfe = 'non_field_errors' 47 | 48 | try: 49 | # this line, plus the psa decorator above, are all that's necessary to 50 | # get and populate a user object for any properly enabled/configured backend 51 | # which python-social-auth can handle. 52 | user = request.backend.do_auth(serializer.validated_data['access_token']) 53 | except HTTPError as e: 54 | # An HTTPError bubbled up from the request to the social auth provider. 55 | # This happens, at least in Google's case, every time you send a malformed 56 | # or incorrect access key. 57 | return Response( 58 | { 59 | 'errors': { 60 | 'token': 'Invalid token', 61 | 'detail': str(e), 62 | } 63 | }, 64 | status=status.HTTP_400_BAD_REQUEST, 65 | ) 66 | 67 | if user: 68 | if user.is_active: 69 | tokens = user.get_tokens() 70 | return Response(tokens) 71 | else: 72 | # user is not active; at some point they deleted their account, 73 | # or were banned by a superuser. They can't just log in with their 74 | # normal credentials anymore, so they can't log in with social 75 | # credentials either. 76 | return Response( 77 | {'errors': {nfe: 'This user account is inactive'}}, 78 | status=status.HTTP_400_BAD_REQUEST, 79 | ) 80 | else: 81 | # Unfortunately, PSA swallows any information the backend provider 82 | # generated as to why specifically the authentication failed; 83 | # this makes it tough to debug except by examining the server logs. 84 | return Response( 85 | {'errors': {nfe: "Authentication Failed"}}, 86 | status=status.HTTP_400_BAD_REQUEST, 87 | ) 88 | -------------------------------------------------------------------------------- /src/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import path, re_path, include, reverse_lazy 3 | from django.conf.urls.static import static 4 | from django.conf.urls import url 5 | from django.contrib import admin 6 | from django.views.generic.base import RedirectView 7 | from rest_framework.routers import DefaultRouter 8 | from rest_framework_simplejwt.views import ( 9 | TokenObtainPairView, 10 | TokenRefreshView, 11 | ) 12 | from drf_yasg.views import get_schema_view 13 | from drf_yasg import openapi 14 | 15 | from src.social.views import exchange_token, complete_twitter_login 16 | from src.files.urls import files_router 17 | from src.users.urls import users_router 18 | 19 | schema_view = get_schema_view( 20 | openapi.Info(title="Pastebin API", default_version='v1'), 21 | public=True, 22 | ) 23 | 24 | router = DefaultRouter() 25 | 26 | router.registry.extend(users_router.registry) 27 | router.registry.extend(files_router.registry) 28 | 29 | urlpatterns = [ 30 | # admin panel 31 | path('admin/', admin.site.urls), 32 | url(r'^jet/', include('jet.urls', 'jet')), # Django JET URLS 33 | # summernote editor 34 | path('summernote/', include('django_summernote.urls')), 35 | # api 36 | path('api/v1/', include(router.urls)), 37 | url(r'^api/v1/password_reset/', include('django_rest_passwordreset.urls', namespace='password_reset')), 38 | # auth 39 | path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), 40 | path('api/v1/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), 41 | path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), 42 | # social login 43 | url('', include('social_django.urls', namespace='social')), 44 | url(r'^complete/twitter/', complete_twitter_login), 45 | url(r'^api/v1/social/(?P[^/]+)/$', exchange_token), 46 | # swagger docs 47 | url(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), 48 | url(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), 49 | url(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), 50 | url(r'^health/', include('health_check.urls')), 51 | # the 'api-root' from django rest-frameworks default router 52 | re_path(r'^$', RedirectView.as_view(url=reverse_lazy('api-root'), permanent=False)), 53 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 54 | -------------------------------------------------------------------------------- /src/users/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'src.users.apps.UsersConfig' 2 | -------------------------------------------------------------------------------- /src/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from src.users.models import User 6 | 7 | 8 | @admin.register(User) 9 | class UserAdmin(UserAdmin): 10 | fieldsets = ( 11 | (None, {'fields': ('username', 'password')}), 12 | ( 13 | _('Personal info'), 14 | { 15 | 'fields': ( 16 | 'first_name', 17 | 'last_name', 18 | 'email', 19 | ) 20 | }, 21 | ), 22 | (_('Profile image'), {'fields': ('profile_picture',)}), 23 | (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), 24 | (_('Important dates'), {'fields': ('last_login', 'date_joined')}), 25 | ) 26 | -------------------------------------------------------------------------------- /src/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UsersConfig(AppConfig): 5 | name = 'src.users' 6 | 7 | # actstream register model 8 | # def ready(self): 9 | # from actstream import registry 10 | # registry.register(self.get_model('User')) 11 | -------------------------------------------------------------------------------- /src/users/backends.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.contrib.auth.backends import ModelBackend 4 | 5 | from src.users.models import User 6 | 7 | 8 | class EmailOrUsernameModelBackend(ModelBackend): 9 | def authenticate(self, request, **kwargs): 10 | username = kwargs['username'] 11 | password = kwargs['password'] 12 | 13 | if username and re.search(r'[^@\s]+@[^@\s]+\.[^@\s]+', username): 14 | kwargs = {'email': username} 15 | else: 16 | kwargs = {'username': username} 17 | 18 | try: 19 | user = User.objects.get(**kwargs) 20 | except User.DoesNotExist: 21 | return None 22 | else: 23 | if user.is_active and user.check_password(password): 24 | return user 25 | 26 | return None 27 | 28 | def get_user(self, user_id): 29 | try: 30 | return User.objects.get(pk=user_id) 31 | except User.DoesNotExist: 32 | return None 33 | -------------------------------------------------------------------------------- /src/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.8 on 2017-12-21 03:04 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.auth.models 6 | import django.contrib.auth.validators 7 | from django.db import migrations, models 8 | import django.utils.timezone 9 | import uuid 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | ('auth', '0008_alter_user_username_max_length'), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='User', 23 | fields=[ 24 | ('password', models.CharField(max_length=128, verbose_name='password')), 25 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 26 | ( 27 | 'is_superuser', 28 | models.BooleanField( 29 | default=False, 30 | help_text='Designates that this user has all permissions without explicitly assigning them.', 31 | verbose_name='superuser status', 32 | ), 33 | ), 34 | ( 35 | 'username', 36 | models.CharField( 37 | error_messages={'unique': 'A user with that username already exists.'}, 38 | help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', 39 | max_length=150, 40 | unique=True, 41 | validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], 42 | verbose_name='username', 43 | ), 44 | ), 45 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 46 | ('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')), 47 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 48 | ( 49 | 'is_staff', 50 | models.BooleanField( 51 | default=False, 52 | help_text='Designates whether the user can log into this admin site.', 53 | verbose_name='staff status', 54 | ), 55 | ), 56 | ( 57 | 'is_active', 58 | models.BooleanField( 59 | default=True, 60 | help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', 61 | verbose_name='active', 62 | ), 63 | ), 64 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 65 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 66 | ( 67 | 'groups', 68 | models.ManyToManyField( 69 | blank=True, 70 | help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', 71 | related_name='user_set', 72 | related_query_name='user', 73 | to='auth.Group', 74 | verbose_name='groups', 75 | ), 76 | ), 77 | ( 78 | 'user_permissions', 79 | models.ManyToManyField( 80 | blank=True, 81 | help_text='Specific permissions for this user.', 82 | related_name='user_set', 83 | related_query_name='user', 84 | to='auth.Permission', 85 | verbose_name='user permissions', 86 | ), 87 | ), 88 | ], 89 | options={ 90 | 'verbose_name': 'user', 91 | 'verbose_name_plural': 'users', 92 | 'abstract': False, 93 | }, 94 | managers=[ 95 | ('objects', django.contrib.auth.models.UserManager()), 96 | ], 97 | ), 98 | ] 99 | -------------------------------------------------------------------------------- /src/users/migrations/0002_auto_20171227_2246.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2017-12-27 22:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('users', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='last_name', 16 | field=models.CharField(blank=True, max_length=150, verbose_name='last name'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/users/migrations/0003_user_profile_picture.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-20 18:20 2 | 3 | from django.db import migrations 4 | import easy_thumbnails.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('users', '0002_auto_20171227_2246'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='user', 16 | name='profile_picture', 17 | field=easy_thumbnails.fields.ThumbnailerImageField( 18 | blank=True, null=True, upload_to='profile_pictures/', verbose_name='ProfilePicture' 19 | ), # noqa 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/users/migrations/0004_auto_20210317_0720.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-17 07:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('users', '0003_user_profile_picture'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='first_name', 16 | field=models.CharField(blank=True, max_length=150, verbose_name='first name'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/src/users/migrations/__init__.py -------------------------------------------------------------------------------- /src/users/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.dispatch import receiver 4 | from django.contrib.auth.models import AbstractUser 5 | from rest_framework_simplejwt.tokens import RefreshToken 6 | from easy_thumbnails.fields import ThumbnailerImageField 7 | from django.urls import reverse 8 | from django_rest_passwordreset.signals import reset_password_token_created 9 | from easy_thumbnails.signals import saved_file 10 | from easy_thumbnails.signal_handlers import generate_aliases_global 11 | 12 | from src.common.helpers import build_absolute_uri 13 | from src.notifications.services import notify, ACTIVITY_USER_RESETS_PASS 14 | 15 | 16 | @receiver(reset_password_token_created) 17 | def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs): 18 | """ 19 | Handles password reset tokens 20 | When a token is created, an e-mail needs to be sent to the user 21 | """ 22 | reset_password_path = reverse('password_reset:reset-password-confirm') 23 | context = { 24 | 'username': reset_password_token.user.username, 25 | 'email': reset_password_token.user.email, 26 | 'reset_password_url': build_absolute_uri(f'{reset_password_path}?token={reset_password_token.key}'), 27 | } 28 | 29 | notify(ACTIVITY_USER_RESETS_PASS, context=context, email_to=[reset_password_token.user.email]) 30 | 31 | 32 | class User(AbstractUser): 33 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 34 | profile_picture = ThumbnailerImageField('ProfilePicture', upload_to='profile_pictures/', blank=True, null=True) 35 | 36 | def get_tokens(self): 37 | refresh = RefreshToken.for_user(self) 38 | 39 | return { 40 | 'refresh': str(refresh), 41 | 'access': str(refresh.access_token), 42 | } 43 | 44 | def __str__(self): 45 | return self.username 46 | 47 | 48 | saved_file.connect(generate_aliases_global) 49 | -------------------------------------------------------------------------------- /src/users/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class IsUserOrReadOnly(permissions.BasePermission): 5 | """ 6 | Object-level permission to only allow owners of an object to edit it. 7 | """ 8 | 9 | def has_object_permission(self, request, view, obj): 10 | 11 | if request.method in permissions.SAFE_METHODS: 12 | return True 13 | 14 | return obj == request.user 15 | -------------------------------------------------------------------------------- /src/users/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from src.users.models import User 4 | from src.common.serializers import ThumbnailerJSONSerializer 5 | 6 | 7 | class UserSerializer(serializers.ModelSerializer): 8 | profile_picture = ThumbnailerJSONSerializer(required=False, allow_null=True, alias_target='src.users') 9 | 10 | class Meta: 11 | model = User 12 | fields = ( 13 | 'id', 14 | 'username', 15 | 'first_name', 16 | 'last_name', 17 | 'profile_picture', 18 | ) 19 | read_only_fields = ('username',) 20 | 21 | 22 | class CreateUserSerializer(serializers.ModelSerializer): 23 | profile_picture = ThumbnailerJSONSerializer(required=False, allow_null=True, alias_target='src.users') 24 | tokens = serializers.SerializerMethodField() 25 | 26 | def get_tokens(self, user): 27 | return user.get_tokens() 28 | 29 | def create(self, validated_data): 30 | # call create_user on user object. Without this 31 | # the password will be stored in plain text. 32 | user = User.objects.create_user(**validated_data) 33 | return user 34 | 35 | class Meta: 36 | model = User 37 | fields = ( 38 | 'id', 39 | 'username', 40 | 'password', 41 | 'first_name', 42 | 'last_name', 43 | 'email', 44 | 'tokens', 45 | 'profile_picture', 46 | ) 47 | read_only_fields = ('tokens',) 48 | extra_kwargs = {'password': {'write_only': True}} 49 | -------------------------------------------------------------------------------- /src/users/templates/emails/user_reset_password.html: -------------------------------------------------------------------------------- 1 |

Hi {{ username }},

2 |

3 | You asked for password reset for this email account {{ email }}. Click on this link {{ reset_password_url }} to reset 5 | your password 6 |

7 | -------------------------------------------------------------------------------- /src/users/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vivify-Ideas/python-django-drf-boilerplate/9313481a560889257c9662a2df69446599f27c59/src/users/test/__init__.py -------------------------------------------------------------------------------- /src/users/test/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | 4 | class UserFactory(factory.django.DjangoModelFactory): 5 | class Meta: 6 | model = 'users.User' 7 | django_get_or_create = ('username',) 8 | 9 | id = factory.Faker('uuid4') 10 | username = factory.Sequence(lambda n: f'testuser{n}') 11 | password = factory.PostGenerationMethodCall('set_password', 'asdf') 12 | email = factory.Faker('email') 13 | first_name = factory.Faker('first_name') 14 | last_name = factory.Faker('last_name') 15 | is_active = True 16 | is_staff = False 17 | -------------------------------------------------------------------------------- /src/users/test/test_serializers.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.contrib.auth.hashers import check_password 3 | from nose.tools import eq_, ok_ 4 | from ..serializers import CreateUserSerializer 5 | 6 | 7 | class TestCreateUserSerializer(TestCase): 8 | def setUp(self): 9 | self.user_data = {'username': 'test', 'password': 'test'} 10 | 11 | def test_serializer_with_empty_data(self): 12 | serializer = CreateUserSerializer(data={}) 13 | eq_(serializer.is_valid(), False) 14 | 15 | def test_serializer_with_valid_data(self): 16 | serializer = CreateUserSerializer(data=self.user_data) 17 | ok_(serializer.is_valid()) 18 | 19 | def test_serializer_hashes_password(self): 20 | serializer = CreateUserSerializer(data=self.user_data) 21 | ok_(serializer.is_valid()) 22 | 23 | user = serializer.save() 24 | ok_(check_password(self.user_data.get('password'), user.password)) 25 | -------------------------------------------------------------------------------- /src/users/test/test_views.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.contrib.auth.hashers import check_password 3 | from nose.tools import ok_, eq_ 4 | from rest_framework.test import APITestCase 5 | from rest_framework import status 6 | from faker import Faker 7 | from ..models import User 8 | from .factories import UserFactory 9 | 10 | fake = Faker() 11 | 12 | 13 | class TestUserListTestCase(APITestCase): 14 | """ 15 | Tests /users list operations. 16 | """ 17 | 18 | def setUp(self): 19 | self.url = reverse('user-list') 20 | self.user_data = {'username': 'test', 'password': 'test'} 21 | 22 | def test_post_request_with_no_data_fails(self): 23 | response = self.client.post(self.url, {}) 24 | eq_(response.status_code, status.HTTP_400_BAD_REQUEST) 25 | 26 | def test_post_request_with_valid_data_succeeds(self): 27 | response = self.client.post(self.url, self.user_data) 28 | eq_(response.status_code, status.HTTP_201_CREATED) 29 | 30 | user = User.objects.get(pk=response.data.get('id')) 31 | eq_(user.username, self.user_data.get('username')) 32 | ok_(check_password(self.user_data.get('password'), user.password)) 33 | 34 | 35 | class TestUserDetailTestCase(APITestCase): 36 | """ 37 | Tests /users detail operations. 38 | """ 39 | 40 | def setUp(self): 41 | self.user = UserFactory() 42 | tokens = self.user.get_tokens() 43 | access_token = tokens['access'] 44 | self.url = reverse('user-detail', kwargs={'pk': self.user.pk}) 45 | self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') 46 | 47 | def test_get_request_returns_a_given_user(self): 48 | response = self.client.get(self.url) 49 | eq_(response.status_code, status.HTTP_200_OK) 50 | 51 | def test_put_request_updates_a_user(self): 52 | new_first_name = fake.first_name() 53 | payload = {'first_name': new_first_name} 54 | response = self.client.put(self.url, payload) 55 | eq_(response.status_code, status.HTTP_200_OK) 56 | 57 | user = User.objects.get(pk=self.user.id) 58 | eq_(user.first_name, new_first_name) 59 | -------------------------------------------------------------------------------- /src/users/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from src.users.views import UserViewSet 4 | 5 | users_router = SimpleRouter() 6 | 7 | users_router.register(r'users', UserViewSet) 8 | -------------------------------------------------------------------------------- /src/users/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets, mixins 2 | from rest_framework.permissions import AllowAny 3 | from rest_framework.decorators import action 4 | from rest_framework.response import Response 5 | from rest_framework import status 6 | 7 | from src.users.models import User 8 | from src.users.permissions import IsUserOrReadOnly 9 | from src.users.serializers import CreateUserSerializer, UserSerializer 10 | 11 | 12 | class UserViewSet(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): 13 | """ 14 | Creates, Updates and Retrieves - User Accounts 15 | """ 16 | 17 | queryset = User.objects.all() 18 | serializers = {'default': UserSerializer, 'create': CreateUserSerializer} 19 | permissions = {'default': (IsUserOrReadOnly,), 'create': (AllowAny,)} 20 | 21 | def get_serializer_class(self): 22 | return self.serializers.get(self.action, self.serializers['default']) 23 | 24 | def get_permissions(self): 25 | self.permission_classes = self.permissions.get(self.action, self.permissions['default']) 26 | return super().get_permissions() 27 | 28 | @action(detail=False, methods=['get'], url_path='me', url_name='me') 29 | def get_user_data(self, instance): 30 | try: 31 | return Response(UserSerializer(self.request.user, context={'request': self.request}).data, status=status.HTTP_200_OK) 32 | except Exception as e: 33 | return Response({'error': 'Wrong auth token' + e}, status=status.HTTP_400_BAD_REQUEST) 34 | -------------------------------------------------------------------------------- /src/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for viral project. 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/gunicorn/ 6 | """ 7 | import os 8 | 9 | from django.core.wsgi import get_wsgi_application 10 | 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.config.local") 12 | 13 | application = get_wsgi_application() 14 | --------------------------------------------------------------------------------