├── .circleci └── config.yml ├── .coveragerc ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug---problem.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .pre-commit-config.yaml ├── .prospector.yaml ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── apps ├── core │ ├── __init__.py │ ├── constants.py │ ├── custom_email_backend.py │ ├── custom_exceptions.py │ ├── email_service.py │ ├── tests │ │ ├── __init__.py │ │ ├── base_test_utils.py │ │ ├── test_custom_email_backend.py │ │ ├── test_docs.py │ │ ├── test_email_service.py │ │ ├── test_exception_handler.py │ │ ├── test_poetry.py │ │ └── test_welcome_page_view.py │ ├── urls.py │ └── views.py ├── gdpr │ ├── __init__.py │ ├── constants.py │ ├── email_service.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── daily_tasks.py │ │ │ ├── delete_inactive_users.py │ │ │ ├── remove_deleted_users.py │ │ │ └── send_account_info.py │ ├── tests │ │ ├── __init__.py │ │ ├── base_test_utils.py │ │ ├── test_delete_inactive_users_command.py │ │ ├── test_email_service.py │ │ ├── test_remove_deleted_users_command.py │ │ ├── test_send_account_info_command.py │ │ └── test_utils.py │ └── utils.py └── users │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── constants │ ├── __init__.py │ ├── messages.py │ ├── template_names.py │ └── url_names.py │ ├── email_service.py │ ├── forms.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_user_auth_fields.py │ ├── 0003_user_gdpr_fields.py │ ├── 0004_user_last_password_change_date.py │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── tests │ ├── __init__.py │ ├── base_test_utils.py │ ├── constants.py │ ├── factories.py │ ├── test_change_user_password.py │ ├── test_delete_user.py │ ├── test_email_service.py │ ├── test_forms.py │ ├── test_jwt_token.py │ ├── test_registration.py │ ├── test_reset_user_password.py │ ├── test_serializers.py │ ├── test_user_info_link.py │ ├── test_user_login.py │ ├── test_user_me_endpoint.py │ └── test_user_model.py │ ├── tokens.py │ ├── urls.py │ └── views.py ├── conf ├── __init__.py ├── settings.py ├── tests │ ├── __init__.py │ └── test_utils.py ├── urls.py ├── utils.py └── wsgi.py ├── conftest.py ├── devops ├── docker │ ├── postgres │ │ ├── Dockerfile │ │ └── init.sh │ └── redis │ │ ├── Dockerfile │ │ └── healthcheck.sh └── entrypoint.sh ├── docker-compose.yml ├── manage.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── runtime.txt ├── scripts ├── __init__.py ├── poetry_scripts.py └── tests │ └── test_poetry_scripts.py ├── setup.cfg ├── static └── welcome │ └── style.css └── templates └── welcome.html /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Python CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-python/ for more details 4 | ## 5 | version: 2 6 | jobs: 7 | build: 8 | working_directory: ~/repo 9 | docker: 10 | # specify the version you desire here 11 | # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` 12 | - image: circleci/python:3.7 13 | environment: 14 | - DJANGO_SETTINGS_MODULE: conf.settings 15 | - ENV: test 16 | - DATABASE_URL: postgres://postgres@127.0.0.1:5432/postgres 17 | 18 | #Start test database & Redis 19 | - image: circleci/postgres:11.2 20 | environment: 21 | POSTGRES_USER: postgres 22 | POSTGRES_DB: postgres 23 | POSTGRES_PASSWORD: "" 24 | - image: redis:5.0.4 25 | 26 | steps: 27 | - run: 28 | name: Install dockerize 29 | command: 30 | # Avoid Dockerize race conditions - https://discuss.circleci.com/t/prevent-race-conditions-by-waiting-for-services-with-dockerize/11215/3 31 | if \[ -f /usr/local/bin/dockerize \]; then 32 | echo "dockerize found; skipping installation"; 33 | else wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 34 | && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 35 | && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz; 36 | fi 37 | environment: 38 | DOCKERIZE_VERSION: v0.3.0 39 | 40 | - setup_remote_docker: 41 | docker_layer_caching: false 42 | 43 | - run: 44 | name: Wait for Postgres database to be up 45 | command: dockerize -wait tcp://localhost:5432 -timeout 1m 46 | 47 | - run: 48 | name: Wait for Redis to be up 49 | command: dockerize -wait tcp://localhost:6379 -timeout 1m 50 | 51 | - checkout 52 | 53 | - restore_cache: 54 | keys: 55 | - v1-dependencies-{{ checksum "poetry.lock" }} 56 | # fallback to using the latest cache if no exact match is found 57 | - v1-dependencies- 58 | 59 | - run: 60 | name: Install requirements from Poetry 61 | command: | 62 | poetry install 63 | poetry check 64 | poetry show 65 | 66 | - run: 67 | name: Test for missing or conflicting migrations 68 | command: | 69 | poetry run python manage.py makemigrations --check 70 | 71 | - run: 72 | name: Run tests and create a Report 73 | command: | 74 | mkdir test-reports 75 | poetry run create-coverage 76 | poetry run codecov-report 77 | 78 | - save_cache: 79 | key: v1-dependencies-{{ checksum "poetry.lock" }} 80 | paths: 81 | - ~/.local 82 | - ~/.cache 83 | - ~/repo/.git 84 | - ~/repo/_static 85 | 86 | - store_artifacts: 87 | path: htmlcov 88 | destination: htmlcov 89 | 90 | - store_test_results: 91 | path: test-reports 92 | destination: test-reports 93 | 94 | workflows: 95 | version: 2 96 | build-and-deploy: 97 | jobs: 98 | - build 99 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | *locale/* 5 | *documentation/* 6 | *migrations/* 7 | *.local/* 8 | *conftest.py 9 | conf/settings/* 10 | conf/wsgi.py 11 | manage.py 12 | 13 | plugins = 14 | django_coverage_plugin 15 | 16 | [report] 17 | # Regex for lines to exclude from consideration 18 | exclude_lines = 19 | # Have to re-enable the standard pragma 20 | pragma: no cover 21 | 22 | # Don't complain about missing debug-only code: 23 | def __repr__ 24 | if self\.debug 25 | 26 | # Don't complain if tests don't hit defensive assertion code: 27 | raise AssertionError 28 | raise NotImplementedError 29 | 30 | # Don't complain if non-runnable code isn't run: 31 | if 0: 32 | if __name__ == .__main__.: 33 | 34 | # Exclude files with 100% coverage. 35 | skip_covered = True 36 | 37 | [html] 38 | directory = htmlcov 39 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PROJECT_NAME=boilerplate 2 | DB_PASSWORD=secret 3 | DB_USER=boilerplate 4 | 5 | # Autocreated admin credentials 6 | ADMIN_LOGIN=admin 7 | ADMIN_EMAIL=admin@example.com 8 | ADMIN_PASSWORD=!@secret!@ 9 | 10 | ENV=local 11 | SECRET_KEY=notsafeforproduction 12 | PORT=8000 13 | HOSTNAME=localhost 14 | 15 | # URL for Postgres 16 | DOCKER_POSTGRES_PORT=5432 17 | DATABASE_URL=postgresql://${PROJECT_NAME}:${DB_PASSWORD}@localhost:${DOCKER_POSTGRES_PORT}/postgres 18 | 19 | # URL for REDIS 20 | DOCKER_REDIS_PORT=6379 21 | REDIS_URL=redis://redis:${DOCKER_REDIS_PORT} 22 | # Another option is REDIS_URL=redis://redis@localhost:${DOCKER_REDIS_PORT} 23 | CACHE_TIMEOUT=300 24 | RQ_DEFAULT_RESULT_TTL=86400 25 | RQ_DEFAULT_TIMEOUT=180 26 | 27 | # Sentry 28 | USE_SENTRY=True 29 | SENTRY_AUTH_TOKEN= 30 | SENTRY_PROJECT_NAME=boilerplate-local 31 | SENTRY_DSN= 32 | HEROKU_SLUG_COMMIT="somehash" 33 | 34 | # CORS 35 | CORS_ORIGIN_ALLOW_ALL=False 36 | CORS_ORIGIN_WHITELIST= 37 | CORS_ALLOW_CREDENTIALS=True 38 | 39 | ALLOWED_HOSTS=['*'] 40 | USE_X_FORWARDED_HOST=False 41 | 42 | # Security 43 | SECURE_SSL_REDIRECT=True 44 | SECURE_REDIRECT_EXEMPT=["ht/"] 45 | SECURE_PROXY_SSL_HEADER=x-forwarded-proto 46 | SESSION_COOKIE_SECURE=True 47 | CSRF_COOKIE_HTTPONLY=True 48 | CSRF_COOKIE_SECURE=True 49 | X_FRAME_MIDDLEWARE_ENABLED=False 50 | X_FRAME_OPTIONS=DENY 51 | SECURE_CONTENT_TYPE_NOSNIFF=False 52 | 53 | # HSTS 54 | HSTS_ENABLED=True 55 | SECURE_HSTS_SECONDS=31536000 56 | SECURE_HSTS_INCLUDE_SUBDOMAINS=True 57 | SECURE_HSTS_PRELOAD=True 58 | 59 | # Silky 60 | SILKY_PROFILER=False 61 | SILKY_META=False -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug---problem.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug / Problem 3 | about: "Report an issue \U0001F6A8 " 4 | title: '' 5 | labels: Bugfixing 6 | assignees: jensneuhaus 7 | 8 | --- 9 | 10 | ## 😱 What is happening? What did you expect instead? 11 | 12 | ## ☝️ Steps to reproduce 13 | 14 | ## 🐛 More details (Sentry link, etc.) 15 | 16 | ## 🚨 Priority 17 | 18 | - [ ] Major problem (System not working at all) 19 | - [ ] Very high (Urgent fix needed) 20 | - [ ] High, but handled later 21 | - [ ] Medium 22 | - [ ] Low 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: "Suggest an idea for this project\U0001F5FC" 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## ☝️What is it? Why do we need it? 11 | 12 | ## 💭 Implementation details 13 | 14 | ## 📋 Todos 15 | 16 | - [ ] tbd 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### 🤘 Changes / Issues 2 | 3 | - [ ] # 4 | 5 | 6 | ### 🚨 Other Important Infos 7 | 8 | Nope 9 | -------------------------------------------------------------------------------- /.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 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 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 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # PyCharm settings 107 | .idea/ 108 | 109 | # PyTest cache 110 | .pytest_cache/ 111 | 112 | # Vs code configs 113 | .vscode -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: git://github.com/pre-commit/pre-commit-hooks 4 | rev: v1.2.3 5 | hooks: 6 | - id: check-added-large-files 7 | args: ['--maxkb=500'] 8 | - id: check-byte-order-marker 9 | - id: check-case-conflict 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: debug-statements 13 | - id: detect-private-key 14 | - id: trailing-whitespace 15 | - id: flake8 16 | - id: no-commit-to-branch 17 | 18 | - repo: https://github.com/ambv/black 19 | rev: 18.9b0 20 | hooks: 21 | - id: black 22 | language_version: python3.7 23 | 24 | - repo: local 25 | hooks: 26 | - id: isort 27 | name: isort-local 28 | entry : poetry run isort 29 | language: python 30 | types: [python] 31 | pass_filenames: true 32 | - id: missing-migrations 33 | name: missing-migrations-local 34 | entry: poetry run ./manage.py makemigrations --check 35 | language: system 36 | always_run: true 37 | pass_filenames: false 38 | - id: prospector 39 | name: prospector-local 40 | entry: poetry run prospector --messages-only -- 41 | language: python 42 | types: [python] 43 | pass_filenames: true 44 | 45 | -------------------------------------------------------------------------------- /.prospector.yaml: -------------------------------------------------------------------------------- 1 | output-format: text 2 | 3 | strictness: high 4 | test-warnings: true 5 | doc-warnings: false 6 | member-warnings: true 7 | 8 | uses: 9 | - django 10 | 11 | pep8: 12 | full: true 13 | disable: 14 | - D100 15 | - D101 16 | - D102 17 | - D103 18 | - D105 19 | - D205 20 | - D400 21 | - N802 22 | options: 23 | max-line-length: 88 24 | 25 | pyflakes: 26 | disable: 27 | - F999 28 | 29 | pylint: 30 | run: false 31 | 32 | dodgy: 33 | run: true 34 | 35 | ignore-paths: 36 | - htmlcov 37 | - docs 38 | - etc 39 | - run 40 | - logs 41 | - notebooks 42 | - scripts 43 | - .env 44 | - .venv 45 | - requirements.txt 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | ENV PYTHONDONTWRITEBYTECODE 1 5 | ENV DEBIAN_FRONTEND noninteractive 6 | 7 | WORKDIR /app 8 | 9 | RUN apt-get clean \ 10 | && apt-get update \ 11 | && apt-get install -y \ 12 | locales \ 13 | locales-all \ 14 | apt-utils \ 15 | && locale-gen en_US.UTF-8 16 | 17 | ENV LC_ALL en_US.UTF-8 18 | ENV LANG en_US.UTF-8 19 | ENV LANGUAGE en_US.UTF-8 20 | 21 | RUN apt-get install -y \ 22 | build-essential \ 23 | libpq-dev \ 24 | postgresql-client \ 25 | curl \ 26 | binutils \ 27 | libproj-dev \ 28 | gdal-bin 29 | 30 | # Install Poetry & ensure it is in $PATH 31 | RUN curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | POETRY_PREVIEW=1 python 32 | ENV PATH "/root/.poetry/bin:/opt/venv/bin:${PATH}" 33 | 34 | # Only copying these files here in order to take advantage of Docker cache. We only want the 35 | # next stage (poetry install) to run if these files change, but not the rest of the app. 36 | COPY pyproject.toml poetry.lock ./ 37 | 38 | # Currently poetry install is significantly slower than pip install, so we're creating a 39 | # requirements.txt output and running pip install with it. 40 | # Follow this issue: https://github.com/python-poetry/poetry/issues/338 41 | # Setting --without-hashes because of this issue: https://github.com/pypa/pip/issues/4995 42 | RUN poetry config virtualenvs.create false \ 43 | && poetry export --without-hashes -f requirements.txt --dev \ 44 | | poetry run pip install -r /dev/stdin \ 45 | && poetry debug 46 | 47 | COPY . /app 48 | 49 | # Because initially we only copy the lock and pyproject file, we can only install the dependencies 50 | # in the RUN above, as the `packages` portion of the pyproject.toml file is not 51 | # available at this point. Now, after the whole package has been copied in, we run `poetry install` 52 | # again to only install packages, scripts, etc. (and thus it should be very quick). 53 | # See this issue for more context: https://github.com/python-poetry/poetry/issues/1899 54 | RUN poetry install --no-interaction 55 | 56 | RUN mkdir ./_static ./_media || true 57 | 58 | COPY ./devops/entrypoint.sh /entrypoint.sh 59 | RUN chmod +x /entrypoint.sh 60 | RUN chmod +x ./manage.py 61 | 62 | CMD /entrypoint.sh 63 | 64 | HEALTHCHECK CMD curl -s -S -H http://$HOSTNAME:8000 65 | 66 | EXPOSE 8000 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 unicorn.supplies 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: python manage.py migrate --no-input 2 | web: gunicorn --bind="${HOST:-0.0.0.0}:${PORT:-5000}" --log-file=- --log-level=INFO --capture-output conf.wsgi:application 3 | worker: python manage.py rqworker -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pixelpassion.io](https://img.shields.io/badge/made%20by-pixelpassion.io-blue.svg)](https://www.pixelpassion.io/) 2 | 3 | # `🥜 django-saas-boilerplate` 4 | 5 | A boilerplate to get started with Django, django-rest-framework on Heroku. It is great to start a API-first SaaS-application. 6 | 7 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/pixelpassion/django-rest-boilerplate) 8 | 9 | ## Features 10 | 11 | * Optimized for Python 3.7+ and Django 2.2+ 12 | * A nicely formatted [settings.py](https://docs.djangoproject.com/en/2.2/ref/settings/) 13 | * [12-Factor](12factor.net) based settings via [django-environ](12factor.net) 14 | * Based on an API build with the awesome [django-rest-framework](https://www.django-rest-framework.org) 15 | * Good considerated defaults for API [Throttling](https://www.django-rest-framework.org/api-guide/throttling/), [Pagination](https://www.django-rest-framework.org/api-guide/pagination/) and [Exception handling](https://www.django-rest-framework.org/api-guide/exceptions/) 16 | * API documentation build with [Swagger](https://swagger.io) 17 | * Optimized testing with [py.test](https://docs.pytest.org/en/latest/) & Coverage 100% 18 | * A helpful [Makefile](https://en.wikipedia.org/wiki/Make_(software)) for faster and easier deployments 19 | * Support for asynchronous task handling with [RQ](https://python-rq.org) 20 | * Email sending with [Sendgrid](https://sendgrid.com) support 21 | * Build in support for [Sentry](https://sentry.io) Error monitoring 22 | * Continuous integration with [CircleCI](https://circleci.com) 23 | * Deployment for Heroku with [Procfile](https://devcenter.heroku.com/articles/procfile), [app.json](https://devcenter.heroku.com/articles/app-json-schema), [Whitenoise](https://devcenter.heroku.com/articles/django-assets) and [Foreman](https://devcenter.heroku.com/articles/heroku-local#run-your-app-locally-using-foreman) 24 | * Docker files of [Postgres](https://www.postgresql.org) and [Redis](https://redis.io) for easier local development 25 | * Support of [Docker deployments on Heroku](https://devcenter.heroku.com/categories/deploying-with-docker) 26 | * Code formatting done with [Black](https://www.mattlayman.com/blog/2018/python-code-black/) 27 | * [Werkzeug](https://github.com/joeyespo/django-werkzeug), [iPython](https://ipython.org/install.html), [django-extensions](https://github.com/django-extensions/django-extensions), [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) and [ipdb](https://pypi.org/project/ipdb/) installed for local debugging 28 | * [Pipenv](https://github.com/pypa/pipenv) integrated with the required packages 29 | * [Django Admin](https://docs.djangoproject.com/en/2.2/ref/contrib/admin/) with some links & optimizations 30 | * Helpful [Github templates](https://help.github.com/en/articles/about-issue-and-pull-request-templates) for issue creation and pull requests 31 | 32 | ### Layout 33 | 34 | ``` 35 | django-rest-boilerplate 36 | ├── .circleci // CircleCI configuration 37 | | ├── config.yml 38 | ├── .github 39 | | ├── ISSUE_TEMPLATE 40 | | | ├── bug---problem.md // A Github template for reporting bugs 41 | | | └── feature_request.md // A Github template for posting new features 42 | | ├── PULL_REQUEST_TEMPLATE.md // A Github template layout for Pull requests 43 | ├── apps 44 | | ├── core // Django core app 45 | | ├── users // Django Users app 46 | ├── conf // Django configuration folder 47 | | ├── urls.py // The main url.py file 48 | | ├── wsgi.py // The WSGI handler 49 | | ├── settings // Django settings 50 | | | ├── base.py // General settings 51 | | | ├── local.py // Local settings 52 | | | ├── production.py // Production settings 53 | | | ├── test.py // Test settings 54 | ├── devops // Devops + Infastructure 55 | | ├── Docker // Docker container 56 | | | ├── postgres // Postgres Docker 57 | | | ├── redis // Redis Docker 58 | ├── .coveragerc 59 | ├── .env.example // Copy to .env for local development 60 | ├── .gitignore // Default .gitignore 61 | ├── .pre-commit-config.yaml 62 | ├── .prospector.yaml 63 | ├── LICENSE 64 | ├── Pipfile // Pipenv file 65 | ├── Pipfile.lock // Pipenv lock file 66 | ├── Procfile // Declaration of Heroku processes 67 | ├── README.md 68 | ├── app.json // For automated Heroku deployment 69 | ├── conftest.py 70 | ├── docker-compose.yml // Docker handling for local development 71 | ├── manage.py 72 | ├── pytest.ini 73 | ├── runtime.txt // Python version for Heroku deployment 74 | ├── setup.cfg 75 | ├── tasks.py 76 | ``` 77 | 78 | ## Documentation 79 | 80 | Read our documentation at .. 81 | 82 | ## Local setup 83 | 84 | Download & install the Docker Community edition 85 | * https://www.docker.com/community-edition 86 | 87 | Run the following commands, it will build & start the needed containers (Django, Worker, Postgres DB, Redis, Mailhog): 88 | ``` 89 | pipenv install --dev 90 | docker-compose up -d 91 | ./manage.py migrate 92 | ./manage.py runserver 93 | ``` 94 | 95 | Open your browser and go to http://localhost:8000/ 96 | 97 | ## Deployment 98 | You can deploy project to Heroku using `Deploy` button or Heroku instructions 99 | https://devcenter.heroku.com/categories/working-with-django 100 | 101 | ## Environment variables required to deploy project 102 | | Name | What is it? | Default | 103 | | --------------------- | ------------------------------ | ---------------------| 104 | | DATABASE_URL | Database's url | postgresql://${DB_USER}:${DB_PASSWORD}@localhost:${DOCKER_POSTGRES_PORT}/postgres for local, Heroku sets it automatically 105 | | REDIS_URL | Redis's url | redis://redis:${DOCKER_REDIS_PORT} for local, Heroku sets it automatically 106 | | ENV | App's environment | 107 | |SECRET_KEY|Secret key that django requires for security| Heroku sets it automatically 108 | 109 | All other envs you can find in env.example 110 | ## Install & Run Locally 111 | 112 | This project contains docker integration. You can run it with `pipenv run go-docker`. 113 | 114 | In any case before you need to write your `.env` file with correct variables: when using the dockerized 115 | local development setup, copy `.env.local`; otherwise copy `.env.example` to `.env`, and modifiy 116 | accordingly. 117 | 118 | 119 | ## Add git hooks 120 | 121 | We are using prospectr + pre-commit to make things workable and clear. Before write any code you need to install 122 | dev dependencies and run `pre-commit install -f` after that. Then whenever you run `git commit` you'll have a fancy 123 | output with checks according to our code standards. 124 | 125 | ## Prepare a new branch for your work 126 | 127 | Work on new `bug/features` will be done in a new branch (*) 128 | There is a convention in the name of the branches used: 129 | **`1-short-description-of-purpose`** 130 | 131 | Naming a Branch: 132 | - Start branch name with the Issue Number: `#1 Initial Issue` > `1-initial-branch-name` 133 | - Use lowercase only 134 | - Use dashes to separate words 135 | 136 | ## Make awesome commits 137 | 138 | Commits are the smallest portion of code that can be reviewed and has a 139 | purpose in the codebase. Each commit counts in a branch, not only the full set 140 | of changes. 141 | 142 | Please follow this guideline: 143 | https://udacity.github.io/git-styleguide/ 144 | 145 | To use cool github linking to the issue please add #taskNumber in the end. E.g.: 146 | 147 | `docs: add changes to the Readme #123` 148 | 149 | ## Documentation 150 | 151 | Please make sure that each public class, method and function has meaningful documentation which describes the purpose of the code. 152 | To make things easier to follow we use Python annotations to the public functions and method. 153 | Cheat sheet: 154 | https://mypy.readthedocs.io/en/latest/cheat_sheet_py3.html 155 | 156 | More info here: 157 | https://docs.python.org/3/library/typing.html 158 | 159 | 160 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"django-rest-boilerplate", 3 | "description":"Another Django boilerplate for API-first development, local Docker and deployments on Heroku", 4 | "keywords":[ 5 | "django", 6 | "django-rest-framework", 7 | "Heroku", 8 | "RQ", 9 | "Redis", 10 | "Docker" 11 | ], 12 | "website":"https://www.pixelpassion.io", 13 | "repository":"https://github.com/pixelpassion/django-rest-boilerplate", 14 | "env":{ 15 | "ENV":"production", 16 | "SECRET_KEY":{ 17 | "generator":"secret" 18 | } 19 | }, 20 | "formation": { 21 | "web": { 22 | "quantity": 1 23 | }, 24 | "worker": { 25 | "quantity": 1 26 | } 27 | }, 28 | "addons":[ 29 | { 30 | "plan":"heroku-postgresql:hobby-dev" 31 | }, 32 | { 33 | "plan":"heroku-redis:hobby-dev" 34 | } 35 | ], 36 | "buildpacks": [ 37 | { 38 | "url": "https://github.com/heroku/heroku-buildpack-python.git" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /apps/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/core/__init__.py -------------------------------------------------------------------------------- /apps/core/constants.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext_lazy as _ 2 | 3 | SAASY_API_KEY_NOT_ASSIGNED_MESSAGE = _( 4 | "Set the SAASY_API_KEY in the project settings for using CustomEmailBackend" 5 | ) 6 | INVALID_EMAIL_CLASS_USED_MESSAGE = _( 7 | "To use saasy you should use SaasyEmailMessage," 8 | " not standard django one. " 9 | "Make sure you specify the 'context' and 'template' arguments" 10 | " when creating the email object." 11 | ) 12 | INVALID_ARG_TYPE_MESSAGE = _('"{}" argument must be a {}') 13 | -------------------------------------------------------------------------------- /apps/core/custom_email_backend.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.mail import EmailMessage 3 | from django.core.mail.backends.base import BaseEmailBackend 4 | 5 | from saasy.client import Client 6 | 7 | from .constants import ( 8 | INVALID_ARG_TYPE_MESSAGE, 9 | INVALID_EMAIL_CLASS_USED_MESSAGE, 10 | SAASY_API_KEY_NOT_ASSIGNED_MESSAGE, 11 | ) 12 | 13 | 14 | class SaasyEmailMessage(EmailMessage): 15 | """ Added two new arguments (context, template) for working with saasy 16 | """ 17 | 18 | def __init__( 19 | self, 20 | context: dict, 21 | template: str, 22 | subject="", 23 | body="", 24 | from_email=None, 25 | to=None, 26 | bcc=None, 27 | connection=None, 28 | attachments=None, 29 | headers=None, 30 | cc=None, 31 | reply_to=None, 32 | ): 33 | super().__init__( 34 | subject, 35 | body, 36 | from_email, 37 | to, 38 | bcc, 39 | connection, 40 | attachments, 41 | headers, 42 | cc, 43 | reply_to, 44 | ) 45 | if not isinstance(context, dict): 46 | raise TypeError(INVALID_ARG_TYPE_MESSAGE.format("context", "dict")) 47 | self.context = context 48 | if not isinstance(template, str): 49 | raise TypeError(INVALID_ARG_TYPE_MESSAGE.format("template", "string")) 50 | self.template = template 51 | 52 | 53 | class CustomEmailBackend(BaseEmailBackend): 54 | """ 55 | Custom EmailBackend class for sending emails using the saasy package 56 | """ 57 | 58 | def __init__(self, api_key=None, **kwargs): 59 | super().__init__() 60 | if not settings.SAASY_API_KEY: 61 | raise ValueError(SAASY_API_KEY_NOT_ASSIGNED_MESSAGE) 62 | self.saasy = Client(auth_token=settings.SAASY_API_KEY) 63 | 64 | def send_messages(self, email_messages): 65 | if not email_messages: 66 | return None 67 | 68 | for email_message in email_messages: 69 | if not isinstance(email_message, SaasyEmailMessage): 70 | raise ValueError(INVALID_EMAIL_CLASS_USED_MESSAGE) 71 | context = email_message.context 72 | template = email_message.template 73 | recipients = email_message.recipients() 74 | if not recipients: 75 | continue 76 | 77 | for recipient in recipients: 78 | mail = self.saasy.create_mail( 79 | {"to_address": recipient, "context": context, "template": template} 80 | ) 81 | self.saasy.send_mail(mail["id"]) 82 | -------------------------------------------------------------------------------- /apps/core/custom_exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import exceptions as rest_extencions 2 | from rest_framework import status 3 | from rest_framework.response import Response 4 | from rest_framework.views import exception_handler 5 | 6 | 7 | def custom_exception_handler(exc: object, context: dict): 8 | response = exception_handler(exc, context) 9 | if response is None: 10 | response = Response( 11 | data=exc.args, 12 | content_type="application/json", 13 | status=status.HTTP_500_INTERNAL_SERVER_ERROR, 14 | ) 15 | 16 | if hasattr(response, "data"): 17 | if isinstance(exc, rest_extencions.APIException): 18 | exception_details = response.data.get("detail") 19 | messages = [] 20 | error_codes = [] 21 | if exception_details: 22 | messages.append(exception_details.__str__()) 23 | error_codes.append(exception_details.code) 24 | else: 25 | for key, value in response.data.items(): 26 | messages.append(f"{key}: {value[0].__str__()}") 27 | error_codes.append(value[0].code) 28 | response.data = {"messages": messages} 29 | response.data["error_code"] = error_codes 30 | elif isinstance(exc, Exception): 31 | response.data = {"messages": response.data} 32 | response.data["error_code"] = [exc.__class__.__name__] 33 | response.data["status_code"] = response.status_code 34 | 35 | headers = {} 36 | if isinstance(exc, rest_extencions.APIException): 37 | headers = {} 38 | if getattr(exc, "auth_header", None): 39 | headers["WWW-Authenticate"] = exc.auth_header 40 | if getattr(exc, "wait", None): 41 | headers["Retry-After"] = "%d" % exc.wait 42 | 43 | return Response(response.data, status=response.status_code, headers=headers) 44 | -------------------------------------------------------------------------------- /apps/core/email_service.py: -------------------------------------------------------------------------------- 1 | from apps.core.custom_email_backend import SaasyEmailMessage 2 | 3 | 4 | class BaseSaasyEmailService: 5 | def _send_message(self, email, template_name, context={}): 6 | email_message = SaasyEmailMessage( 7 | template=template_name, context=context, to=[email] 8 | ) 9 | email_message.send() 10 | -------------------------------------------------------------------------------- /apps/core/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/core/tests/__init__.py -------------------------------------------------------------------------------- /apps/core/tests/base_test_utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.tokens import default_token_generator 2 | from django.utils.encoding import force_bytes 3 | from django.utils.http import urlsafe_base64_encode 4 | 5 | 6 | def generate_uid_and_token(user): 7 | uuid = urlsafe_base64_encode(force_bytes(user.pk)) 8 | token = default_token_generator.make_token(user) 9 | 10 | return {"uuid": uuid, "token": token} 11 | 12 | 13 | def get_mocked_saasy_functions(mocker): 14 | mocked_create_mail_func = mocker.patch("saasy.client.Client.create_mail") 15 | mocked_create_mail_func.side_effect = lambda x: {"id": 1} 16 | mocked_send_mail_func = mocker.patch("saasy.client.Client.send_mail") 17 | 18 | return mocked_create_mail_func, mocked_send_mail_func 19 | 20 | 21 | def mock_email_backend_send_messages(mocker): 22 | return mocker.patch( 23 | f"apps.core.custom_email_backend.CustomEmailBackend.send_messages" 24 | ) 25 | -------------------------------------------------------------------------------- /apps/core/tests/test_custom_email_backend.py: -------------------------------------------------------------------------------- 1 | from django.core.mail import EmailMessage, send_mail 2 | 3 | import pytest 4 | 5 | from apps.core.constants import ( 6 | INVALID_ARG_TYPE_MESSAGE, 7 | INVALID_EMAIL_CLASS_USED_MESSAGE, 8 | SAASY_API_KEY_NOT_ASSIGNED_MESSAGE, 9 | ) 10 | from apps.core.custom_email_backend import CustomEmailBackend, SaasyEmailMessage 11 | 12 | from .base_test_utils import get_mocked_saasy_functions 13 | 14 | DEFAULT_CONTEXT = { 15 | "first_context_variable": "Hello", 16 | "second_context_variable": "World", 17 | } 18 | RECIPIENT_EMAIL = "some@mail.com" 19 | TEMPLATE_NAME = "some template" 20 | 21 | 22 | def test_custom_email_backend_correct_email_messages(mocker): 23 | mocked_create_mail_func, mocked_send_mail_func = get_mocked_saasy_functions(mocker) 24 | 25 | email_message = SaasyEmailMessage( 26 | template=TEMPLATE_NAME, context=DEFAULT_CONTEXT, to=[RECIPIENT_EMAIL] 27 | ) 28 | 29 | CustomEmailBackend().send_messages([email_message]) 30 | 31 | assert mocked_create_mail_func.call_count == 1 32 | assert mocked_send_mail_func.call_count == 1 33 | assert mocked_create_mail_func.call_args[0][0] == { 34 | "to_address": RECIPIENT_EMAIL, 35 | "context": DEFAULT_CONTEXT, 36 | "template": TEMPLATE_NAME, 37 | } 38 | assert mocked_send_mail_func.call_args[0][0] == 1 39 | 40 | 41 | def test_custom_email_backend_without_api_key(settings): 42 | settings.SAASY_API_KEY = None 43 | 44 | with pytest.raises(ValueError) as em: 45 | email_message = SaasyEmailMessage( 46 | template=TEMPLATE_NAME, context=DEFAULT_CONTEXT, to=[RECIPIENT_EMAIL] 47 | ) 48 | email_message.send() 49 | assert SAASY_API_KEY_NOT_ASSIGNED_MESSAGE == em.value.args[0] 50 | 51 | 52 | @pytest.mark.parametrize( 53 | "message_args, missing", 54 | [ 55 | [{"context": DEFAULT_CONTEXT}, "template"], 56 | [{"template": TEMPLATE_NAME}, "context"], 57 | ], 58 | ) 59 | def test_custom_email_backend_email_message_without_needed_args( 60 | mocker, message_args, missing 61 | ): 62 | mocked_create_mail_func, mocked_send_mail_func = get_mocked_saasy_functions(mocker) 63 | 64 | with pytest.raises(TypeError) as em: 65 | email_message = SaasyEmailMessage(**message_args) 66 | email_message.send() 67 | assert ( 68 | f"__init__() missing 1 required positional argument: '{missing}'" 69 | == em.value.args[0] 70 | ) 71 | 72 | assert mocked_create_mail_func.call_count == 0 73 | assert mocked_send_mail_func.call_count == 0 74 | 75 | 76 | def test_custom_backend_send_email_incorrect_function(mocker): 77 | mocked_create_mail_func, mocked_send_mail_func = get_mocked_saasy_functions(mocker) 78 | 79 | with pytest.raises(ValueError) as em: 80 | send_mail( 81 | "Subject here", 82 | "Here is the message.", 83 | "from@example.com", 84 | ["to@example.com"], 85 | ) 86 | assert INVALID_EMAIL_CLASS_USED_MESSAGE == em.value.args[0] 87 | 88 | assert mocked_create_mail_func.call_count == 0 89 | assert mocked_send_mail_func.call_count == 0 90 | 91 | 92 | def test_custom_email_backend_with_wrong_email_message_class(mocker): 93 | mocked_create_mail_func, mocked_send_mail_func = get_mocked_saasy_functions(mocker) 94 | 95 | with pytest.raises(ValueError) as em: 96 | email_message = EmailMessage(to=[RECIPIENT_EMAIL]) 97 | email_message.send() 98 | assert INVALID_EMAIL_CLASS_USED_MESSAGE == em.value.args[0] 99 | 100 | assert mocked_create_mail_func.call_count == 0 101 | assert mocked_send_mail_func.call_count == 0 102 | 103 | 104 | def test_custom_email_backend_messages_without_recipients(mocker): 105 | mocked_create_mail_func, mocked_send_mail_func = get_mocked_saasy_functions(mocker) 106 | 107 | message_with_recipients = SaasyEmailMessage( 108 | template=TEMPLATE_NAME, context=DEFAULT_CONTEXT, to=[RECIPIENT_EMAIL] 109 | ) 110 | message_without_recipients = SaasyEmailMessage( 111 | template=TEMPLATE_NAME, context=DEFAULT_CONTEXT 112 | ) 113 | 114 | CustomEmailBackend().send_messages( 115 | [message_with_recipients, message_without_recipients] 116 | ) 117 | 118 | assert mocked_create_mail_func.call_count == 1 119 | assert mocked_send_mail_func.call_count == 1 120 | 121 | 122 | @pytest.mark.parametrize("messages", [[], None, ""]) 123 | def test_custom_email_backend_messages_without_messages(mocker, messages): 124 | mocked_create_mail_func, mocked_send_mail_func = get_mocked_saasy_functions(mocker) 125 | 126 | backend_response = CustomEmailBackend().send_messages(messages) 127 | 128 | assert backend_response is None 129 | assert mocked_create_mail_func.call_count == 0 130 | assert mocked_send_mail_func.call_count == 0 131 | 132 | 133 | def test_create_saasy_email_message_invalid_arg_type(): 134 | with pytest.raises(TypeError) as em: 135 | SaasyEmailMessage(context="some_context", template=TEMPLATE_NAME) 136 | assert em.value.args[0] == INVALID_ARG_TYPE_MESSAGE.format("context", "dict") 137 | 138 | 139 | def test_create_saasy_email_message_invalid_template_arg_type(): 140 | with pytest.raises(TypeError) as em: 141 | SaasyEmailMessage(template={"hello": "world"}, context=DEFAULT_CONTEXT) 142 | assert em.value.args[0] == INVALID_ARG_TYPE_MESSAGE.format("template", "string") 143 | -------------------------------------------------------------------------------- /apps/core/tests/test_docs.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib import reload 3 | 4 | from django.urls import clear_url_caches 5 | 6 | import pytest 7 | 8 | pytestmark = pytest.mark.django_db 9 | DOCS_URL = "/docs/" 10 | 11 | 12 | @pytest.fixture 13 | def all_urlconfs(): 14 | return [ 15 | "apps.core.urls", 16 | "apps.users.urls", 17 | "conf.urls", # The ROOT_URLCONF must be last! 18 | ] 19 | 20 | 21 | @pytest.fixture 22 | def reloaded_urlconfs(all_urlconfs): 23 | def _reloaded_urlconfs(): 24 | """ 25 | Use this to ensure all urlconfs are reloaded as needed before the test. 26 | """ 27 | 28 | clear_url_caches() 29 | for urlconf in all_urlconfs: 30 | if urlconf in sys.modules: 31 | reload(sys.modules[urlconf]) 32 | 33 | return _reloaded_urlconfs 34 | 35 | 36 | def test_docs_view_public_api_doc_true(client, settings, reloaded_urlconfs): 37 | """Test docs view when PUBLIC_API_DOCUMENTATION is True.""" 38 | settings.STATICFILES_STORAGE = ( 39 | "django.contrib.staticfiles.storage.StaticFilesStorage" 40 | ) 41 | # added because swagger need statifiles to show web page 42 | settings.PUBLIC_API_DOCUMENTATION = True 43 | settings.DEBUG = False 44 | reloaded_urlconfs() 45 | response = client.get(DOCS_URL) 46 | assert response.status_code == 200 47 | 48 | 49 | def test_docs_view_debug_true(client, settings, reloaded_urlconfs): 50 | """Test docs view when DEBUG is True.""" 51 | settings.STATICFILES_STORAGE = ( 52 | "django.contrib.staticfiles.storage.StaticFilesStorage" 53 | ) 54 | # added because swagger need statifiles to show web page 55 | settings.DEBUG = True 56 | settings.PUBLIC_API_DOCUMENTATION = False 57 | reloaded_urlconfs() 58 | response = client.get(DOCS_URL) 59 | assert response.status_code == 200 60 | 61 | 62 | def test_docs_view_env_false(client, settings, reloaded_urlconfs): 63 | """Test docs view when PUBLIC_API_DOCUMENTATION is False.""" 64 | settings.PUBLIC_API_DOCUMENTATION = False 65 | settings.DEBUG = False 66 | reloaded_urlconfs() 67 | response = client.get(DOCS_URL) 68 | assert response.status_code == 404 69 | -------------------------------------------------------------------------------- /apps/core/tests/test_email_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from apps.core.email_service import BaseSaasyEmailService 4 | 5 | pytestmark = pytest.mark.django_db 6 | 7 | email_service = BaseSaasyEmailService() 8 | 9 | 10 | def test_base_email_service_send_message(user, mocker): 11 | mail_id = 1 12 | mocked_create_mail_func = mocker.patch("saasy.client.Client.create_mail") 13 | mocked_create_mail_func.side_effect = lambda x: {"id": mail_id} 14 | mocked_send_mail_func = mocker.patch("saasy.client.Client.send_mail") 15 | 16 | template_name = "test_template" 17 | 18 | email = user.email 19 | context = {"first": 1, "second": 2} 20 | 21 | email_service._send_message(email, template_name, context) 22 | assert mocked_create_mail_func.call_count == 1 23 | assert mocked_send_mail_func.call_count == 1 24 | 25 | assert mocked_create_mail_func.call_args[0][0] == { 26 | "to_address": email, 27 | "context": context, 28 | "template": template_name, 29 | } 30 | assert mocked_send_mail_func.call_args[0][0] == mail_id 31 | -------------------------------------------------------------------------------- /apps/core/tests/test_exception_handler.py: -------------------------------------------------------------------------------- 1 | from django.core import exceptions as django_extencions 2 | 3 | import pytest 4 | from rest_framework import exceptions as rest_extencions 5 | 6 | from apps.core.custom_exceptions import custom_exception_handler 7 | 8 | EXC_MESSAGE = "error_message" 9 | 10 | 11 | def check_response_data(response, status_code, error_code, exc_message): 12 | assert response.status_code == status_code 13 | assert response.data["error_code"] == [error_code] 14 | assert response.data["status_code"] == status_code 15 | response_messages = response.data["messages"] 16 | assert exc_message in response_messages 17 | 18 | 19 | def get_exception_and_context(rf, exc_class): 20 | context = {"request": rf.get("dummy")} 21 | exc = exc_class(EXC_MESSAGE) 22 | return context, exc 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "exc", 27 | [ 28 | django_extencions.FieldDoesNotExist, 29 | django_extencions.ObjectDoesNotExist, 30 | django_extencions.SuspiciousOperation, 31 | django_extencions.DisallowedHost, 32 | django_extencions.RequestDataTooBig, 33 | django_extencions.ViewDoesNotExist, 34 | django_extencions.FieldError, 35 | django_extencions.ValidationError, 36 | ], 37 | ) 38 | def test_custom_exception_handler_django_extencions(rf, exc): 39 | context, exc = get_exception_and_context(rf, exc) 40 | status_code = 500 41 | error_code = exc.__class__.__name__ 42 | 43 | response = custom_exception_handler(exc, context) 44 | check_response_data(response, status_code, error_code, EXC_MESSAGE) 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "exc", 49 | [ 50 | rest_extencions.NotFound, 51 | rest_extencions.APIException, 52 | rest_extencions.PermissionDenied, 53 | rest_extencions.AuthenticationFailed, 54 | rest_extencions.MethodNotAllowed, 55 | rest_extencions.NotAuthenticated, 56 | rest_extencions.ParseError, 57 | ], 58 | ) 59 | def test_custom_exception_handler_rest_extencions(rf, exc): 60 | context, exc = get_exception_and_context(rf, exc) 61 | status_code = exc.status_code 62 | error_code = exc.default_code 63 | 64 | message = EXC_MESSAGE 65 | if exc.__class__ == rest_extencions.MethodNotAllowed: 66 | message = f'Method "{EXC_MESSAGE}" not allowed.' 67 | 68 | response = custom_exception_handler(exc, context) 69 | check_response_data(response, status_code, error_code, message) 70 | -------------------------------------------------------------------------------- /apps/core/tests/test_poetry.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def remove_tempdir(request): 9 | """ This fixture remove temporary temprequirements.txt created for the 10 | test_requirements_circleci test. 11 | """ 12 | 13 | def fin(): 14 | os.remove("temprequirements.txt") 15 | 16 | request.addfinalizer(fin) 17 | 18 | 19 | def test_requirements_circleci(remove_tempdir): 20 | """Test that generates temprequirements.txt file from pyproject.toml and 21 | checks if we have the same requirements in our project.""" 22 | 23 | os.system("poetry export -f requirements.txt > temprequirements.txt") 24 | with open("requirements.txt", "r") as project_requirements, open( 25 | "temprequirements.txt", "r" 26 | ) as test_requirements: 27 | assert ( 28 | hashlib.md5(project_requirements.read().encode("utf-8")).hexdigest() 29 | == hashlib.md5(test_requirements.read().encode("utf-8")).hexdigest() 30 | ) 31 | -------------------------------------------------------------------------------- /apps/core/tests/test_welcome_page_view.py: -------------------------------------------------------------------------------- 1 | from apps.core.views import WelcomePageView 2 | 3 | 4 | def test_welcome_view_get_context_data(settings): 5 | """Test that get_context_data in WelcomePageView correctly returns context.""" 6 | settings.APP_NAME = "app_name" 7 | settings.SENTRY_DSN = None 8 | settings.API_URL = None 9 | settings.PUBLIC_API_DOCUMENTATION = None 10 | view = WelcomePageView() 11 | context = view.get_context_data() 12 | assert "APP_NAME" in context 13 | for name in ["API_URL", "SENTRY_DSN", "PUBLIC_API_DOCUMENTATION", "DEBUG"]: 14 | assert name not in context 15 | 16 | 17 | def test_welcome_page_view_integration_test(client, settings): 18 | """Assert that anonymous client can access WelcomePageView and that 19 | welcome.html used as a template""" 20 | settings.STATICFILES_STORAGE = ( 21 | "django.contrib.staticfiles.storage.StaticFilesStorage" 22 | ) 23 | response = client.get("") 24 | assert response.status_code == 200 25 | assert "welcome.html" in (t.name for t in response.templates) 26 | -------------------------------------------------------------------------------- /apps/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from rest_framework.routers import DefaultRouter 4 | 5 | from apps.users.constants.url_names import ( 6 | GENERATE_CODE_URL_NAME, 7 | GENERATE_TOKEN_URL_NAME, 8 | ) 9 | from apps.users.views import MyTokenObtainPairView, MyTokenObtainPairViewWithMFA 10 | 11 | router = DefaultRouter() 12 | 13 | urlpatterns = [ 14 | path("users/", include("apps.users.urls")), 15 | path("auth/login/", MyTokenObtainPairView.as_view(), name=GENERATE_CODE_URL_NAME), 16 | path( 17 | "auth/login/code/", 18 | MyTokenObtainPairViewWithMFA.as_view(), 19 | name=GENERATE_TOKEN_URL_NAME, 20 | ), 21 | path("auth/", include("trench.urls")), 22 | ] 23 | urlpatterns += router.urls 24 | -------------------------------------------------------------------------------- /apps/core/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.views.generic.base import TemplateView 3 | 4 | 5 | class WelcomePageView(TemplateView): 6 | 7 | template_name = "welcome.html" 8 | 9 | def get_context_data(self, *args, **kwargs): 10 | context = super().get_context_data(*args, **kwargs) 11 | for var_name in [ 12 | "APP_NAME", 13 | "API_URL", 14 | "SENTRY_DSN", 15 | "PUBLIC_API_DOCUMENTATION", 16 | "DEBUG", 17 | ]: 18 | variable = getattr(settings, var_name, None) 19 | if variable: 20 | context[var_name] = variable 21 | return context 22 | -------------------------------------------------------------------------------- /apps/gdpr/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/gdpr/__init__.py -------------------------------------------------------------------------------- /apps/gdpr/constants.py: -------------------------------------------------------------------------------- 1 | # templates names 2 | INACTIVE_ACCOUNT_DELETION_DONE_TEMPLATE = "INACTIVE_ACCOUNT_DELETION_DONE_TEMPLATE" 3 | INACTIVE_ACCOUNT_DELETION_WARNING_TEMPLATE = ( 4 | "INACTIVE_ACCOUNT_DELETION_WARNING_TEMPLATE" 5 | ) 6 | -------------------------------------------------------------------------------- /apps/gdpr/email_service.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from apps.core.email_service import BaseSaasyEmailService 4 | 5 | from .constants import ( 6 | INACTIVE_ACCOUNT_DELETION_DONE_TEMPLATE, 7 | INACTIVE_ACCOUNT_DELETION_WARNING_TEMPLATE, 8 | ) 9 | 10 | 11 | class GDPRSaasyEmailService(BaseSaasyEmailService): 12 | def send_inactive_account_was_deleted_email(self, user: object): 13 | settings_deletion_bcc_email = settings.INACTIVE_ACCOUNT_DELETION_BCC_EMAIL 14 | if settings_deletion_bcc_email: 15 | self._send_message( 16 | settings_deletion_bcc_email, INACTIVE_ACCOUNT_DELETION_DONE_TEMPLATE 17 | ) 18 | self._send_message(user.email, INACTIVE_ACCOUNT_DELETION_DONE_TEMPLATE) 19 | 20 | def send_warning_about_upcoming_account_deletion(self, user: object, weeks: int): 21 | settings_warning_bcc_email = settings.INACTIVE_ACCOUNT_WARNING_BCC_EMAIL 22 | context = {"WEEKS_LEFT": weeks, "PUBLIC_URL": settings.PUBLIC_URL} 23 | if settings_warning_bcc_email: 24 | self._send_message( 25 | settings_warning_bcc_email, 26 | INACTIVE_ACCOUNT_DELETION_WARNING_TEMPLATE, 27 | context, 28 | ) 29 | self._send_message( 30 | user.email, INACTIVE_ACCOUNT_DELETION_WARNING_TEMPLATE, context 31 | ) 32 | -------------------------------------------------------------------------------- /apps/gdpr/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/gdpr/management/__init__.py -------------------------------------------------------------------------------- /apps/gdpr/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/gdpr/management/commands/__init__.py -------------------------------------------------------------------------------- /apps/gdpr/management/commands/daily_tasks.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.core.management import call_command 4 | from django.core.management.base import BaseCommand 5 | 6 | 7 | class Command(BaseCommand): 8 | 9 | help = """Daily tasks""" 10 | 11 | def handle(self, **options): 12 | self.run_daily_tasks() 13 | 14 | def run_daily_tasks(self): 15 | sys.stdout("--- Running daily tasks... ---") 16 | call_command("delete_inactive_users") 17 | call_command("remove_deleted_users") 18 | call_command("send_account_info") 19 | sys.stdout("--- Done ---") 20 | -------------------------------------------------------------------------------- /apps/gdpr/management/commands/delete_inactive_users.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.conf import settings 4 | from django.core.management.base import BaseCommand 5 | from django.utils import timezone 6 | 7 | from apps.gdpr.email_service import GDPRSaasyEmailService 8 | from apps.users.models import User 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Deletes inactive users and sends warning messages" 13 | email_service = GDPRSaasyEmailService() 14 | 15 | def __init__(self): 16 | super().__init__() 17 | self.settings_deletion_weeks = settings.INACTIVE_ACCOUNT_DELETION_IN_WEEKS 18 | self.settings_warning_weeks = settings.INACTIVE_ACCOUNT_WARNING_IN_WEEKS 19 | 20 | def _get_users_for_deletion(self): 21 | return User.objects.filter( 22 | is_deleted=False, 23 | warning_sent_email=User.SECOND_WARNING_SENT, 24 | last_login__lte=timezone.now() 25 | - timedelta(weeks=self.settings_deletion_weeks), 26 | ) 27 | 28 | def _get_users_for_second_warning_email(self): 29 | return User.objects.filter( 30 | is_deleted=False, 31 | warning_sent_email=User.FIRST_WARNING_SENT, 32 | last_login__lt=timezone.now() 33 | - timedelta(weeks=self.settings_warning_weeks[0]), 34 | last_login__gt=timezone.now() 35 | - timedelta(weeks=self.settings_deletion_weeks), 36 | ) 37 | 38 | def _get_users_for_first_warning_email(self): 39 | return User.objects.filter( 40 | is_deleted=False, 41 | warning_sent_email=User.NO_WARNING, 42 | last_login__lt=timezone.now() 43 | - timedelta(weeks=self.settings_warning_weeks[0]), 44 | last_login__gt=timezone.now() 45 | - timedelta(weeks=self.settings_warning_weeks[1]), 46 | ) 47 | 48 | def run_delete_inactive_users_command(self): 49 | if self.settings_deletion_weeks: 50 | users_for_deletion = self._get_users_for_deletion() 51 | 52 | if self.settings_warning_weeks is not None: 53 | users_for_second_warning = self._get_users_for_second_warning_email() 54 | users_for_first_warning = self._get_users_for_first_warning_email() 55 | for users, weeks in { 56 | users_for_second_warning: self.settings_warning_weeks[1], 57 | users_for_first_warning: self.settings_warning_weeks[0], 58 | }.items(): 59 | for user in users: 60 | self.email_service.send_warning_about_upcoming_account_deletion( 61 | user, weeks 62 | ) 63 | users.update( 64 | warning_sent_email=User.FIRST_WARNING_SENT 65 | if weeks == self.settings_warning_weeks[0] 66 | else User.SECOND_WARNING_SENT 67 | ) 68 | for user in users_for_deletion: 69 | self.email_service.send_inactive_account_was_deleted_email(user) 70 | users_for_deletion.delete() 71 | 72 | def handle(self, *args, **options): 73 | self.run_delete_inactive_users_command() 74 | -------------------------------------------------------------------------------- /apps/gdpr/management/commands/remove_deleted_users.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.conf import settings 4 | from django.core.management.base import BaseCommand 5 | from django.utils import timezone 6 | 7 | from apps.users.email_service import UsersSaasyEmailService 8 | from apps.users.models import User 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Deletes programmatically deleted users who logged in more than a week ago." 13 | email_service = UsersSaasyEmailService() 14 | 15 | def run_remove_deleted_users_command(self): 16 | account_deletion_in_days = settings.ACCOUNT_DELETION_RETENTION_IN_DAYS 17 | if account_deletion_in_days: 18 | users = User.objects.filter( 19 | is_deleted=True, 20 | last_login__lt=timezone.now() 21 | - timedelta(days=account_deletion_in_days), 22 | ) 23 | for user in users: 24 | self.email_service.send_account_was_deleted_email(user) 25 | users.delete() 26 | 27 | def handle(self, *args, **options): 28 | self.run_remove_deleted_users_command() 29 | -------------------------------------------------------------------------------- /apps/gdpr/management/commands/send_account_info.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.conf import settings 4 | from django.core.management.base import BaseCommand 5 | from django.utils import timezone 6 | 7 | from apps.users.email_service import UsersSaasyEmailService 8 | from apps.users.models import User 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Sends a link to user data or deletes expired links" 13 | email_service = UsersSaasyEmailService() 14 | 15 | def __init__(self): 16 | super().__init__() 17 | self.settings_link_availability_days = ( 18 | settings.ACCOUNT_INFO_LINK_AVAILABILITY_IN_DAYS 19 | ) 20 | 21 | def _get_users_with_expired_link(self): 22 | return User.objects.filter( 23 | account_info_link__isnull=False, 24 | last_account_info_created__lt=timezone.now() 25 | - timedelta(days=self.settings_link_availability_days), 26 | account_info_sent__in=[True, False], 27 | ) 28 | 29 | def _get_users_for_send_account_data_email(self): 30 | return User.objects.filter( 31 | account_info_link__isnull=False, 32 | last_account_info_created__gt=timezone.now() 33 | - timedelta(days=self.settings_link_availability_days), 34 | account_info_sent=False, 35 | ) 36 | 37 | def run_send_account_info_command(self): 38 | users_with_expired_link = self._get_users_with_expired_link() 39 | users_for_send_account_data_email = ( 40 | self._get_users_for_send_account_data_email() 41 | ) 42 | 43 | for user in users_with_expired_link: 44 | user.delete_account_info_link() 45 | for user in users_for_send_account_data_email: 46 | self.email_service.send_account_info_is_ready_email(user) 47 | user.account_info_sent = True 48 | user.save(update_fields=["account_info_sent"]) 49 | 50 | def handle(self, *args, **options): 51 | self.run_send_account_info_command() 52 | -------------------------------------------------------------------------------- /apps/gdpr/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/gdpr/tests/__init__.py -------------------------------------------------------------------------------- /apps/gdpr/tests/base_test_utils.py: -------------------------------------------------------------------------------- 1 | def mock_gdpr_email_service_function(mocker, func_name): 2 | return mocker.patch(f"apps.gdpr.email_service.GDPRSaasyEmailService.{func_name}") 3 | -------------------------------------------------------------------------------- /apps/gdpr/tests/test_delete_inactive_users_command.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.management import call_command 4 | from django.utils import timezone 5 | 6 | import pytest 7 | 8 | from apps.gdpr.management.commands.delete_inactive_users import ( 9 | Command as DeleteInactiveUsersCommand, 10 | ) 11 | from apps.users.models import User 12 | 13 | from .base_test_utils import mock_gdpr_email_service_function 14 | 15 | pytestmark = pytest.mark.django_db 16 | 17 | 18 | def create_users_with_different_last_login_dates(user_factory): 19 | for weeks in [2, 4, 5, 52, 55]: 20 | if 1 < weeks < 4: 21 | warning_sent_email = User.NO_WARNING 22 | elif 4 <= weeks < 52: 23 | warning_sent_email = User.FIRST_WARNING_SENT 24 | else: 25 | warning_sent_email = User.SECOND_WARNING_SENT 26 | user_factory( 27 | is_deleted=False, 28 | last_login=timezone.now() - timedelta(weeks=weeks), 29 | warning_sent_email=warning_sent_email, 30 | ) 31 | 32 | 33 | @pytest.mark.parametrize("weeks", [0, None]) 34 | def test_delete_inactive_users_command_if_settings_deletion_weeks_is_none_or_zero( 35 | user_factory, mocker, settings, weeks 36 | ): 37 | settings.INACTIVE_ACCOUNT_DELETION_IN_WEEKS = weeks 38 | create_users_with_different_last_login_dates(user_factory) 39 | 40 | create_users_with_different_last_login_dates(user_factory) 41 | users_before = User.objects.count() 42 | 43 | mocked_warning_emails_func = mock_gdpr_email_service_function( 44 | mocker, "send_warning_about_upcoming_account_deletion" 45 | ) 46 | mocked_delete_email_func = mock_gdpr_email_service_function( 47 | mocker, "send_inactive_account_was_deleted_email" 48 | ) 49 | 50 | call_command("delete_inactive_users") 51 | assert mocked_warning_emails_func.call_count == 0 52 | assert mocked_delete_email_func.call_count == 0 53 | assert users_before == User.objects.count() 54 | 55 | 56 | def test_delete_inactive_users_command_if_settings_warning_weeks_is_none( 57 | user_factory, mocker, settings 58 | ): 59 | settings.INACTIVE_ACCOUNT_WARNING_IN_WEEKS = None 60 | 61 | create_users_with_different_last_login_dates(user_factory) 62 | users_before = User.objects.count() 63 | 64 | users_for_deletion_count = User.objects.filter( 65 | last_login__lte=timezone.now() - timedelta(weeks=52), 66 | warning_sent_email=User.SECOND_WARNING_SENT, 67 | ).count() 68 | 69 | mocked_warning_emails_func = mock_gdpr_email_service_function( 70 | mocker, "send_warning_about_upcoming_account_deletion" 71 | ) 72 | mocked_delete_email_func = mock_gdpr_email_service_function( 73 | mocker, "send_inactive_account_was_deleted_email" 74 | ) 75 | 76 | call_command("delete_inactive_users") 77 | assert mocked_warning_emails_func.call_count == 0 78 | assert mocked_delete_email_func.call_count == users_for_deletion_count 79 | assert users_before - users_for_deletion_count == User.objects.count() 80 | 81 | 82 | def test_delete_inactive_users_command_if_deletion_bcc_email_is_none( 83 | user_factory, mocker, settings 84 | ): 85 | settings.INACTIVE_ACCOUNT_DELETION_BCC_EMAIL = None 86 | 87 | mocked_warning_emails_func = mock_gdpr_email_service_function( 88 | mocker, "send_warning_about_upcoming_account_deletion" 89 | ) 90 | mocked_delete_email_func = mock_gdpr_email_service_function( 91 | mocker, "send_inactive_account_was_deleted_email" 92 | ) 93 | 94 | create_users_with_different_last_login_dates(user_factory) 95 | users_before = User.objects.count() 96 | 97 | users_for_deletion_count = User.objects.filter( 98 | last_login__lte=timezone.now() - timedelta(weeks=52), 99 | warning_sent_email=User.SECOND_WARNING_SENT, 100 | ).count() 101 | users_for_waring_count = User.objects.filter( 102 | last_login__lt=timezone.now() - timedelta(weeks=1), 103 | last_login__gt=timezone.now() - timedelta(weeks=52), 104 | ).count() 105 | 106 | call_command("delete_inactive_users") 107 | 108 | assert mocked_warning_emails_func.call_count == users_for_waring_count 109 | assert mocked_delete_email_func.call_count == users_for_deletion_count 110 | assert User.objects.count() == users_before - users_for_deletion_count 111 | 112 | 113 | def test_delete_inactive_users_command_if_warning_bcc_email_is_none( 114 | user_factory, mocker, settings 115 | ): 116 | settings.INACTIVE_ACCOUNT_WARNING_BCC_EMAIL = None 117 | 118 | mocked_warning_emails_func = mock_gdpr_email_service_function( 119 | mocker, "send_warning_about_upcoming_account_deletion" 120 | ) 121 | mocked_delete_email_func = mock_gdpr_email_service_function( 122 | mocker, "send_inactive_account_was_deleted_email" 123 | ) 124 | 125 | create_users_with_different_last_login_dates(user_factory) 126 | users_before = User.objects.count() 127 | 128 | users_for_deletion_count = User.objects.filter( 129 | last_login__lte=timezone.now() - timedelta(weeks=52), 130 | warning_sent_email=User.SECOND_WARNING_SENT, 131 | ).count() 132 | users_for_waring_count = User.objects.filter( 133 | last_login__lt=timezone.now() - timedelta(weeks=1), 134 | last_login__gt=timezone.now() - timedelta(weeks=52), 135 | ).count() 136 | 137 | call_command("delete_inactive_users") 138 | 139 | assert mocked_warning_emails_func.call_count == users_for_waring_count 140 | assert mocked_delete_email_func.call_count == users_for_deletion_count 141 | assert User.objects.count() == users_before - users_for_deletion_count 142 | 143 | 144 | def test_delete_inactive_users_command_flow(user_factory, mocker): 145 | mocked_warning_emails_func = mock_gdpr_email_service_function( 146 | mocker, "send_warning_about_upcoming_account_deletion" 147 | ) 148 | mocked_delete_email_func = mock_gdpr_email_service_function( 149 | mocker, "send_inactive_account_was_deleted_email" 150 | ) 151 | create_users_with_different_last_login_dates(user_factory) 152 | 153 | users_for_deletion_count = User.objects.filter( 154 | last_login__lte=timezone.now() - timedelta(weeks=52), 155 | warning_sent_email=User.SECOND_WARNING_SENT, 156 | ).count() 157 | users_for_waring_count = User.objects.filter( 158 | last_login__lt=timezone.now() - timedelta(weeks=1), 159 | last_login__gt=timezone.now() - timedelta(weeks=52), 160 | ).count() 161 | users_before = User.objects.count() 162 | 163 | call_command("delete_inactive_users") 164 | 165 | assert mocked_warning_emails_func.call_count == users_for_waring_count 166 | assert mocked_delete_email_func.call_count == users_for_deletion_count 167 | assert User.objects.count() == users_before - users_for_deletion_count 168 | 169 | 170 | def test_delete_inactive_users_command_deletion_email_sending(user_factory, mocker): 171 | user_factory( 172 | is_deleted=False, 173 | last_login=timezone.now() - timedelta(weeks=55), 174 | warning_sent_email=User.SECOND_WARNING_SENT, 175 | ) 176 | mocked_email_func = mock_gdpr_email_service_function( 177 | mocker, "send_inactive_account_was_deleted_email" 178 | ) 179 | call_command("delete_inactive_users") 180 | assert mocked_email_func.call_count == 1 181 | 182 | 183 | @pytest.mark.parametrize( 184 | "warning_sent_email_status", [User.FIRST_WARNING_SENT, User.NO_WARNING] 185 | ) 186 | def test_delete_inactive_users_command_wrong_warning_sent_email_status( 187 | user_factory, mocker, warning_sent_email_status 188 | ): 189 | user_factory( 190 | is_deleted=False, 191 | last_login=timezone.now() - timedelta(weeks=55), 192 | warning_sent_email=warning_sent_email_status, 193 | ) 194 | mocked_email_func = mock_gdpr_email_service_function( 195 | mocker, "send_inactive_account_was_deleted_email" 196 | ) 197 | call_command("delete_inactive_users") 198 | assert mocked_email_func.call_count == 0 199 | 200 | 201 | @pytest.mark.parametrize("weeks", [2, 4, 5]) 202 | def test_warning_inactive_users_command_warning_email_sending( 203 | user_factory, mocker, weeks 204 | ): 205 | user_factory( 206 | is_deleted=False, 207 | last_login=timezone.now() - timedelta(weeks=weeks), 208 | warning_sent_email=User.FIRST_WARNING_SENT, 209 | ) 210 | mocked_email_func = mock_gdpr_email_service_function( 211 | mocker, "send_warning_about_upcoming_account_deletion" 212 | ) 213 | call_command("delete_inactive_users") 214 | assert mocked_email_func.call_count == 1 215 | 216 | 217 | def test_warning_inactive_users_command_wrong_warning_sent_email_status( 218 | user_factory, mocker 219 | ): 220 | user_factory( 221 | is_deleted=False, 222 | last_login=timezone.now() - timedelta(weeks=4), 223 | warning_sent_email=User.SECOND_WARNING_SENT, 224 | ) 225 | mocked_email_func = mock_gdpr_email_service_function( 226 | mocker, "send_warning_about_upcoming_account_deletion" 227 | ) 228 | call_command("delete_inactive_users") 229 | assert mocked_email_func.call_count == 0 230 | 231 | 232 | def test_delete_inactive_users_command_functions(user_factory): 233 | command = DeleteInactiveUsersCommand() 234 | create_users_with_different_last_login_dates(user_factory) 235 | 236 | assert command._get_users_for_deletion().count() == 2 237 | assert command._get_users_for_second_warning_email().count() == 2 238 | assert command._get_users_for_first_warning_email().count() == 1 239 | 240 | 241 | @pytest.mark.parametrize("weeks", [1, 2, 3]) 242 | def test_sent_email_inactive_users_one_week(user_factory, weeks, mocker): 243 | mocked_email_func = mock_gdpr_email_service_function( 244 | mocker, "send_warning_about_upcoming_account_deletion" 245 | ) 246 | user = user_factory( 247 | is_deleted=False, 248 | last_login=timezone.now() - timedelta(weeks=weeks), 249 | warning_sent_email=User.NO_WARNING, 250 | ) 251 | 252 | call_command("delete_inactive_users") 253 | user.refresh_from_db() 254 | 255 | assert mocked_email_func.call_count == 1 256 | assert user.warning_sent_email == User.FIRST_WARNING_SENT 257 | 258 | 259 | @pytest.mark.parametrize("weeks", [4, 5, 50]) 260 | def test_sent_email_inactive_users_four_week(user_factory, weeks, mocker): 261 | mocked_email_func = mock_gdpr_email_service_function( 262 | mocker, "send_warning_about_upcoming_account_deletion" 263 | ) 264 | user = user_factory( 265 | is_deleted=False, 266 | last_login=timezone.now() - timedelta(weeks=weeks), 267 | warning_sent_email=User.FIRST_WARNING_SENT, 268 | ) 269 | 270 | call_command("delete_inactive_users") 271 | user.refresh_from_db() 272 | 273 | assert mocked_email_func.call_count == 1 274 | assert user.warning_sent_email == User.SECOND_WARNING_SENT 275 | 276 | 277 | @pytest.mark.parametrize("weeks", [62, 53, 54]) 278 | def test_sent_email_inactive_users_settings_week(user_factory, weeks, mocker): 279 | mocked_email_func = mock_gdpr_email_service_function( 280 | mocker, "send_inactive_account_was_deleted_email" 281 | ) 282 | user_factory( 283 | is_deleted=False, 284 | last_login=timezone.now() - timedelta(weeks=weeks), 285 | warning_sent_email=User.SECOND_WARNING_SENT, 286 | ) 287 | users_before = User.objects.count() 288 | 289 | call_command("delete_inactive_users") 290 | 291 | assert mocked_email_func.call_count == 1 292 | assert User.objects.count() == users_before - 1 293 | 294 | 295 | def test_delete_inactive_users_command_not_deleted_users(user_factory, mocker): 296 | user_factory( 297 | is_deleted=False, 298 | last_login=timezone.now() - timedelta(weeks=55), 299 | warning_sent_email=User.NO_WARNING, 300 | ) 301 | mocked_email_func = mock_gdpr_email_service_function( 302 | mocker, "send_inactive_account_was_deleted_email" 303 | ) 304 | call_command("delete_inactive_users") 305 | assert mocked_email_func.call_count == 0 306 | 307 | 308 | def test_warning_inactive_users_command_deleted_users(user_factory, mocker): 309 | user_factory( 310 | is_deleted=True, 311 | last_login=timezone.now() - timedelta(weeks=5), 312 | warning_sent_email=User.FIRST_WARNING_SENT, 313 | ) 314 | mocked_email_func = mock_gdpr_email_service_function( 315 | mocker, "send_warning_about_upcoming_account_deletion" 316 | ) 317 | call_command("delete_inactive_users") 318 | assert mocked_email_func.call_count == 0 319 | -------------------------------------------------------------------------------- /apps/gdpr/tests/test_email_service.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as dj_settings 2 | 3 | import pytest 4 | 5 | from apps.gdpr.constants import ( 6 | INACTIVE_ACCOUNT_DELETION_DONE_TEMPLATE, 7 | INACTIVE_ACCOUNT_DELETION_WARNING_TEMPLATE, 8 | ) 9 | from apps.gdpr.email_service import GDPRSaasyEmailService 10 | 11 | from .base_test_utils import mock_gdpr_email_service_function 12 | 13 | pytestmark = pytest.mark.django_db 14 | 15 | email_service = GDPRSaasyEmailService() 16 | 17 | 18 | def test_send_inactive_account_was_deleted_email(user, mocker): 19 | mocked_email_func = mock_gdpr_email_service_function(mocker, "_send_message") 20 | bcc_email = dj_settings.INACTIVE_ACCOUNT_DELETION_BCC_EMAIL 21 | 22 | email_service.send_inactive_account_was_deleted_email(user) 23 | assert mocked_email_func.call_count == 2 24 | 25 | for index, sent_message in enumerate(mocked_email_func.call_args_list): 26 | call_data = sent_message[0] 27 | assert call_data[0] == user.email if index else bcc_email 28 | assert call_data[1] == INACTIVE_ACCOUNT_DELETION_DONE_TEMPLATE 29 | assert len(call_data) == 2 30 | 31 | 32 | def test_send_inactive_account_was_deleted_email_if_deletion_bcc_email_is_none( 33 | user, mocker, settings 34 | ): 35 | settings.INACTIVE_ACCOUNT_DELETION_BCC_EMAIL = None 36 | mocked_email_func = mock_gdpr_email_service_function(mocker, "_send_message") 37 | 38 | email_service.send_inactive_account_was_deleted_email(user) 39 | assert mocked_email_func.call_count == 1 40 | 41 | call_data = mocked_email_func.call_args_list[0][0] 42 | assert call_data[0] == user.email 43 | assert call_data[1] == INACTIVE_ACCOUNT_DELETION_DONE_TEMPLATE 44 | assert len(call_data) == 2 45 | 46 | 47 | def test_send_warning_about_upcoming_account_deletion(user, mocker): 48 | mocked_email_func = mock_gdpr_email_service_function(mocker, "_send_message") 49 | 50 | weeks = 5 51 | bcc_email = dj_settings.INACTIVE_ACCOUNT_WARNING_BCC_EMAIL 52 | email_service.send_warning_about_upcoming_account_deletion(user, weeks) 53 | 54 | assert mocked_email_func.call_count == 2 55 | 56 | for index, sent_message in enumerate(mocked_email_func.call_args_list): 57 | call_data = sent_message[0] 58 | assert call_data[0] == user.email if index else bcc_email 59 | assert call_data[1] == INACTIVE_ACCOUNT_DELETION_WARNING_TEMPLATE 60 | assert call_data[2] == { 61 | "WEEKS_LEFT": weeks, 62 | "PUBLIC_URL": dj_settings.PUBLIC_URL, 63 | } 64 | 65 | 66 | def test_send_warning_about_upcoming_account_deletion_if_warning_bcc_email_is_none( 67 | user, mocker, settings 68 | ): 69 | settings.INACTIVE_ACCOUNT_WARNING_BCC_EMAIL = None 70 | mocked_email_func = mock_gdpr_email_service_function(mocker, "_send_message") 71 | 72 | weeks = 5 73 | email_service.send_warning_about_upcoming_account_deletion(user, weeks) 74 | 75 | assert mocked_email_func.call_count == 1 76 | 77 | call_data = mocked_email_func.call_args_list[0][0] 78 | assert call_data[0] == user.email 79 | assert call_data[1] == INACTIVE_ACCOUNT_DELETION_WARNING_TEMPLATE 80 | assert call_data[2] == {"WEEKS_LEFT": weeks, "PUBLIC_URL": dj_settings.PUBLIC_URL} 81 | -------------------------------------------------------------------------------- /apps/gdpr/tests/test_remove_deleted_users_command.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.management import call_command 4 | from django.utils import timezone 5 | 6 | import pytest 7 | 8 | from apps.users.models import User 9 | from apps.users.tests.base_test_utils import mock_users_email_service_function 10 | 11 | pytestmark = pytest.mark.django_db 12 | 13 | 14 | def get_users_for_tests(user_factory): 15 | # create users with different last_login dates and is_deleted values 16 | for days in range(5, 10): 17 | last_login_date = timezone.now() - timedelta(days=days) 18 | user_factory(is_deleted=False, last_login=last_login_date) 19 | user_factory.create_batch(2, is_deleted=True, last_login=last_login_date) 20 | return User.objects.all() 21 | 22 | 23 | def test_remove_deleted_users_command(user_factory, mocker): 24 | mocked_delete_email_func = mock_users_email_service_function( 25 | mocker, "send_account_was_deleted_email" 26 | ) 27 | 28 | users = get_users_for_tests(user_factory) 29 | users_before_count = users.count() 30 | 31 | users_for_deletion_count = users.filter( 32 | last_login__lt=timezone.now() - timedelta(days=7), is_deleted=True 33 | ).count() 34 | 35 | call_command("remove_deleted_users") 36 | 37 | assert mocked_delete_email_func.call_count == users_for_deletion_count 38 | assert User.objects.count() == users_before_count - users_for_deletion_count 39 | 40 | 41 | def test_remove_deleted_users_command_if_account_deletion_retention_in_days_is_zero( 42 | user_factory, mocker, settings 43 | ): 44 | settings.ACCOUNT_DELETION_RETENTION_IN_DAYS = 0 45 | mocked_delete_email_func = mock_users_email_service_function( 46 | mocker, "send_account_was_deleted_email" 47 | ) 48 | 49 | users = get_users_for_tests(user_factory) 50 | users_before_count = users.count() 51 | 52 | call_command("remove_deleted_users") 53 | 54 | assert mocked_delete_email_func.call_count == 0 55 | assert User.objects.count() == users_before_count 56 | -------------------------------------------------------------------------------- /apps/gdpr/tests/test_send_account_info_command.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import timedelta 3 | 4 | from django.core.management import call_command 5 | from django.utils import timezone 6 | 7 | import pytest 8 | 9 | from apps.gdpr.management.commands.send_account_info import ( 10 | Command as SendAccountInfoCommand, 11 | ) 12 | from apps.users.tests.base_test_utils import mock_users_email_service_function 13 | 14 | pytestmark = pytest.mark.django_db 15 | 16 | 17 | def create_users_with_different_link_data(user_factory): 18 | # create users with different last_account_info_created 19 | # dates and account_info_link, account_info_sent values 20 | users_expired_link = user_factory.create_batch( 21 | 2, 22 | account_info_link=uuid.uuid4(), 23 | last_account_info_created=timezone.now() - timedelta(days=10), 24 | account_info_sent=False, 25 | ) 26 | users_expired_link_sent_data = user_factory.create_batch( 27 | 3, 28 | account_info_link=uuid.uuid4(), 29 | last_account_info_created=timezone.now() - timedelta(days=10), 30 | account_info_sent=True, 31 | ) 32 | users_valid_link = user_factory.create_batch( 33 | 4, 34 | account_info_link=uuid.uuid4(), 35 | last_account_info_created=timezone.now(), 36 | account_info_sent=False, 37 | ) 38 | user_factory.create_batch( 39 | 5, 40 | account_info_link=uuid.uuid4(), 41 | last_account_info_created=timezone.now(), 42 | account_info_sent=True, 43 | ) # users with sent data 44 | 45 | return users_expired_link, users_expired_link_sent_data, users_valid_link 46 | 47 | 48 | def test_send_account_info_command(user_factory, mocker): 49 | mocked_delete_email_func = mock_users_email_service_function( 50 | mocker, "send_account_info_is_ready_email" 51 | ) 52 | ( 53 | users_expired_link, 54 | users_expired_link_sent_data, 55 | users_valid_link, 56 | ) = create_users_with_different_link_data(user_factory) 57 | users_for_link_deletion = users_expired_link + users_expired_link_sent_data 58 | 59 | call_command("send_account_info") 60 | 61 | assert mocked_delete_email_func.call_count == len(users_valid_link) 62 | 63 | for user in users_for_link_deletion: 64 | user.refresh_from_db() 65 | assert user.account_info_link is None 66 | assert user.last_account_info_created is None 67 | assert not user.account_info_sent 68 | 69 | for user in users_valid_link: 70 | user.refresh_from_db() 71 | assert user.account_info_link is not None 72 | assert user.last_account_info_created is not None 73 | assert user.account_info_sent 74 | 75 | 76 | def test_send_account_info_command_functions(user_factory): 77 | command = SendAccountInfoCommand() 78 | ( 79 | users_expired_link, 80 | users_expired_link_sent_data, 81 | users_valid_link, 82 | ) = create_users_with_different_link_data(user_factory) 83 | 84 | users_for_link_deletion_count = len(users_expired_link) + len( 85 | users_expired_link_sent_data 86 | ) 87 | assert ( 88 | command._get_users_with_expired_link().count() == users_for_link_deletion_count 89 | ) 90 | assert command._get_users_for_send_account_data_email().count() == len( 91 | users_valid_link 92 | ) 93 | -------------------------------------------------------------------------------- /apps/gdpr/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from apps.gdpr.utils import account_info_handler 4 | 5 | pytestmark = pytest.mark.django_db 6 | 7 | 8 | def test_account_info_handler(user): 9 | needed_data = { 10 | "email": user.email, 11 | "username": user.username, 12 | "first_name": user.first_name, 13 | "last_name": user.last_name, 14 | "privacy_policy": user.privacy_policy, 15 | "warning_sent_email": user.warning_sent_email, 16 | "account_info_link": user.account_info_link, 17 | "last_account_info_created": None, 18 | "is_staff": user.is_staff, 19 | "is_active": user.is_active, 20 | "date_joined": user.date_joined.strftime("%d/%m/%Y %H:%m:%S"), 21 | "last_login": None, 22 | "last_password_change_date": user.last_password_change_date.strftime( 23 | "%d/%m/%Y %H:%m:%S" 24 | ), 25 | } 26 | assert account_info_handler(user) == needed_data 27 | -------------------------------------------------------------------------------- /apps/gdpr/utils.py: -------------------------------------------------------------------------------- 1 | def change_date_format(date): 2 | return date.strftime("%d/%m/%Y %H:%m:%S") 3 | 4 | 5 | def account_info_handler(user): 6 | 7 | return { 8 | "email": user.email, 9 | "username": user.username, 10 | "first_name": user.first_name, 11 | "last_name": user.last_name, 12 | "privacy_policy": user.privacy_policy, 13 | "warning_sent_email": user.warning_sent_email, 14 | "account_info_link": user.account_info_link, 15 | "last_account_info_created": change_date_format(user.last_account_info_created) 16 | if user.last_account_info_created 17 | else None, 18 | "is_staff": user.is_staff, 19 | "is_active": user.is_active, 20 | "date_joined": change_date_format(user.date_joined) 21 | if user.date_joined 22 | else None, 23 | "last_login": change_date_format(user.last_login) if user.last_login else None, 24 | "last_password_change_date": change_date_format(user.last_password_change_date) 25 | if user.last_password_change_date 26 | else None, 27 | } 28 | -------------------------------------------------------------------------------- /apps/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/users/__init__.py -------------------------------------------------------------------------------- /apps/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /apps/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UsersConfig(AppConfig): 5 | name = "users" 6 | -------------------------------------------------------------------------------- /apps/users/constants/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/users/constants/__init__.py -------------------------------------------------------------------------------- /apps/users/constants/messages.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext as _ 2 | 3 | BLANK_FIELD_MESSAGE = _("This field may not be blank.") 4 | UNIQUE_EMAIL_MESSAGE = _("Email must be unique.") 5 | EXPIRED_LINK_MESSAGE = _("Link Expired") 6 | REQUIRED_FLAG_MESSAGE = _("This flag is required.") 7 | VALID_BOOLEAN_FIELD_MESSAGE = _("Must be a valid boolean.") 8 | INVALID_TOKEN_MESSAGE = _("Invalid or expired token.") 9 | NO_USER_IN_REQUEST_MESSAGE = _("No user in the request.") 10 | NO_REQUEST_IN_CONTEXT_MESSAGE = _("No request in the context.") 11 | USER_ALREADY_DELETED_MESSAGE = _("User was already deleted") 12 | USER_WILL_BE_DELETED_MESSAGE = _("User will be deleted soon.") 13 | USER_ACCOUNT_INFO_HAS_ALREADY_BEEN_SENT = _( 14 | "User account information has already been sent." 15 | ) 16 | -------------------------------------------------------------------------------- /apps/users/constants/template_names.py: -------------------------------------------------------------------------------- 1 | # templates names 2 | ACCOUNT_WAS_RECOVERED_EMAIL_TEMPLATE = "ACCOUNT_WAS_RECOVERED_EMAIL_TEMPLATE" 3 | USER_PASSWORD_RESET_EMAIL_TEMPLATE = "USER_PASSWORD_RESET_EMAIL_TEMPLATE" # noqa 4 | USER_ACCOUNT_VERIFICATION_EMAIL_TEMPLATE = "USER_ACCOUNT_VERIFICATION_EMAIL_TEMPLATE" 5 | ACCOUNT_SCHEDULED_FOR_DELETION_TEMPLATE_NAME = ( 6 | "ACCOUNT_SCHEDULED_FOR_DELETION_TEMPLATE_NAME" 7 | ) 8 | ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE = "ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE" 9 | ACCOUNT_INFO_ASKED_FOR_TEMPLATE = "ACCOUNT_INFO_ASKED_FOR_TEMPLATE" 10 | ACCOUNT_INFO_IS_READY_TEMPLATE = "ACCOUNT_INFO_IS_READY_TEMPLATE" 11 | -------------------------------------------------------------------------------- /apps/users/constants/url_names.py: -------------------------------------------------------------------------------- 1 | USER_API_URL_NAME = "user_me" 2 | USER_REGISTRATION_URL_NAME = "user_register" 3 | LOGOUT_URL_NAME = "rest_logout" 4 | PASS_RESET_URL_NAME = "rest_password_reset" 5 | PASS_RESET_CONFIRM_URL_NAME = "rest_password_reset_confirm" 6 | CHANGE_PASS_URL_NAME = "rest_password_change" 7 | TOKEN_REFRESH_URL_NAME = "token_refresh" 8 | TOKEN_VERIFY_URL_NAME = "token_verify" 9 | CREATE_USER_DATA_LINK_URL_NAME = "create_user_data_link" 10 | GET_USER_DATA_URL_NAME = "get_user_data" 11 | GENERATE_CODE_URL_NAME = "generate-code" 12 | GENERATE_TOKEN_URL_NAME = "generate-token" 13 | -------------------------------------------------------------------------------- /apps/users/email_service.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.tokens import default_token_generator 3 | from django.utils.encoding import force_bytes 4 | from django.utils.http import urlsafe_base64_encode 5 | 6 | from apps.core.email_service import BaseSaasyEmailService 7 | 8 | from .constants.template_names import ( 9 | ACCOUNT_INFO_ASKED_FOR_TEMPLATE, 10 | ACCOUNT_INFO_IS_READY_TEMPLATE, 11 | ACCOUNT_SCHEDULED_FOR_DELETION_TEMPLATE_NAME, 12 | ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE, 13 | ACCOUNT_WAS_RECOVERED_EMAIL_TEMPLATE, 14 | USER_ACCOUNT_VERIFICATION_EMAIL_TEMPLATE, 15 | USER_PASSWORD_RESET_EMAIL_TEMPLATE, 16 | ) 17 | 18 | 19 | class UsersSaasyEmailService(BaseSaasyEmailService): 20 | def send_account_was_deleted_email(self, user: object): 21 | settings_deleted_bcc_email = settings.ACCOUNT_DELETED_BCC_EMAIL 22 | if settings_deleted_bcc_email: 23 | self._send_message( 24 | settings_deleted_bcc_email, ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE 25 | ) 26 | self._send_message(user.email, ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE) 27 | 28 | def send_account_was_recovered_email(self, user: object): 29 | self._send_message(user.email, ACCOUNT_WAS_RECOVERED_EMAIL_TEMPLATE) 30 | 31 | def send_reset_password_email(self, user: object): 32 | uuid = urlsafe_base64_encode(force_bytes(user.pk)) 33 | token = default_token_generator.make_token(user) 34 | 35 | context = {"PUBLIC_URL": settings.PUBLIC_URL, "UUID": uuid, "TOKEN": token} 36 | self._send_message(user.email, USER_PASSWORD_RESET_EMAIL_TEMPLATE, context) 37 | 38 | def send_user_account_activation_email(self, user: object): 39 | # TODO: change context 40 | context = {"PUBLIC_URL": settings.PUBLIC_URL} 41 | self._send_message( 42 | user.email, USER_ACCOUNT_VERIFICATION_EMAIL_TEMPLATE, context 43 | ) 44 | 45 | def send_account_scheduled_for_deletion_email(self, user: object): 46 | settings_account_scheduled_bcc_email = ( 47 | settings.ACCOUNT_SCHEDULED_FOR_DELETION_BCC_EMAIL 48 | ) 49 | settings_account_deletion_in_days = settings.ACCOUNT_DELETION_RETENTION_IN_DAYS 50 | if settings_account_deletion_in_days: 51 | if settings_account_scheduled_bcc_email: 52 | self._send_message( 53 | settings_account_scheduled_bcc_email, 54 | ACCOUNT_SCHEDULED_FOR_DELETION_TEMPLATE_NAME, 55 | ) 56 | self._send_message(user.email, ACCOUNT_SCHEDULED_FOR_DELETION_TEMPLATE_NAME) 57 | 58 | def send_account_info_asked_for_email(self, user: object): 59 | settings_account_info_asked_for_email = settings.ACCOUNT_INFO_ASKED_FOR_EMAIL 60 | if settings_account_info_asked_for_email: 61 | self._send_message( 62 | settings_account_info_asked_for_email, ACCOUNT_INFO_ASKED_FOR_TEMPLATE 63 | ) 64 | self._send_message(user.email, ACCOUNT_INFO_ASKED_FOR_TEMPLATE) 65 | 66 | def send_account_info_is_ready_email(self, user: object): 67 | context = { 68 | "PUBLIC_URL": settings.PUBLIC_URL, 69 | "ACCOUNT_INFO_LINK": str(user.account_info_link), 70 | "ACCOUNT_INFO_LINK_AVAILABILITY_IN_DAYS": ( 71 | settings.ACCOUNT_INFO_LINK_AVAILABILITY_IN_DAYS 72 | ), 73 | "GDPR_SUPPORT_EMAIL": settings.GDPR_SUPPORT_EMAIL, 74 | } 75 | self._send_message(user.email, ACCOUNT_INFO_IS_READY_TEMPLATE, context) 76 | -------------------------------------------------------------------------------- /apps/users/forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm 2 | from django.utils import timezone 3 | 4 | from .email_service import UsersSaasyEmailService 5 | from .models import User 6 | 7 | 8 | class CustomPasswordResetForm(PasswordResetForm): 9 | """ 10 | Default form was customized to create an RefreshToken object 11 | during saving and update templates 12 | """ 13 | 14 | def save( 15 | self, 16 | domain_override=None, 17 | subject_template_name=None, 18 | email_template_name=None, 19 | use_https=False, 20 | token_generator=None, 21 | from_email=None, 22 | request=None, 23 | html_email_template_name=None, 24 | extra_email_context=None, 25 | ): 26 | """ 27 | Generate a one-use only link for resetting password and send it to the 28 | user. 29 | """ 30 | user = User.objects.filter( 31 | email=self.cleaned_data["email"], is_active=True 32 | ).first() 33 | if user: 34 | UsersSaasyEmailService().send_reset_password_email(user) 35 | 36 | 37 | class CustomSetPasswordForm(SetPasswordForm): 38 | def save(self, commit=True): 39 | super().save(commit) 40 | self.user.last_password_change_date = timezone.now() 41 | self.user.save() 42 | return self.user 43 | -------------------------------------------------------------------------------- /apps/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-05 15:30 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | import django.utils.timezone 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [("auth", "0011_update_proxy_permissions")] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="User", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("password", models.CharField(max_length=128, verbose_name="password")), 29 | ( 30 | "last_login", 31 | models.DateTimeField( 32 | blank=True, null=True, verbose_name="last login" 33 | ), 34 | ), 35 | ( 36 | "is_superuser", 37 | models.BooleanField( 38 | default=False, 39 | help_text="Designates that this user has all permissions without explicitly assigning them.", 40 | verbose_name="superuser status", 41 | ), 42 | ), 43 | ( 44 | "username", 45 | models.CharField( 46 | error_messages={ 47 | "unique": "A user with that username already exists." 48 | }, 49 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 50 | max_length=150, 51 | unique=True, 52 | validators=[ 53 | django.contrib.auth.validators.UnicodeUsernameValidator() 54 | ], 55 | verbose_name="username", 56 | ), 57 | ), 58 | ( 59 | "first_name", 60 | models.CharField( 61 | blank=True, max_length=30, verbose_name="first name" 62 | ), 63 | ), 64 | ( 65 | "last_name", 66 | models.CharField( 67 | blank=True, max_length=150, verbose_name="last name" 68 | ), 69 | ), 70 | ( 71 | "is_staff", 72 | models.BooleanField( 73 | default=False, 74 | help_text="Designates whether the user can log into this admin site.", 75 | verbose_name="staff status", 76 | ), 77 | ), 78 | ( 79 | "is_active", 80 | models.BooleanField( 81 | default=True, 82 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 83 | verbose_name="active", 84 | ), 85 | ), 86 | ( 87 | "date_joined", 88 | models.DateTimeField( 89 | default=django.utils.timezone.now, verbose_name="date joined" 90 | ), 91 | ), 92 | ( 93 | "email", 94 | models.EmailField( 95 | max_length=254, unique=True, verbose_name="Email address" 96 | ), 97 | ), 98 | ( 99 | "groups", 100 | models.ManyToManyField( 101 | blank=True, 102 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 103 | related_name="user_set", 104 | related_query_name="user", 105 | to="auth.Group", 106 | verbose_name="groups", 107 | ), 108 | ), 109 | ( 110 | "user_permissions", 111 | models.ManyToManyField( 112 | blank=True, 113 | help_text="Specific permissions for this user.", 114 | related_name="user_set", 115 | related_query_name="user", 116 | to="auth.Permission", 117 | verbose_name="user permissions", 118 | ), 119 | ), 120 | ], 121 | options={ 122 | "verbose_name": "User", 123 | "verbose_name_plural": "Users", 124 | "ordering": ["date_joined"], 125 | }, 126 | managers=[("objects", django.contrib.auth.models.UserManager())], 127 | ) 128 | ] 129 | -------------------------------------------------------------------------------- /apps/users/migrations/0002_user_auth_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.9 on 2020-01-30 15:22 2 | 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("users", "0001_initial")] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="user", 15 | name="privacy_policy", 16 | field=models.BooleanField( 17 | default=True, verbose_name="Privacy policy accepted" 18 | ), 19 | ), 20 | migrations.AddField( 21 | model_name="user", 22 | name="security_hash", 23 | field=models.UUIDField( 24 | default=uuid.uuid4, unique=True, verbose_name="Security hash" 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /apps/users/migrations/0003_user_gdpr_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-02-12 15:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("users", "0002_user_auth_fields")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="user", 13 | name="account_info_link", 14 | field=models.UUIDField( 15 | blank=True, null=True, verbose_name="Account info link" 16 | ), 17 | ), 18 | migrations.AddField( 19 | model_name="user", 20 | name="account_info_sent", 21 | field=models.BooleanField( 22 | default=False, verbose_name="Account info has been sent" 23 | ), 24 | ), 25 | migrations.AddField( 26 | model_name="user", 27 | name="is_deleted", 28 | field=models.BooleanField(default=False, verbose_name="Deleted"), 29 | ), 30 | migrations.AddField( 31 | model_name="user", 32 | name="last_account_info_created", 33 | field=models.DateTimeField( 34 | blank=True, null=True, verbose_name="Account info link created date" 35 | ), 36 | ), 37 | migrations.AddField( 38 | model_name="user", 39 | name="warning_sent_email", 40 | field=models.CharField( 41 | choices=[ 42 | ("no_warnings", "No warnings"), 43 | ("first_warning_sent", "First warning sent"), 44 | ("second_warning_sent", "Second warning sent"), 45 | ], 46 | default="no_warnings", 47 | max_length=256, 48 | verbose_name="Warning email", 49 | ), 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /apps/users/migrations/0004_user_last_password_change_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-02-13 15:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("users", "0003_user_gdpr_fields")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="user", 13 | name="last_password_change_date", 14 | field=models.DateTimeField( 15 | blank=True, null=True, verbose_name="Last password change date" 16 | ), 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /apps/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/users/migrations/__init__.py -------------------------------------------------------------------------------- /apps/users/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import timedelta 3 | 4 | from django.conf import settings 5 | from django.contrib.auth.models import AbstractUser 6 | from django.core.exceptions import ValidationError 7 | from django.db import models 8 | from django.utils import timezone 9 | from django.utils.translation import gettext as _ 10 | 11 | from .constants.messages import ( 12 | USER_ALREADY_DELETED_MESSAGE, 13 | USER_WILL_BE_DELETED_MESSAGE, 14 | ) 15 | 16 | 17 | class User(AbstractUser): 18 | """ 19 | Set User model. 20 | 21 | This model is inherited from default user model. 22 | """ 23 | 24 | NO_WARNING, FIRST_WARNING_SENT, SECOND_WARNING_SENT = ( 25 | "no_warnings", 26 | "first_warning_sent", 27 | "second_warning_sent", 28 | ) 29 | WARNING_CHOICES = ( 30 | (NO_WARNING, _("No warnings")), 31 | (FIRST_WARNING_SENT, _("First warning sent")), 32 | (SECOND_WARNING_SENT, _("Second warning sent")), 33 | ) 34 | 35 | email = models.EmailField(_("Email address"), unique=True) 36 | privacy_policy = models.BooleanField(_("Privacy policy accepted"), default=True) 37 | security_hash = models.UUIDField( 38 | _("Security hash"), default=uuid.uuid4, unique=True 39 | ) 40 | is_deleted = models.BooleanField(_("Deleted"), default=False) 41 | warning_sent_email = models.CharField( 42 | verbose_name=_("Warning email"), 43 | choices=WARNING_CHOICES, 44 | max_length=256, 45 | default=NO_WARNING, 46 | ) 47 | account_info_link = models.UUIDField(_("Account info link"), blank=True, null=True) 48 | last_account_info_created = models.DateTimeField( 49 | _("Account info link created date"), blank=True, null=True 50 | ) 51 | account_info_sent = models.BooleanField( 52 | _("Account info has been sent"), default=False 53 | ) 54 | last_password_change_date = models.DateTimeField( 55 | _("Last password change date"), blank=True, null=True 56 | ) 57 | 58 | USERNAME_FIELD = "email" 59 | REQUIRED_FIELDS = ["username"] 60 | 61 | class Meta: 62 | """ 63 | Meta class of Users model. 64 | 65 | This class set verbose_name and verbose_name_plural. 66 | """ 67 | 68 | ordering = ["date_joined"] 69 | verbose_name = _("User") 70 | verbose_name_plural = _("Users") 71 | app_label = "users" 72 | 73 | def soft_delete_user(self): 74 | if self.is_deleted: 75 | raise ValidationError(USER_ALREADY_DELETED_MESSAGE) 76 | self.is_deleted = True 77 | self.last_login = timezone.now() 78 | self.save(update_fields=["is_deleted", "last_login"]) 79 | 80 | def soft_undelete_user(self): 81 | if self.last_login > timezone.now() - timedelta( 82 | days=settings.ACCOUNT_DELETION_RETENTION_IN_DAYS 83 | ): 84 | self.is_deleted = False 85 | self.save() 86 | else: 87 | raise ValidationError(USER_WILL_BE_DELETED_MESSAGE) 88 | 89 | def create_account_info_link(self): 90 | self.account_info_link = uuid.uuid4() 91 | self.last_account_info_created = timezone.now() 92 | self.save(update_fields=["account_info_link", "last_account_info_created"]) 93 | 94 | def delete_account_info_link(self): 95 | self.account_info_link = None 96 | self.last_account_info_created = None 97 | self.account_info_sent = False 98 | self.save( 99 | update_fields=[ 100 | "account_info_link", 101 | "last_account_info_created", 102 | "account_info_sent", 103 | ] 104 | ) 105 | 106 | def set_password(self, raw_password): 107 | super().set_password(raw_password) 108 | self.last_password_change_date = timezone.now() 109 | 110 | def __str__(self): 111 | """ 112 | __str__ method. 113 | 114 | This method return annotation of object. 115 | """ 116 | return self.email 117 | -------------------------------------------------------------------------------- /apps/users/serializers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.forms import PasswordChangeForm 3 | from django.contrib.auth.password_validation import validate_password 4 | from django.contrib.auth.tokens import default_token_generator 5 | from django.utils.encoding import force_text 6 | from django.utils.http import urlsafe_base64_decode as uid_decoder 7 | 8 | from rest_auth.serializers import PasswordResetSerializer 9 | from rest_framework import serializers 10 | from rest_framework_simplejwt.serializers import ( 11 | TokenObtainPairSerializer, 12 | TokenRefreshSerializer, 13 | TokenVerifySerializer, 14 | ) 15 | from rest_framework_simplejwt.tokens import RefreshToken, UntypedToken 16 | 17 | from .constants.messages import ( 18 | EXPIRED_LINK_MESSAGE, 19 | INVALID_TOKEN_MESSAGE, 20 | NO_REQUEST_IN_CONTEXT_MESSAGE, 21 | NO_USER_IN_REQUEST_MESSAGE, 22 | REQUIRED_FLAG_MESSAGE, 23 | UNIQUE_EMAIL_MESSAGE, 24 | ) 25 | from .forms import CustomPasswordResetForm, CustomSetPasswordForm 26 | from .models import User 27 | 28 | 29 | class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): 30 | """ CustomTokenObtainPairSerializer is designed to add the user security_hash 31 | to token attributes and return additional data with token data 32 | """ 33 | 34 | @classmethod 35 | def get_token(cls, user): 36 | token = super().get_token(user) 37 | token["security_hash"] = str(user.security_hash) 38 | return token 39 | 40 | 41 | def validate_token_by_security_hash(token: object): 42 | """ Сhecks if the user security_hash is equal to the security_hash from the token 43 | """ 44 | user = User.objects.get(id=token["user_id"]) 45 | if str(user.security_hash) != token["security_hash"]: 46 | raise serializers.ValidationError(INVALID_TOKEN_MESSAGE) 47 | return 48 | 49 | 50 | class CustomTokenVerifySerializer(TokenVerifySerializer): 51 | """ CustomTokenVerifySerializer is designed to configure the validate 52 | method and verify the token by user security_hash 53 | """ 54 | 55 | def validate(self, attrs): 56 | token = UntypedToken(attrs["token"]) 57 | validate_token_by_security_hash(token) 58 | return {} 59 | 60 | 61 | class CustomTokenRefreshSerializer(TokenRefreshSerializer): 62 | """ CustomTokenRefreshSerializer is designed to configure the validate 63 | method and verify the token by user security_hash 64 | """ 65 | 66 | def validate(self, attrs): 67 | data = super().validate(attrs) 68 | refresh = RefreshToken(attrs["refresh"]) 69 | validate_token_by_security_hash(refresh) 70 | return data 71 | 72 | 73 | class CustomPasswordResetSerializer(PasswordResetSerializer): 74 | """ 75 | Default serializer was customized to change form class 76 | """ 77 | 78 | password_reset_form_class = CustomPasswordResetForm 79 | 80 | 81 | class CustomPasswordResetConfirmSerializer(serializers.Serializer): 82 | """ 83 | Serializer for requesting a password reset e-mail. 84 | """ 85 | 86 | new_password = serializers.CharField(max_length=128) 87 | uid = serializers.CharField() 88 | token = serializers.CharField() 89 | 90 | set_password_form_class = CustomSetPasswordForm 91 | 92 | def validate(self, attrs): 93 | self._errors = {} 94 | 95 | # Decode the uidb64 to uid to get User object 96 | try: 97 | uid = force_text(uid_decoder(attrs["uid"])) 98 | self.user = User._default_manager.get(pk=uid) 99 | except (TypeError, ValueError, OverflowError, User.DoesNotExist): 100 | raise serializers.ValidationError(EXPIRED_LINK_MESSAGE) 101 | 102 | # Construct SetPasswordForm instance 103 | self.set_password_form = self.set_password_form_class( 104 | user=self.user, 105 | data={ 106 | "new_password1": attrs["new_password"], 107 | "new_password2": attrs["new_password"], 108 | }, 109 | ) 110 | if not self.set_password_form.is_valid(): 111 | raise serializers.ValidationError(self.set_password_form.errors) 112 | if not default_token_generator.check_token(self.user, attrs["token"]): 113 | raise serializers.ValidationError(EXPIRED_LINK_MESSAGE) 114 | return attrs 115 | 116 | def save(self): 117 | return self.set_password_form.save() 118 | 119 | 120 | class BaseUserSerializer(serializers.ModelSerializer): 121 | email = serializers.EmailField(max_length=100) 122 | 123 | class Meta: 124 | model = User 125 | fields = ["email"] 126 | 127 | def validate_email(self, data: str) -> str: 128 | data = data.lower() 129 | if User.objects.filter(email=data).exists(): 130 | raise serializers.ValidationError(UNIQUE_EMAIL_MESSAGE) 131 | return data 132 | 133 | 134 | class UserDetailSerializer(BaseUserSerializer): 135 | first_name = serializers.CharField(max_length=256, required=False) 136 | last_name = serializers.CharField(max_length=256, required=False) 137 | email = serializers.EmailField(read_only=True) 138 | admin_url = serializers.SerializerMethodField() 139 | 140 | class Meta(BaseUserSerializer.Meta): 141 | fields = BaseUserSerializer.Meta.fields + [ 142 | "first_name", 143 | "last_name", 144 | "admin_url", 145 | "last_password_change_date", 146 | ] 147 | 148 | def get_admin_url(self, instance): 149 | if instance.is_staff or instance.is_superuser: 150 | return settings.ADMIN_URL 151 | return None 152 | 153 | 154 | class UserRegistrationSerializer(BaseUserSerializer): 155 | """ User registration serializer 156 | """ 157 | 158 | access = serializers.SerializerMethodField( 159 | read_only=True, method_name="get_access_token" 160 | ) 161 | refresh = serializers.SerializerMethodField( 162 | read_only=True, method_name="get_refresh_token" 163 | ) 164 | first_name = serializers.CharField(max_length=256, required=True) 165 | last_name = serializers.CharField(max_length=256, required=True) 166 | password = serializers.CharField(write_only=True) 167 | privacy_policy = serializers.BooleanField(required=True, write_only=True) 168 | 169 | class Meta(BaseUserSerializer.Meta): 170 | fields = BaseUserSerializer.Meta.fields + [ 171 | "access", 172 | "refresh", 173 | "first_name", 174 | "last_name", 175 | "password", 176 | "privacy_policy", 177 | ] 178 | 179 | def get_access_token(self, user: User) -> str: 180 | refresh_token = RefreshToken.for_user(user) 181 | return str(refresh_token.access_token) 182 | 183 | def get_refresh_token(self, user: User) -> str: 184 | refresh_token = RefreshToken.for_user(user) 185 | return str(refresh_token) 186 | 187 | def validate_privacy_policy(self, data: bool) -> bool: 188 | if not data: 189 | raise serializers.ValidationError(REQUIRED_FLAG_MESSAGE) 190 | return data 191 | 192 | def validate_password(self, data: str) -> str: 193 | validate_password(data) 194 | return data 195 | 196 | def create(self, validated_data): 197 | email = validated_data.get("email") 198 | user = User.objects.create( 199 | privacy_policy=validated_data.get("privacy_policy"), 200 | first_name=validated_data.get("first_name"), 201 | last_name=validated_data.get("last_name"), 202 | email=email, 203 | username=email, 204 | is_active=False, 205 | ) 206 | user.set_password(validated_data.get("password")) 207 | user.save() 208 | return user 209 | 210 | 211 | class CustomPasswordChangeSerializer(serializers.Serializer): 212 | """ Customized serializer for changing user password 213 | """ 214 | 215 | old_password = serializers.CharField(max_length=128) 216 | new_password = serializers.CharField(max_length=128) 217 | 218 | set_password_form_class = PasswordChangeForm 219 | 220 | def __init__(self, *args, **kwargs): 221 | super(CustomPasswordChangeSerializer, self).__init__(*args, **kwargs) 222 | 223 | self.request = self.context.get("request") 224 | if self.request: 225 | self.user = getattr(self.request, "user", None) 226 | if not self.user: 227 | raise serializers.ValidationError(NO_USER_IN_REQUEST_MESSAGE) 228 | else: 229 | raise serializers.ValidationError(NO_REQUEST_IN_CONTEXT_MESSAGE) 230 | 231 | def validate(self, attrs): 232 | self.set_password_form = self.set_password_form_class( 233 | user=self.user, 234 | data={ 235 | "old_password": attrs["old_password"], 236 | "new_password1": attrs["new_password"], 237 | "new_password2": attrs["new_password"], 238 | }, 239 | ) 240 | 241 | if not self.set_password_form.is_valid(): 242 | raise serializers.ValidationError(self.set_password_form.errors) 243 | return attrs 244 | 245 | def save(self): 246 | self.set_password_form.save() 247 | -------------------------------------------------------------------------------- /apps/users/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/apps/users/tests/__init__.py -------------------------------------------------------------------------------- /apps/users/tests/base_test_utils.py: -------------------------------------------------------------------------------- 1 | def mock_users_email_service_function(mocker, func_name): 2 | return mocker.patch(f"apps.users.email_service.UsersSaasyEmailService.{func_name}") 3 | -------------------------------------------------------------------------------- /apps/users/tests/constants.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | from apps.users.constants.url_names import ( 4 | CHANGE_PASS_URL_NAME, 5 | CREATE_USER_DATA_LINK_URL_NAME, 6 | GENERATE_CODE_URL_NAME, 7 | GENERATE_TOKEN_URL_NAME, 8 | LOGOUT_URL_NAME, 9 | PASS_RESET_CONFIRM_URL_NAME, 10 | PASS_RESET_URL_NAME, 11 | TOKEN_REFRESH_URL_NAME, 12 | TOKEN_VERIFY_URL_NAME, 13 | USER_API_URL_NAME, 14 | USER_REGISTRATION_URL_NAME, 15 | ) 16 | 17 | # urls 18 | USER_REGISTRATION_URL = reverse(f"v0:{USER_REGISTRATION_URL_NAME}") 19 | USER_API_URL = reverse(f"v0:{USER_API_URL_NAME}") 20 | LOGOUT_URL = reverse(f"v0:{LOGOUT_URL_NAME}") 21 | PASS_RESET_URL = reverse(f"v0:{PASS_RESET_URL_NAME}") 22 | PASS_RESET_CONFIRM_URL = reverse(f"v0:{PASS_RESET_CONFIRM_URL_NAME}") 23 | CHANGE_PASS_URL = reverse(f"v0:{CHANGE_PASS_URL_NAME}") 24 | TOKEN_REFRESH_URL = reverse(f"v0:{TOKEN_REFRESH_URL_NAME}") 25 | TOKEN_VERIFY_URL = reverse(f"v0:{TOKEN_VERIFY_URL_NAME}") 26 | CREATE_USER_DATA_LINK_URL = reverse(f"v0:{CREATE_USER_DATA_LINK_URL_NAME}") 27 | GENERATE_CODE_URL = reverse(f"v0:{GENERATE_CODE_URL_NAME}") 28 | GENERATE_TOKEN_URL = reverse(f"v0:{GENERATE_TOKEN_URL_NAME}") 29 | 30 | 31 | TEST_EMAIL = "new_user@email.com" 32 | TEST_PASSWORD = "!TestPassword1" # noqa 33 | NEW_TEST_PASSWORD = "!TestPassword2" # noqa 34 | 35 | CORRECT_REG_DATA = { 36 | "first_name": "New", 37 | "last_name": "User", 38 | "email": TEST_EMAIL, 39 | "password": TEST_PASSWORD, 40 | "privacy_policy": True, 41 | } 42 | -------------------------------------------------------------------------------- /apps/users/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from apps.users.models import User 4 | 5 | from .constants import TEST_PASSWORD 6 | 7 | 8 | class UserFactory(factory.django.DjangoModelFactory): 9 | class Meta: 10 | model = User 11 | 12 | email = factory.Sequence(lambda n: "email{:03}@mail.com".format(n)) 13 | username = factory.LazyAttribute(lambda user: f"{user.email}") 14 | first_name = factory.Faker("first_name") 15 | last_name = factory.Faker("last_name") 16 | privacy_policy = True 17 | 18 | @factory.post_generation 19 | def post(obj, create, extracted, **kwargs): # noqa 20 | obj.set_password(TEST_PASSWORD) 21 | obj.save() 22 | -------------------------------------------------------------------------------- /apps/users/tests/test_change_user_password.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .constants import CHANGE_PASS_URL, NEW_TEST_PASSWORD, TEST_PASSWORD 4 | 5 | pytestmark = pytest.mark.django_db 6 | 7 | 8 | def test_change_password_page_anon_user(client): 9 | response = client.post(CHANGE_PASS_URL) 10 | assert response.status_code == 401 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "payload", 15 | [ 16 | {"old_password": "123123", "new_password": "123123"}, 17 | {"old_password": "", "new_password": ""}, 18 | {"old_password": "newpass2", "new_password": "newpass20"}, 19 | {"old_password": "dummy", "new_password": ""}, 20 | {"old_password": "dummy", "new_password": NEW_TEST_PASSWORD}, 21 | ], 22 | ) 23 | def test_change_password_invalid_data(user, logged_in_client, payload): 24 | response = logged_in_client.post( 25 | CHANGE_PASS_URL, payload, content_type="application/json" 26 | ) 27 | 28 | user.refresh_from_db() 29 | assert response.status_code == 400 30 | assert not user.check_password(NEW_TEST_PASSWORD) 31 | 32 | 33 | def test_change_password_valid_data(user, logged_in_client): 34 | old_password_change_date = user.last_password_change_date 35 | payload = {"old_password": TEST_PASSWORD, "new_password": NEW_TEST_PASSWORD} 36 | response = logged_in_client.post( 37 | CHANGE_PASS_URL, payload, content_type="application/json" 38 | ) 39 | user.refresh_from_db() 40 | assert response.status_code == 200 41 | assert user.check_password(NEW_TEST_PASSWORD) 42 | assert user.last_password_change_date != old_password_change_date 43 | -------------------------------------------------------------------------------- /apps/users/tests/test_delete_user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from apps.users.constants.template_names import ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE 6 | from apps.users.models import User 7 | 8 | from .base_test_utils import mock_users_email_service_function 9 | from .constants import GENERATE_CODE_URL, TEST_PASSWORD, USER_API_URL 10 | 11 | pytestmark = pytest.mark.django_db 12 | 13 | 14 | def test_user_deletion_deleted_status(logged_in_client, user, mocker): 15 | mock_users_email_service_function( 16 | mocker, "send_account_scheduled_for_deletion_email" 17 | ) 18 | assert not user.is_deleted 19 | user_count_before = User.objects.count() 20 | 21 | response = logged_in_client.delete(USER_API_URL) 22 | assert response.status_code == 204 23 | assert User.objects.count() == user_count_before 24 | 25 | # test user data 26 | user.refresh_from_db() 27 | assert user.is_deleted 28 | 29 | 30 | def test_user_deletion_if_retention_in_days_is_zero( 31 | logged_in_client, user, mocker, settings 32 | ): 33 | settings.ACCOUNT_DELETION_RETENTION_IN_DAYS = 0 34 | mock_users_email_service_function(mocker, "send_account_was_deleted_email") 35 | assert not user.is_deleted 36 | user_count_before = User.objects.count() 37 | 38 | response = logged_in_client.delete(USER_API_URL) 39 | 40 | assert response.status_code == 204 41 | assert User.objects.count() == user_count_before - 1 42 | 43 | 44 | def test_user_deletion_if_retention_in_days_is_zero_send_mail( 45 | logged_in_client, user, mocker, settings 46 | ): 47 | settings.ACCOUNT_DELETION_RETENTION_IN_DAYS = 0 48 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 49 | assert not user.is_deleted 50 | 51 | response = logged_in_client.delete(USER_API_URL) 52 | assert response.status_code == 204 53 | 54 | # test mail 55 | assert mocked_email_func.call_count == 2 56 | call_data = mocked_email_func.call_args[0] 57 | assert call_data[0] == user.email 58 | assert call_data[1] == ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE 59 | 60 | 61 | def test_user_deletion_send_email(logged_in_client, mocker): 62 | mocked_email_func = mock_users_email_service_function( 63 | mocker, "send_account_scheduled_for_deletion_email" 64 | ) 65 | 66 | response = logged_in_client.delete(USER_API_URL) 67 | assert response.status_code == 204 68 | 69 | assert mocked_email_func.call_count == 1 70 | 71 | 72 | def test_change_deleted_status_if_user_logged_in(client, user, mocker): 73 | mock_users_email_service_function(mocker, "send_account_was_recovered_email") 74 | user.is_deleted = True 75 | user.last_login = datetime.today() 76 | user.save() 77 | 78 | response = client.post( 79 | GENERATE_CODE_URL, {"email": user.email, "password": TEST_PASSWORD} 80 | ) 81 | assert response.status_code == 200 82 | 83 | # test user data 84 | user.refresh_from_db() 85 | assert not user.is_deleted 86 | 87 | 88 | def test_send_email_deleted_user_logged_in(client, user, mocker): 89 | mocked_email_func = mock_users_email_service_function( 90 | mocker, "send_account_was_recovered_email" 91 | ) 92 | user.is_deleted = True 93 | user.last_login = datetime.today() 94 | user.save() 95 | 96 | response = client.post( 97 | GENERATE_CODE_URL, {"email": user.email, "password": TEST_PASSWORD} 98 | ) 99 | 100 | assert response.status_code == 200 101 | assert mocked_email_func.call_count == 1 102 | -------------------------------------------------------------------------------- /apps/users/tests/test_email_service.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as dj_settings 2 | 3 | import pytest 4 | 5 | from apps.users.constants.template_names import ( 6 | ACCOUNT_INFO_ASKED_FOR_TEMPLATE, 7 | ACCOUNT_INFO_IS_READY_TEMPLATE, 8 | ACCOUNT_SCHEDULED_FOR_DELETION_TEMPLATE_NAME, 9 | ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE, 10 | ACCOUNT_WAS_RECOVERED_EMAIL_TEMPLATE, 11 | USER_ACCOUNT_VERIFICATION_EMAIL_TEMPLATE, 12 | USER_PASSWORD_RESET_EMAIL_TEMPLATE, 13 | ) 14 | from apps.users.email_service import UsersSaasyEmailService 15 | 16 | from .base_test_utils import mock_users_email_service_function 17 | 18 | pytestmark = pytest.mark.django_db 19 | 20 | email_service = UsersSaasyEmailService() 21 | 22 | 23 | def test_send_account_was_deleted_email(user, mocker): 24 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 25 | bcc_email = dj_settings.ACCOUNT_DELETED_BCC_EMAIL 26 | 27 | user.is_deleted = True 28 | user.save() 29 | 30 | email_service.send_account_was_deleted_email(user) 31 | assert mocked_email_func.call_count == 2 32 | 33 | for index, sent_message in enumerate(mocked_email_func.call_args_list): 34 | call_data = sent_message[0] 35 | assert call_data[0] == user.email if index else bcc_email 36 | assert call_data[1] == ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE 37 | assert len(call_data) == 2 38 | 39 | 40 | def test_send_account_was_deleted_email_if_deleted_bcc_email_is_none( 41 | user, mocker, settings 42 | ): 43 | settings.ACCOUNT_DELETED_BCC_EMAIL = None 44 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 45 | 46 | user.is_deleted = True 47 | user.save() 48 | 49 | email_service.send_account_was_deleted_email(user) 50 | assert mocked_email_func.call_count == 1 51 | 52 | call_data = mocked_email_func.call_args_list[0][0] 53 | assert call_data[0] == user.email 54 | assert call_data[1] == ACCOUNT_WAS_DELETED_EMAIL_TEMPLATE 55 | assert len(call_data) == 2 56 | 57 | 58 | def test_send_account_was_recovered_email(user, mocker): 59 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 60 | 61 | email_service.send_account_was_recovered_email(user) 62 | assert mocked_email_func.call_count == 1 63 | 64 | call_data = mocked_email_func.call_args[0] 65 | assert call_data[0] == user.email 66 | assert call_data[1] == ACCOUNT_WAS_RECOVERED_EMAIL_TEMPLATE 67 | assert len(call_data) == 2 68 | 69 | 70 | def test_send_reset_password_email(user, mocker): 71 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 72 | 73 | email_service.send_reset_password_email(user) 74 | assert mocked_email_func.call_count == 1 75 | 76 | call_data = mocked_email_func.call_args[0] 77 | assert call_data[0] == user.email 78 | assert call_data[1] == USER_PASSWORD_RESET_EMAIL_TEMPLATE 79 | assert "PUBLIC_URL" in call_data[2] 80 | assert "UUID" in call_data[2] 81 | assert "TOKEN" in call_data[2] 82 | 83 | 84 | def test_send_user_account_activation_email(user, mocker): 85 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 86 | 87 | email_service.send_user_account_activation_email(user) 88 | assert mocked_email_func.call_count == 1 89 | 90 | call_data = mocked_email_func.call_args[0] 91 | assert call_data[0] == user.email 92 | assert call_data[1] == USER_ACCOUNT_VERIFICATION_EMAIL_TEMPLATE 93 | assert call_data[2] == {"PUBLIC_URL": dj_settings.PUBLIC_URL} 94 | 95 | 96 | def test_send_account_scheduled_for_deletion_email(user, mocker): 97 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 98 | bcc_email = dj_settings.ACCOUNT_SCHEDULED_FOR_DELETION_BCC_EMAIL 99 | 100 | email_service.send_account_scheduled_for_deletion_email(user) 101 | assert mocked_email_func.call_count == 2 102 | 103 | for index, sent_message in enumerate(mocked_email_func.call_args_list): 104 | call_data = sent_message[0] 105 | assert call_data[0] == user.email if index else bcc_email 106 | assert call_data[1] == ACCOUNT_SCHEDULED_FOR_DELETION_TEMPLATE_NAME 107 | assert len(call_data) == 2 108 | 109 | 110 | def test_send_account_scheduled_for_deletion_email_if_scheduled_bcc_email_is_none( 111 | user, mocker, settings 112 | ): 113 | settings.ACCOUNT_SCHEDULED_FOR_DELETION_BCC_EMAIL = None 114 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 115 | 116 | email_service.send_account_scheduled_for_deletion_email(user) 117 | assert mocked_email_func.call_count == 1 118 | 119 | call_data = mocked_email_func.call_args_list[0][0] 120 | assert call_data[0] == user.email 121 | assert call_data[1] == ACCOUNT_SCHEDULED_FOR_DELETION_TEMPLATE_NAME 122 | assert len(call_data) == 2 123 | 124 | 125 | @pytest.mark.parametrize("in_days_value", [0, None]) 126 | def test_send_account_scheduled_for_deletion_email_if_deletion_retention( 127 | user, mocker, settings, in_days_value 128 | ): 129 | settings.ACCOUNT_DELETION_RETENTION_IN_DAYS = in_days_value 130 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 131 | 132 | email_service.send_account_scheduled_for_deletion_email(user) 133 | assert mocked_email_func.call_count == 0 134 | 135 | 136 | def test_send_account_info_asked_for_email(user, mocker): 137 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 138 | bcc_email = dj_settings.ACCOUNT_INFO_ASKED_FOR_EMAIL 139 | 140 | email_service.send_account_info_asked_for_email(user) 141 | assert mocked_email_func.call_count == 2 142 | 143 | for index, sent_message in enumerate(mocked_email_func.call_args_list): 144 | call_data = sent_message[0] 145 | assert call_data[0] == user.email if index else bcc_email 146 | assert call_data[1] == ACCOUNT_INFO_ASKED_FOR_TEMPLATE 147 | assert len(call_data) == 2 148 | 149 | 150 | def test_send_account_info_is_ready_email(user, mocker): 151 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 152 | user.create_account_info_link() 153 | 154 | email_service.send_account_info_is_ready_email(user) 155 | assert mocked_email_func.call_count == 1 156 | 157 | call_data = mocked_email_func.call_args[0] 158 | assert call_data[0] == user.email 159 | assert call_data[1] == ACCOUNT_INFO_IS_READY_TEMPLATE 160 | assert call_data[2] == { 161 | "PUBLIC_URL": dj_settings.PUBLIC_URL, 162 | "ACCOUNT_INFO_LINK": str(user.account_info_link), 163 | "ACCOUNT_INFO_LINK_AVAILABILITY_IN_DAYS": ( 164 | dj_settings.ACCOUNT_INFO_LINK_AVAILABILITY_IN_DAYS 165 | ), 166 | "GDPR_SUPPORT_EMAIL": dj_settings.GDPR_SUPPORT_EMAIL, 167 | } 168 | 169 | 170 | def test_send_account_info_asked_for_email_if_settings_email_is_none( 171 | user, mocker, settings 172 | ): 173 | settings.ACCOUNT_INFO_ASKED_FOR_EMAIL = None 174 | mocked_email_func = mock_users_email_service_function(mocker, "_send_message") 175 | 176 | email_service.send_account_info_asked_for_email(user) 177 | assert mocked_email_func.call_count == 1 178 | 179 | call_data = mocked_email_func.call_args_list[0][0] 180 | assert call_data[0] == user.email 181 | assert call_data[1] == ACCOUNT_INFO_ASKED_FOR_TEMPLATE 182 | assert len(call_data) == 2 183 | -------------------------------------------------------------------------------- /apps/users/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from apps.core.tests.base_test_utils import mock_email_backend_send_messages 4 | from apps.users.forms import CustomPasswordResetForm, CustomSetPasswordForm 5 | 6 | from .constants import NEW_TEST_PASSWORD 7 | 8 | pytestmark = pytest.mark.django_db 9 | 10 | 11 | def test_password_reset_form_with_valid_email(user, mocker): 12 | mocked_email_func = mock_email_backend_send_messages(mocker) 13 | form_data = {"email": user.email} 14 | form = CustomPasswordResetForm(data=form_data) 15 | 16 | assert form.is_valid() 17 | form.save() 18 | assert mocked_email_func.call_count == 1 19 | 20 | 21 | def test_password_reset_form_with_invalid_email(user, mocker): 22 | mocked_email_func = mock_email_backend_send_messages(mocker) 23 | form_data = {"email": "invalid@mail.com"} 24 | form = CustomPasswordResetForm(data=form_data) 25 | 26 | assert form.is_valid() 27 | form.save() 28 | assert mocked_email_func.call_count == 0 29 | 30 | 31 | def test_password_reset_form_without_email(): 32 | form_data = {"email": ""} 33 | form = CustomPasswordResetForm(data=form_data) 34 | 35 | assert not form.is_valid() 36 | assert form.errors["email"][0] == "This field is required." 37 | 38 | 39 | def test_password_set_form_with_last_password_change_date(user): 40 | user_old_last_password_change_date = user.last_password_change_date 41 | form_data = {"new_password1": NEW_TEST_PASSWORD, "new_password2": NEW_TEST_PASSWORD} 42 | assert not user.check_password(NEW_TEST_PASSWORD) 43 | form = CustomSetPasswordForm(data=form_data, user=user) 44 | 45 | assert form.is_valid() 46 | form.save() 47 | user.refresh_from_db() 48 | assert user_old_last_password_change_date != user.last_password_change_date 49 | assert user.check_password(NEW_TEST_PASSWORD) 50 | -------------------------------------------------------------------------------- /apps/users/tests/test_jwt_token.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from apps.users.constants.messages import INVALID_TOKEN_MESSAGE 6 | 7 | from .constants import ( 8 | GENERATE_CODE_URL, 9 | TEST_PASSWORD, 10 | TOKEN_REFRESH_URL, 11 | TOKEN_VERIFY_URL, 12 | USER_API_URL, 13 | ) 14 | 15 | pytestmark = pytest.mark.django_db 16 | 17 | 18 | def test_change_security_hash(user, client): 19 | # get valid token 20 | payload = {"email": user.email, "password": TEST_PASSWORD} 21 | response = client.post(GENERATE_CODE_URL, payload) 22 | assert response.status_code == 200 23 | access = response.data.get("access") 24 | refresh = response.data.get("refresh") 25 | assert access 26 | assert refresh 27 | 28 | # change user security hash 29 | user.security_hash = uuid.uuid4() 30 | user.save(update_fields=["security_hash"]) 31 | 32 | # verify token 33 | response = client.post(TOKEN_VERIFY_URL, {"token": access}) 34 | assert response.status_code == 400 35 | assert response.data["messages"][0] == f"non_field_errors: {INVALID_TOKEN_MESSAGE}" 36 | 37 | # check if the user has access to the url for auth users 38 | client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access}" 39 | assert client.get(USER_API_URL).status_code == 401 # url for auth users 40 | 41 | 42 | def test_refresh_token_after_security_hash_change(user, client): 43 | # get valid token 44 | payload = {"email": user.email, "password": TEST_PASSWORD} 45 | response = client.post(GENERATE_CODE_URL, payload) 46 | assert response.status_code == 200 47 | access = response.data.get("access") 48 | refresh = response.data.get("refresh") 49 | assert access 50 | assert refresh 51 | 52 | # set token to the header and chenage security hash 53 | client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access}" 54 | user.security_hash = uuid.uuid4() 55 | user.save(update_fields=["security_hash"]) 56 | 57 | # verify token 58 | response = client.post(TOKEN_VERIFY_URL, {"token": access}) 59 | assert response.status_code == 400 60 | assert response.data["messages"][0] == f"non_field_errors: {INVALID_TOKEN_MESSAGE}" 61 | 62 | # refresh token 63 | response = client.post(TOKEN_REFRESH_URL, {"refresh": refresh}) 64 | assert response.status_code == 400 65 | assert response.data["messages"][0] == f"non_field_errors: {INVALID_TOKEN_MESSAGE}" 66 | -------------------------------------------------------------------------------- /apps/users/tests/test_registration.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | import pytest 4 | 5 | from apps.core.tests.base_test_utils import ( 6 | get_mocked_saasy_functions, 7 | mock_email_backend_send_messages, 8 | ) 9 | from apps.users.constants.messages import ( 10 | BLANK_FIELD_MESSAGE, 11 | REQUIRED_FLAG_MESSAGE, 12 | UNIQUE_EMAIL_MESSAGE, 13 | VALID_BOOLEAN_FIELD_MESSAGE, 14 | ) 15 | from apps.users.models import User 16 | 17 | from .constants import CORRECT_REG_DATA, TOKEN_VERIFY_URL, USER_REGISTRATION_URL 18 | 19 | pytestmark = pytest.mark.django_db 20 | 21 | 22 | def test_normal_registration(client, mocker): 23 | mocked_create_mail_func, mocked_send_mail_func = get_mocked_saasy_functions(mocker) 24 | users_before = User.objects.count() 25 | 26 | response = client.post(USER_REGISTRATION_URL, CORRECT_REG_DATA, format="json") 27 | assert response.status_code == 201 28 | assert User.objects.count() == users_before + 1 29 | 30 | assert mocked_create_mail_func.call_count == 1 31 | assert mocked_send_mail_func.call_count == 1 32 | 33 | 34 | def test_normal_registration_returned_data(client, mocker): 35 | mock_email_backend_send_messages(mocker) 36 | response = client.post(USER_REGISTRATION_URL, CORRECT_REG_DATA, format="json") 37 | assert response.status_code == 201 38 | 39 | user = User.objects.latest("id") 40 | 41 | assert set(["first_name", "last_name", "email", "access", "refresh"]) == set( 42 | [field for field, value in response.data.items()] 43 | ) 44 | 45 | for field, value in response.data.items(): 46 | if field not in ["access", "refresh"]: 47 | if field == "full_name": 48 | assert value == user.get_full_name() 49 | else: 50 | assert getattr(user, field) == value 51 | 52 | # check access token 53 | response = client.post(TOKEN_VERIFY_URL, {"token": response.data["access"]}) 54 | 55 | 56 | def test_registration_unaccepted_privacy_policy(client): 57 | field = "privacy_policy" 58 | post_data = deepcopy(CORRECT_REG_DATA) 59 | post_data[field] = False 60 | 61 | response = client.post(USER_REGISTRATION_URL, post_data, format="json") 62 | assert response.status_code == 400 63 | assert response.data["messages"][0] == f"{field}: {REQUIRED_FLAG_MESSAGE}" 64 | 65 | 66 | @pytest.mark.parametrize( 67 | "empty_field,error_message", 68 | [ 69 | ["first_name", BLANK_FIELD_MESSAGE], 70 | ["last_name", BLANK_FIELD_MESSAGE], 71 | ["email", BLANK_FIELD_MESSAGE], 72 | ["password", BLANK_FIELD_MESSAGE], 73 | ["privacy_policy", VALID_BOOLEAN_FIELD_MESSAGE], 74 | ], 75 | ) 76 | def test_registration_required_fields(client, empty_field, error_message): 77 | post_data = deepcopy(CORRECT_REG_DATA) 78 | post_data[empty_field] = "" 79 | 80 | response = client.post(USER_REGISTRATION_URL, post_data, format="json") 81 | assert response.status_code == 400 82 | assert response.data["messages"][0] == f"{empty_field}: {error_message}" 83 | 84 | 85 | def test_registration_dublicated_email(client, user): 86 | post_data = deepcopy(CORRECT_REG_DATA) 87 | post_data["email"] = user.email 88 | users_before = User.objects.count() 89 | 90 | response = client.post(USER_REGISTRATION_URL, post_data, format="json") 91 | assert response.status_code == 400 92 | assert User.objects.count() == users_before 93 | assert response.data["messages"][0] == f"email: {UNIQUE_EMAIL_MESSAGE}" 94 | -------------------------------------------------------------------------------- /apps/users/tests/test_reset_user_password.py: -------------------------------------------------------------------------------- 1 | from django.utils.encoding import force_text 2 | 3 | import pytest 4 | 5 | from apps.core.tests.base_test_utils import ( 6 | generate_uid_and_token, 7 | mock_email_backend_send_messages, 8 | ) 9 | from apps.users.constants.messages import EXPIRED_LINK_MESSAGE 10 | 11 | from .constants import NEW_TEST_PASSWORD, PASS_RESET_CONFIRM_URL, PASS_RESET_URL 12 | 13 | pytestmark = pytest.mark.django_db 14 | 15 | 16 | def test_password_reset_with_invalid_email(client, mocker): 17 | mocked_email_func = mock_email_backend_send_messages(mocker) 18 | 19 | post_data = {"email": "wrong_email@mail.com"} 20 | response = client.post(PASS_RESET_URL, post_data) 21 | 22 | assert mocked_email_func.call_count == 0 23 | assert response.status_code == 200 24 | 25 | 26 | def test_password_reset_with_valid_email(user, client, mocker): 27 | mocked_email_func = mock_email_backend_send_messages(mocker) 28 | 29 | post_data = {"email": user.email} 30 | response = client.post(PASS_RESET_URL, post_data) 31 | 32 | assert response.status_code == 200 33 | assert mocked_email_func.call_count == 1 34 | 35 | 36 | def test_password_set_with_valid_password(user, client): 37 | old_password_change_date = user.last_password_change_date 38 | url_kwargs = generate_uid_and_token(user) 39 | 40 | post_data = { 41 | "new_password": NEW_TEST_PASSWORD, 42 | "uid": force_text(url_kwargs["uuid"]), 43 | "token": url_kwargs["token"], 44 | } 45 | 46 | response = client.post(PASS_RESET_CONFIRM_URL, post_data, format="json") 47 | user.refresh_from_db() 48 | 49 | assert response.status_code == 200 50 | assert user.check_password(NEW_TEST_PASSWORD) 51 | assert user.last_password_change_date != old_password_change_date 52 | 53 | 54 | def test_password_set_with_invalid_uid_and_token(user, client): 55 | post_data = { 56 | "new_password": NEW_TEST_PASSWORD, 57 | "uid": "invalid", 58 | "token": "invalid", 59 | } 60 | 61 | response = client.post(PASS_RESET_CONFIRM_URL, post_data, format="json") 62 | user.refresh_from_db() 63 | 64 | assert response.status_code == 400 65 | assert response.data["messages"][0] == f"non_field_errors: {EXPIRED_LINK_MESSAGE}" 66 | assert not user.check_password(NEW_TEST_PASSWORD) 67 | -------------------------------------------------------------------------------- /apps/users/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework.exceptions import ValidationError 3 | 4 | from apps.users.constants.messages import ( 5 | NO_REQUEST_IN_CONTEXT_MESSAGE, 6 | NO_USER_IN_REQUEST_MESSAGE, 7 | ) 8 | from apps.users.serializers import CustomPasswordChangeSerializer 9 | 10 | 11 | def test_pass_change_setializer_without_request(): 12 | with pytest.raises(ValidationError) as em: 13 | CustomPasswordChangeSerializer(context={}) 14 | assert NO_REQUEST_IN_CONTEXT_MESSAGE == em.value.args[0] 15 | 16 | 17 | def test_pass_change_setializer_without_user_in_request(rf): 18 | request = rf.get("DUMMY") 19 | assert not getattr(request, "user", None) 20 | with pytest.raises(ValidationError) as em: 21 | CustomPasswordChangeSerializer(context={"request": request}) 22 | assert NO_USER_IN_REQUEST_MESSAGE == em.value.args[0] 23 | -------------------------------------------------------------------------------- /apps/users/tests/test_user_info_link.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import timedelta 3 | 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | 7 | import pytest 8 | 9 | from apps.gdpr.utils import account_info_handler 10 | from apps.users.constants.messages import USER_ACCOUNT_INFO_HAS_ALREADY_BEEN_SENT 11 | from apps.users.constants.url_names import GET_USER_DATA_URL_NAME 12 | 13 | from .base_test_utils import mock_users_email_service_function 14 | from .constants import CREATE_USER_DATA_LINK_URL 15 | 16 | pytestmark = pytest.mark.django_db 17 | 18 | 19 | def test_create_user_info_link_anon_user(client): 20 | response = client.post(CREATE_USER_DATA_LINK_URL) 21 | assert response.status_code == 401 22 | 23 | 24 | def test_create_user_info_link_auth_user(logged_in_client, user, mocker): 25 | mocked_asked_for_email_func = mock_users_email_service_function( 26 | mocker, "send_account_info_asked_for_email" 27 | ) 28 | mocked_account_info_is_ready_email_func = mock_users_email_service_function( 29 | mocker, "send_account_info_is_ready_email" 30 | ) 31 | 32 | assert user.account_info_link is None 33 | assert user.last_account_info_created is None 34 | assert not user.account_info_sent 35 | 36 | response = logged_in_client.post(CREATE_USER_DATA_LINK_URL) 37 | assert response.status_code == 201 38 | 39 | user.refresh_from_db() 40 | assert user.account_info_link is not None 41 | assert user.last_account_info_created is not None 42 | assert user.account_info_sent 43 | 44 | assert mocked_asked_for_email_func.call_count == 1 45 | assert mocked_account_info_is_ready_email_func.call_count == 1 46 | 47 | 48 | def test_create_user_info_user_data_already_sended(logged_in_client, user, mocker): 49 | mocked_asked_for_email_func = mock_users_email_service_function( 50 | mocker, "send_account_info_asked_for_email" 51 | ) 52 | mocked_account_info_is_ready_email_func = mock_users_email_service_function( 53 | mocker, "send_account_info_is_ready_email" 54 | ) 55 | 56 | user.account_info_link = uuid.uuid4() 57 | user.last_account_info_created = timezone.now() 58 | user.account_info_sent = True 59 | user.save() 60 | 61 | response = logged_in_client.post(CREATE_USER_DATA_LINK_URL) 62 | assert response.status_code == 400 63 | assert response.data == USER_ACCOUNT_INFO_HAS_ALREADY_BEEN_SENT 64 | 65 | user.refresh_from_db() 66 | assert user.account_info_link is not None 67 | assert user.last_account_info_created is not None 68 | assert user.account_info_sent 69 | 70 | assert mocked_asked_for_email_func.call_count == 0 71 | assert mocked_account_info_is_ready_email_func.call_count == 0 72 | 73 | 74 | def test_create_user_info_link_auth_user_info_automated_is_false( 75 | logged_in_client, user, mocker, settings 76 | ): 77 | settings.ACCOUNT_INFO_AUTOMATED = False 78 | mocked_asked_for_email_func = mock_users_email_service_function( 79 | mocker, "send_account_info_asked_for_email" 80 | ) 81 | mocked_account_info_is_ready_email_func = mock_users_email_service_function( 82 | mocker, "send_account_info_is_ready_email" 83 | ) 84 | 85 | assert user.account_info_link is None 86 | assert user.last_account_info_created is None 87 | assert not user.account_info_sent 88 | 89 | response = logged_in_client.post(CREATE_USER_DATA_LINK_URL) 90 | assert response.status_code == 201 91 | 92 | user.refresh_from_db() 93 | assert user.account_info_link is None 94 | assert user.last_account_info_created is None 95 | assert not user.account_info_sent 96 | 97 | assert mocked_asked_for_email_func.call_count == 1 98 | assert mocked_account_info_is_ready_email_func.call_count == 0 99 | 100 | 101 | def test_get_user_info_link_anon_user(client): 102 | response = client.post(CREATE_USER_DATA_LINK_URL, args=["some_hash"]) 103 | assert response.status_code == 401 104 | 105 | 106 | def test_get_user_info_link_auth_user(logged_in_client, user): 107 | user.account_info_link = uuid.uuid4() 108 | user.last_account_info_created = timezone.now() 109 | user.save() 110 | 111 | user.refresh_from_db() 112 | 113 | response = logged_in_client.get( 114 | reverse(f"v0:{GET_USER_DATA_URL_NAME}", args=[str(user.account_info_link)]) 115 | ) 116 | assert response.status_code == 200 117 | assert response.data == account_info_handler(user) 118 | 119 | 120 | def test_get_user_info_link_auth_user_expired_link(logged_in_client, user): 121 | user.account_info_link = uuid.uuid4() 122 | user.last_account_info_created = timezone.now() - timedelta(days=8) 123 | user.save() 124 | 125 | user.refresh_from_db() 126 | 127 | response = logged_in_client.get( 128 | reverse(f"v0:{GET_USER_DATA_URL_NAME}", args=[str(user.account_info_link)]) 129 | ) 130 | assert response.status_code == 404 131 | 132 | 133 | def test_get_user_info_link_auth_user_fake_link(logged_in_client, user, settings): 134 | user.account_info_link = uuid.uuid4() 135 | user.last_account_info_created = timezone.now() 136 | user.save() 137 | 138 | user.refresh_from_db() 139 | 140 | response = logged_in_client.get( 141 | reverse(f"v0:{GET_USER_DATA_URL_NAME}", args=[uuid.uuid4()]) 142 | ) 143 | assert response.status_code == 404 144 | 145 | 146 | def test_get_user_info_link_other_user(logged_in_client, user_factory): 147 | other_user = user_factory( 148 | account_info_link=uuid.uuid4(), last_account_info_created=timezone.now() 149 | ) 150 | 151 | response = logged_in_client.get( 152 | reverse( 153 | f"v0:{GET_USER_DATA_URL_NAME}", args=[str(other_user.account_info_link)] 154 | ) 155 | ) 156 | assert response.status_code == 404 157 | -------------------------------------------------------------------------------- /apps/users/tests/test_user_login.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | import pytest 4 | from trench.models import MFAMethod 5 | from trench.utils import create_otp_code, create_secret 6 | 7 | from .constants import GENERATE_CODE_URL, GENERATE_TOKEN_URL, TEST_EMAIL, TEST_PASSWORD 8 | 9 | pytestmark = pytest.mark.django_db 10 | 11 | 12 | def test_correct_login(user, client): 13 | payload = {"email": user.email, "password": TEST_PASSWORD} 14 | 15 | response = client.post(GENERATE_CODE_URL, payload) 16 | 17 | assert response.status_code == 200 18 | assert response.data["refresh"] 19 | assert response.data["access"] 20 | 21 | 22 | def test_2fa_integration(user, logged_in_client, client): 23 | mfa_activate_url = reverse("v0:mfa-activate", args=["app"]) 24 | # check that url present 25 | mfa_confirm = reverse("v0:mfa-activate-confirm", args=["app"]) 26 | mfa_backup_codes = reverse("v0:mfa-regenerate-codes", args=["app"]) 27 | mfa_deactivate = reverse("v0:mfa-deactivate", args=["app"]) 28 | 29 | # Step 1: activate MFA 30 | response = logged_in_client.post(mfa_activate_url) 31 | assert response.status_code == 200 32 | qr_link = response.data.get("qr_link") 33 | assert qr_link 34 | assert MFAMethod.objects.filter(user=user).count() == 1 35 | secret = create_secret() 36 | # Step 2: confirm MFA (mocked step) 37 | assert not MFAMethod.objects.filter( 38 | user=user, is_active=True, is_primary=True 39 | ).exists() 40 | MFAMethod.objects.filter(user=user).update(secret=secret) 41 | response = logged_in_client.post(mfa_confirm, {"code": create_otp_code(secret)}) 42 | assert response.status_code == 200 43 | assert response.data["backup_codes"] 44 | assert MFAMethod.objects.filter(user=user, is_active=True, is_primary=True).exists() 45 | 46 | # Step 3: trying to login 47 | payload = {"email": user.email, "password": TEST_PASSWORD} 48 | response = client.post(GENERATE_CODE_URL, payload) 49 | assert response.status_code == 200 50 | 51 | ephemeral_token = response.data["ephemeral_token"] 52 | payload = {"ephemeral_token": ephemeral_token, "code": create_otp_code(secret)} 53 | 54 | # Step 4: login with otp data 55 | response = client.post(GENERATE_TOKEN_URL, payload) 56 | assert response.status_code == 200 57 | assert response.data["access"] 58 | client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {response.data['access']}" 59 | 60 | # Step 5: request backup codes 61 | response = client.post(mfa_backup_codes, {"code": create_otp_code(secret)}) 62 | assert response.status_code == 200 63 | assert response.data["backup_codes"] 64 | 65 | # Step 6: deactivate 66 | code = response.data["backup_codes"][0] 67 | response = client.post(mfa_deactivate, {"code": code}) 68 | assert response.status_code == 204 69 | assert MFAMethod.objects.filter(name="app").exists() 70 | assert not MFAMethod.objects.get(name="app").is_active 71 | 72 | 73 | @pytest.mark.parametrize( 74 | "payload", 75 | [ 76 | {"email": TEST_EMAIL, "password": "TEST_PASSWORD"}, 77 | {"email": "asd@Asd.asd", "password": TEST_PASSWORD}, 78 | {"email": "asd@Asd.asd", "password": "TEST_PASSWORD"}, 79 | ], 80 | ) 81 | def test_incorrect_login(user, client, payload): 82 | response = client.post(GENERATE_CODE_URL, payload) 83 | 84 | assert response.status_code != 200 85 | assert ( 86 | response.data["messages"][0] 87 | == "No active account found with the given credentials" 88 | ) 89 | 90 | 91 | def test_correct_login_inactive_user(user, client): 92 | user.is_active = False 93 | user.save() 94 | payload = {"email": user.email, "password": TEST_PASSWORD} 95 | 96 | response = client.post(GENERATE_CODE_URL, payload) 97 | 98 | assert response.status_code == 401 99 | assert ( 100 | response.data["messages"][0] 101 | == "No active account found with the given credentials" 102 | ) 103 | 104 | 105 | def test_login_username(user, client): 106 | response = client.post( 107 | GENERATE_CODE_URL, {"username": user.username, "password": TEST_PASSWORD} 108 | ) 109 | 110 | assert response.status_code == 400 111 | assert response.data["messages"][0] == "email: This field is required." 112 | 113 | 114 | def test_login_returned_data(client, user): 115 | payload = {"email": user.email, "password": TEST_PASSWORD} 116 | response = client.post(GENERATE_CODE_URL, payload) 117 | assert response.status_code == 200 118 | 119 | assert set(["refresh", "access"]) == set( 120 | [field for field, value in response.data.items()] 121 | ) 122 | -------------------------------------------------------------------------------- /apps/users/tests/test_user_me_endpoint.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from django.conf import settings 4 | 5 | import pytest 6 | 7 | from .constants import USER_API_URL 8 | 9 | pytestmark = pytest.mark.django_db 10 | 11 | returned_data_fields = ["first_name", "last_name", "email", "last_password_change_date"] 12 | TEST_ERROR_MESSAGE = "value for {} field is different" 13 | 14 | 15 | pytestmark = pytest.mark.django_db 16 | 17 | returned_data_fields = ["first_name", "last_name", "email"] 18 | CORRECT_PATCH_DATA = {"first_name": "Updated", "last_name": "Name"} 19 | TEST_ERROR_MESSAGE = "value for {} field is different" 20 | 21 | 22 | def test_cant_update_user_email_and_username(logged_in_client, user): 23 | """ 24 | Test user.email and username updating 25 | """ 26 | new_email = "updated_email@mail.com" 27 | old_email = user.email 28 | assert new_email != old_email 29 | 30 | response = logged_in_client.patch( 31 | USER_API_URL, {"email": new_email}, content_type="application/json" 32 | ) 33 | assert response.status_code == 200 34 | user.refresh_from_db() 35 | assert user.email == old_email 36 | assert user.username == old_email 37 | 38 | 39 | def test_user_current_user_can_update(logged_in_client, user): 40 | """ 41 | Test user update. 42 | """ 43 | response = logged_in_client.patch( 44 | USER_API_URL, CORRECT_PATCH_DATA, content_type="application/json" 45 | ) 46 | assert response.status_code == 200 47 | user.refresh_from_db() 48 | for field in returned_data_fields: 49 | response_data = response.data[field] 50 | error_message = TEST_ERROR_MESSAGE.format(field) 51 | if field != "email": 52 | assert response_data == CORRECT_PATCH_DATA[field], error_message 53 | assert response_data == getattr(user, field), error_message 54 | 55 | 56 | @pytest.mark.parametrize("deleted_field", ["first_name", "last_name"]) 57 | def test_user_update_required_fields(logged_in_client, deleted_field): 58 | patch_data = deepcopy(CORRECT_PATCH_DATA) 59 | del patch_data[deleted_field] 60 | 61 | response = logged_in_client.patch( 62 | USER_API_URL, patch_data, content_type="application/json" 63 | ) 64 | assert response.status_code == 200 65 | 66 | 67 | def test_user_current_user_info_no_auth(client): 68 | """ 69 | Test current user info without login. 70 | """ 71 | response = client.get(USER_API_URL) 72 | assert response.status_code == 401 73 | 74 | 75 | def test_user_current_user_info_auth(logged_in_client, user): 76 | """ 77 | Test current user info. 78 | """ 79 | response = logged_in_client.get(USER_API_URL) 80 | assert response.status_code == 200 81 | for field in returned_data_fields: 82 | error_message = TEST_ERROR_MESSAGE.format(field) 83 | if field == "last_password_change_date": 84 | assert response.data[field] 85 | else: 86 | assert response.data[field] == getattr(user, field), error_message 87 | 88 | 89 | def test_get_admin_url_user_is_staff(logged_in_client, user): 90 | user.is_staff = True 91 | user.save() 92 | 93 | response = logged_in_client.get(USER_API_URL) 94 | assert response.status_code == 200 95 | 96 | assert response.data["admin_url"] == settings.ADMIN_URL 97 | 98 | 99 | def test_get_admin_url_user_is_superuser(logged_in_client, user): 100 | user.is_superuser = True 101 | user.save() 102 | 103 | response = logged_in_client.get(USER_API_URL) 104 | assert response.status_code == 200 105 | 106 | assert response.data["admin_url"] == settings.ADMIN_URL 107 | 108 | 109 | def test_get_admin_url_user_is_superuser_is_staff(logged_in_client, user): 110 | user.is_superuser = True 111 | user.is_staff = True 112 | user.save() 113 | 114 | response = logged_in_client.get(USER_API_URL) 115 | assert response.status_code == 200 116 | 117 | assert response.data["admin_url"] == settings.ADMIN_URL 118 | 119 | 120 | def test_get_admin_url_user_is_not_staff(logged_in_client, user): 121 | response = logged_in_client.get(USER_API_URL) 122 | assert response.status_code == 200 123 | 124 | assert response.data["admin_url"] is None 125 | 126 | 127 | def test_get_admin_url_user_is_not_superuser(logged_in_client, user): 128 | response = logged_in_client.get(USER_API_URL) 129 | assert response.status_code == 200 130 | 131 | assert response.data["admin_url"] is None 132 | -------------------------------------------------------------------------------- /apps/users/tests/test_user_model.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import timedelta 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ValidationError 6 | from django.utils import timezone 7 | 8 | import pytest 9 | 10 | from apps.users.constants.messages import ( 11 | USER_ALREADY_DELETED_MESSAGE, 12 | USER_WILL_BE_DELETED_MESSAGE, 13 | ) 14 | 15 | pytestmark = pytest.mark.django_db 16 | 17 | 18 | def test_soft_delete_user_invalid(user): 19 | user.is_deleted = True 20 | user.save() 21 | 22 | with pytest.raises(ValidationError) as em: 23 | user.soft_delete_user() 24 | assert USER_ALREADY_DELETED_MESSAGE in em.value 25 | 26 | # test user data 27 | user.refresh_from_db() 28 | assert user.is_deleted 29 | 30 | 31 | def test_soft_delete_user_valid(user): 32 | user.is_deleted = False 33 | user.save() 34 | 35 | user.soft_delete_user() 36 | 37 | # test user data 38 | user.refresh_from_db() 39 | assert user.is_deleted 40 | 41 | 42 | def test_soft_undelete_user_invalid(user): 43 | user.is_deleted = True 44 | user.last_login = timezone.now() - timedelta( 45 | days=settings.ACCOUNT_DELETION_RETENTION_IN_DAYS 46 | ) 47 | user.save() 48 | 49 | with pytest.raises(ValidationError) as em: 50 | user.soft_undelete_user() 51 | assert USER_WILL_BE_DELETED_MESSAGE in em.value 52 | 53 | # test user data 54 | user.refresh_from_db() 55 | assert user.is_deleted 56 | 57 | 58 | def test_soft_undelete_user_valid(user): 59 | user.is_deleted = True 60 | user.last_login = timezone.now() - timedelta(days=5) 61 | user.save() 62 | 63 | user.soft_undelete_user() 64 | 65 | # test user data 66 | user.refresh_from_db() 67 | assert not user.is_deleted 68 | 69 | 70 | def test_create_account_info_link(user): 71 | assert user.account_info_link is None 72 | assert user.last_account_info_created is None 73 | assert not user.account_info_sent 74 | 75 | user.create_account_info_link() 76 | 77 | user.refresh_from_db() 78 | assert user.account_info_link is not None 79 | assert user.last_account_info_created is not None 80 | assert not user.account_info_sent 81 | 82 | 83 | def test_recreate_account_info_link(user): 84 | user.account_info_link = uuid.uuid4() 85 | user.last_account_info_created = timezone.now() 86 | user.account_info_sent = True 87 | user.save() 88 | 89 | old_account_info_link = user.account_info_link 90 | old_last_account_info_created = user.last_account_info_created 91 | 92 | user.create_account_info_link() 93 | 94 | user.refresh_from_db() 95 | assert user.account_info_link != old_account_info_link 96 | assert user.last_account_info_created != old_last_account_info_created 97 | assert user.account_info_sent 98 | 99 | 100 | def test_delete_account_info_link(user): 101 | user.account_info_link = uuid.uuid4() 102 | user.last_account_info_created = timezone.now() 103 | user.account_info_sent = True 104 | user.save() 105 | 106 | user.delete_account_info_link() 107 | 108 | assert user.account_info_link is None 109 | assert user.last_account_info_created is None 110 | assert not user.account_info_sent 111 | -------------------------------------------------------------------------------- /apps/users/tokens.py: -------------------------------------------------------------------------------- 1 | from rest_framework_simplejwt.exceptions import TokenError 2 | from rest_framework_simplejwt.tokens import AccessToken 3 | 4 | from apps.users.constants.messages import INVALID_TOKEN_MESSAGE 5 | 6 | from .models import User 7 | 8 | 9 | class CustomAccessToken(AccessToken): 10 | def __init__(self, token=None, verify=True): 11 | super().__init__(token, verify) 12 | user = User.objects.get(id=self.payload["user_id"]) 13 | if str(user.security_hash) != self.payload["security_hash"]: 14 | raise TokenError(INVALID_TOKEN_MESSAGE) 15 | -------------------------------------------------------------------------------- /apps/users/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from rest_auth.views import ( 4 | LogoutView, 5 | PasswordChangeView, 6 | PasswordResetConfirmView, 7 | PasswordResetView, 8 | ) 9 | 10 | from .constants.url_names import ( 11 | CHANGE_PASS_URL_NAME, 12 | CREATE_USER_DATA_LINK_URL_NAME, 13 | GET_USER_DATA_URL_NAME, 14 | LOGOUT_URL_NAME, 15 | PASS_RESET_CONFIRM_URL_NAME, 16 | PASS_RESET_URL_NAME, 17 | TOKEN_REFRESH_URL_NAME, 18 | TOKEN_VERIFY_URL_NAME, 19 | USER_API_URL_NAME, 20 | USER_REGISTRATION_URL_NAME, 21 | ) 22 | from .views import ( 23 | MyTokenRefreshView, 24 | MyTokenVerifyView, 25 | UserAccountDataView, 26 | UserApiView, 27 | UserRegistrationView, 28 | ) 29 | 30 | urlpatterns = [ 31 | path("", UserRegistrationView.as_view(), name=USER_REGISTRATION_URL_NAME), 32 | path( 33 | "api/token/refresh/", MyTokenRefreshView.as_view(), name=TOKEN_REFRESH_URL_NAME 34 | ), 35 | path("api/token/verify/", MyTokenVerifyView.as_view(), name=TOKEN_VERIFY_URL_NAME), 36 | path("logout/", LogoutView.as_view(), name=LOGOUT_URL_NAME), 37 | path("password/change/", PasswordChangeView.as_view(), name=CHANGE_PASS_URL_NAME), 38 | path("password/reset/", PasswordResetView.as_view(), name=PASS_RESET_URL_NAME), 39 | path( 40 | "password/reset/confirm/", 41 | PasswordResetConfirmView.as_view(), 42 | name=PASS_RESET_CONFIRM_URL_NAME, 43 | ), 44 | path( 45 | "me/", 46 | UserApiView.as_view( 47 | {"get": "retrieve", "patch": "update", "delete": "perform_destroy"} 48 | ), 49 | name=USER_API_URL_NAME, 50 | ), 51 | path( 52 | "me/account-data/", 53 | UserAccountDataView.as_view(), 54 | name=CREATE_USER_DATA_LINK_URL_NAME, 55 | ), 56 | path( 57 | "me/account-data//", 58 | UserAccountDataView.as_view(), 59 | name=GET_USER_DATA_URL_NAME, 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /apps/users/views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.conf import settings 4 | from django.shortcuts import get_object_or_404 5 | from django.utils import timezone 6 | 7 | from rest_framework.permissions import AllowAny 8 | from rest_framework.response import Response 9 | from rest_framework.views import APIView 10 | from rest_framework.viewsets import ModelViewSet 11 | from rest_framework_simplejwt.tokens import RefreshToken 12 | from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView 13 | from trench.views.simplejwt import ( 14 | JSONWebTokenLoginOrRequestMFACode, 15 | JSONWebTokenLoginWithMFACode, 16 | ) 17 | 18 | from apps.users.constants.messages import USER_ACCOUNT_INFO_HAS_ALREADY_BEEN_SENT 19 | from apps.users.email_service import UsersSaasyEmailService 20 | 21 | from .models import User 22 | from .serializers import ( 23 | CustomTokenObtainPairSerializer, 24 | CustomTokenRefreshSerializer, 25 | CustomTokenVerifySerializer, 26 | UserDetailSerializer, 27 | UserRegistrationSerializer, 28 | ) 29 | 30 | 31 | class BaseMyTokenObtainPairView: 32 | def handle_user_login(self, request, serializer, *args, **kwargs): 33 | # soft user undeletion and send recovery email 34 | if request.user and isinstance(request.user, User): 35 | user = request.user 36 | else: 37 | user = User.objects.get(email=self.request.data["email"]) 38 | if user.is_deleted: 39 | user.soft_undelete_user() 40 | UsersSaasyEmailService().send_account_was_recovered_email(user) 41 | 42 | token = RefreshToken.for_user(serializer.user) 43 | token["security_hash"] = str(user.security_hash) 44 | return Response({"refresh": str(token), "access": str(token.access_token)}) 45 | 46 | 47 | class MyTokenObtainPairView( 48 | BaseMyTokenObtainPairView, JSONWebTokenLoginOrRequestMFACode 49 | ): 50 | serializer_class = CustomTokenObtainPairSerializer 51 | 52 | 53 | class MyTokenObtainPairViewWithMFA( 54 | BaseMyTokenObtainPairView, JSONWebTokenLoginWithMFACode 55 | ): 56 | ... 57 | 58 | 59 | class MyTokenVerifyView(TokenVerifyView): 60 | serializer_class = CustomTokenVerifySerializer 61 | 62 | 63 | class MyTokenRefreshView(TokenRefreshView): 64 | serializer_class = CustomTokenRefreshSerializer 65 | 66 | 67 | class UserRegistrationView(APIView): 68 | """ Default User view 69 | """ 70 | 71 | permission_classes = (AllowAny,) 72 | http_method_names = ["post"] 73 | 74 | def post(self, request): 75 | """ Creates User 76 | """ 77 | serializer = UserRegistrationSerializer( 78 | data=request.data, context={"request": self.request} 79 | ) 80 | if serializer.is_valid(raise_exception=True): 81 | serializer.save() 82 | user = User.objects.get(email=self.request.data["email"]) 83 | UsersSaasyEmailService().send_user_account_activation_email(user) 84 | return Response(serializer.data, status=201) 85 | return Response(serializer.data) 86 | 87 | 88 | class UserApiView(ModelViewSet): 89 | serializer_class = UserDetailSerializer 90 | http_method_names = ["get", "patch", "delete"] 91 | 92 | def get_queryset(self): 93 | return User.objects.filter(pk=self.request.user.pk) 94 | 95 | def get_object(self): 96 | return self.request.user 97 | 98 | def perform_destroy(self, request, format=None): 99 | user = self.get_object() 100 | user.soft_delete_user() 101 | if settings.ACCOUNT_DELETION_RETENTION_IN_DAYS == 0: 102 | user.delete() 103 | UsersSaasyEmailService().send_account_was_deleted_email(user) 104 | else: 105 | UsersSaasyEmailService().send_account_scheduled_for_deletion_email(user) 106 | return Response(status=204) 107 | 108 | 109 | class UserAccountDataView(APIView): 110 | http_method_names = ["post", "get"] 111 | 112 | def _get_account_info_handler(self): 113 | import importlib 114 | 115 | function_string = settings.ACCOUNT_INFO_HANDLER 116 | mod_name, func_name = function_string.rsplit(".", 1) 117 | mod = importlib.import_module(mod_name) 118 | func = getattr(mod, func_name) 119 | return func 120 | 121 | def post(self, request, format=None): 122 | user = self.request.user 123 | if user.account_info_sent: 124 | return Response(status=400, data=USER_ACCOUNT_INFO_HAS_ALREADY_BEEN_SENT) 125 | UsersSaasyEmailService().send_account_info_asked_for_email(user) 126 | if settings.ACCOUNT_INFO_AUTOMATED: 127 | user.create_account_info_link() 128 | UsersSaasyEmailService().send_account_info_is_ready_email(user) 129 | user.account_info_sent = True 130 | user.save(update_fields=["account_info_sent"]) 131 | return Response(status=201) 132 | 133 | def get(self, request, account_info_link, format=None): 134 | user = get_object_or_404( 135 | User, 136 | id=self.request.user.id, 137 | account_info_link=self.kwargs["account_info_link"], 138 | last_account_info_created__gt=timezone.now() 139 | - timedelta(days=settings.ACCOUNT_INFO_LINK_AVAILABILITY_IN_DAYS), 140 | ) 141 | account_info_handler = self._get_account_info_handler() 142 | return Response(status=200, data=account_info_handler(user)) 143 | -------------------------------------------------------------------------------- /conf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/conf/__init__.py -------------------------------------------------------------------------------- /conf/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/conf/tests/__init__.py -------------------------------------------------------------------------------- /conf/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from conf.utils import account_warning_and_deletion_in_weeks_are_correct 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "deletion_weeks, warning_weeks", 8 | [ 9 | [1, (52, 53)], 10 | [1, (1, 4)], 11 | [52, (0, 4)], 12 | [3, (2, 3)], 13 | [52, (4, 1)], 14 | [52, (4, 4)], 15 | [4, (4, 4)], 16 | [4, ("4", "4")], 17 | [None, (4, 4)], 18 | [52, (None, 4)], 19 | [52, (4)], 20 | [52, (0)], 21 | [52, (4,)], 22 | [52, (0,)], 23 | [52, ()], 24 | [52, (4, None)], 25 | [52, 0], 26 | [52, "0"], 27 | [0, None], 28 | [None, (0, 0)], 29 | ["52", (1, 4)], 30 | ], 31 | ) 32 | def test_account_warning_and_deletion_in_weeks_are_correct_function_incorrect( 33 | settings, deletion_weeks, warning_weeks 34 | ): 35 | settings.INACTIVE_ACCOUNT_DELETION_IN_WEEKS = deletion_weeks 36 | settings.INACTIVE_ACCOUNT_WARNING_IN_WEEKS = warning_weeks 37 | assert not account_warning_and_deletion_in_weeks_are_correct( 38 | deletion_weeks, warning_weeks 39 | ) 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "deletion_weeks, warning_weeks", 44 | [[52, (1, 4)], [None, (1, 4)], [52, None], [None, None]], 45 | ) 46 | def test_account_warning_and_deletion_in_weeks_are_correct_function_correct( 47 | settings, deletion_weeks, warning_weeks 48 | ): 49 | settings.INACTIVE_ACCOUNT_DELETION_IN_WEEKS = deletion_weeks 50 | settings.INACTIVE_ACCOUNT_WARNING_IN_WEEKS = warning_weeks 51 | assert account_warning_and_deletion_in_weeks_are_correct( 52 | deletion_weeks, warning_weeks 53 | ) 54 | -------------------------------------------------------------------------------- /conf/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import include, path, re_path 4 | 5 | from drf_yasg import openapi 6 | from drf_yasg.views import get_schema_view 7 | from rest_framework import permissions 8 | 9 | from apps.core.views import WelcomePageView 10 | 11 | schema_view = get_schema_view( 12 | openapi.Info( 13 | title="Django-saas-boilerplate API", 14 | default_version="v0", 15 | description="Django-saas-boilerplate API", 16 | ), 17 | public=True, 18 | permission_classes=(permissions.AllowAny,), 19 | ) 20 | 21 | 22 | urlpatterns = ( 23 | path(settings.ADMIN_URL, admin.site.urls), 24 | path("api/v0/", include(("apps.core.urls", "core"), namespace="v0")), 25 | re_path(r"^django-rq/", include("django_rq.urls")), 26 | path("", WelcomePageView.as_view()), 27 | ) 28 | 29 | if settings.PUBLIC_API_DOCUMENTATION or settings.DEBUG: 30 | urlpatterns += ( 31 | re_path( 32 | r"^docs/$", schema_view.with_ui("swagger", cache_timeout=0), name="docs" 33 | ), 34 | ) 35 | -------------------------------------------------------------------------------- /conf/utils.py: -------------------------------------------------------------------------------- 1 | def account_warning_and_deletion_in_weeks_are_correct( # noqa 2 | deletion_weeks: int, warning_weeks: tuple 3 | ) -> bool: 4 | """ Validates variables INACTIVE_ACCOUNT_DELETION_IN_WEEKS 5 | and INACTIVE_ACCOUNT_WARNING_IN_WEEKS. 6 | INACTIVE_ACCOUNT_DELETION_IN_WEEKS must not be 7 | zero or less than one of INACTIVE_ACCOUNT_WARNING_IN_WEEKS. 8 | Check if INACTIVE_ACCOUNT_DELETION_IN_WEEKS is an integer. 9 | Also check if INACTIVE_ACCOUNT_WARNING_IN_WEEKS is a tuple with two integers. 10 | If one of the conditions is not satisfied, returns False else True. 11 | """ 12 | if deletion_weeks is not None and ( 13 | type(deletion_weeks) != int or deletion_weeks == 0 14 | ): 15 | return False 16 | 17 | if warning_weeks is not None: 18 | if type(warning_weeks) == tuple and len(warning_weeks) == 2: 19 | first_week_warning = warning_weeks[0] 20 | second_week_warning = warning_weeks[1] 21 | if ( 22 | (not first_week_warning or not second_week_warning) 23 | or first_week_warning >= second_week_warning 24 | or (type(first_week_warning) != int or type(second_week_warning) != int) 25 | ): 26 | return False 27 | else: 28 | return False 29 | 30 | if deletion_weeks is not None: 31 | if ( 32 | deletion_weeks <= first_week_warning 33 | or deletion_weeks <= second_week_warning 34 | ): 35 | return False 36 | return True 37 | -------------------------------------------------------------------------------- /conf/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conf.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | """ This file contains global level fixtures for the pytest 2 | """ 3 | import os 4 | import shutil 5 | 6 | from django.conf import settings 7 | 8 | import pytest 9 | from pytest_factoryboy import register 10 | 11 | from apps.users.tests.constants import GENERATE_CODE_URL, TEST_EMAIL, TEST_PASSWORD 12 | from apps.users.tests.factories import UserFactory 13 | 14 | 15 | @pytest.fixture(scope="session", autouse=True) 16 | def set_mail_backend(): 17 | settings.SAASY_API_KEY = "some_test_key" 18 | settings.EMAIL_BACKEND = "apps.core.custom_email_backend.CustomEmailBackend" 19 | 20 | 21 | @pytest.fixture() 22 | def logged_in_client(user, client): 23 | response = client.post( 24 | GENERATE_CODE_URL, {"email": user.email, "password": TEST_PASSWORD} 25 | ) 26 | assert response.status_code == 200 27 | client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {response.data['access']}" 28 | return client 29 | 30 | 31 | @pytest.fixture(scope="session", autouse=True) 32 | def remove_tempdir(request): 33 | """ This fixture remove temporary directory created for the media root 34 | """ 35 | 36 | def fin(): 37 | if all([os.path.isdir(settings.MEDIA_ROOT), "test" == settings.ENV]): 38 | shutil.rmtree(settings.MEDIA_ROOT) 39 | 40 | request.addfinalizer(fin) 41 | 42 | 43 | register(UserFactory, email=TEST_EMAIL) 44 | -------------------------------------------------------------------------------- /devops/docker/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:11.2 2 | 3 | COPY init.sh /docker-entrypoint-initdb.d/ 4 | RUN chmod +x /docker-entrypoint-initdb.d/init.sh 5 | 6 | HEALTHCHECK CMD pg_isready -U ${PROJECT_NAME} -------------------------------------------------------------------------------- /devops/docker/postgres/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | >&2 echo "!!! First start of Postgres !!!" 5 | 6 | -------------------------------------------------------------------------------- /devops/docker/redis/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redis:5.0.4 2 | 3 | COPY healthcheck.sh /usr/local/bin/ 4 | 5 | RUN chmod +x /usr/local/bin/healthcheck.sh 6 | 7 | HEALTHCHECK CMD sh /usr/local/bin/healthcheck.sh 8 | 9 | EXPOSE 6379 -------------------------------------------------------------------------------- /devops/docker/redis/healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | host="$(hostname -i || echo '127.0.0.1')" 5 | 6 | if ping="$(redis-cli -h "$host" ping)" && [ "$ping" = 'PONG' ]; then 7 | exit 0 8 | fi 9 | 10 | exit 1 -------------------------------------------------------------------------------- /devops/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | python manage.py collectstatic --clear --link --no-input -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.2" 2 | 3 | volumes: 4 | postgres_data: {} 5 | redis_data: {} 6 | 7 | services: 8 | postgres: 9 | build: ./devops/docker/postgres 10 | restart: on-failure 11 | container_name: ${PROJECT_NAME}_postgres 12 | image: ${PROJECT_NAME}_postgres 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | environment: 16 | POSTGRES_USER: ${PROJECT_NAME} 17 | POSTGRES_DB: ${PROJECT_NAME} 18 | POSTGRES_PASSWORD: ${DB_PASSWORD} 19 | command: -c fsync=off -c synchronous_commit=off -c full_page_writes=off 20 | networks: 21 | - default 22 | ports: 23 | - "5432:5432" 24 | 25 | redis: 26 | restart: on-failure 27 | container_name: ${PROJECT_NAME}_redis 28 | image: ${PROJECT_NAME}_redis 29 | build: 30 | context: ./devops/docker/redis/ 31 | volumes: 32 | - redis_data:/data 33 | networks: 34 | - default 35 | ports: 36 | - "6379:6379" 37 | 38 | backend: 39 | image: ${PROJECT_NAME}_backend 40 | container_name: ${PROJECT_NAME}_backend 41 | build: 42 | dockerfile: Dockerfile 43 | context: ./ 44 | restart: on-failure 45 | ports: 46 | - "8000:8000" 47 | working_dir: /app 48 | environment: 49 | - DEBUG=True 50 | - DATABASE_URL=postgresql://${PROJECT_NAME}:${DB_PASSWORD}@postgres:5432/${PROJECT_NAME} 51 | - ALLOWED_HOSTS=* 52 | - SECRET_KEY=notsafeforproduction 53 | - CORS_ORIGIN_ALLOW_ALL=True 54 | - RQ_HOST=redis 55 | - RQ_PORT=${DOCKER_REDIS_PORT} 56 | - DJANGO_SETTINGS_MODULE=conf.settings 57 | - PORT=${PORT} 58 | - HOSTNAME=${HOSTNAME} 59 | volumes: 60 | - .:/app 61 | networks: 62 | - default 63 | stdin_open: true 64 | tty: true 65 | depends_on: 66 | - postgres 67 | - redis 68 | - rqworkers 69 | 70 | rqworkers: 71 | image: ${PROJECT_NAME}_rqworkers 72 | container_name: ${PROJECT_NAME}_rqworkers 73 | build: 74 | dockerfile: Dockerfile 75 | context: . 76 | restart: always 77 | working_dir: /app 78 | environment: 79 | - DEBUG=True 80 | - DATABASE_URL=postgresql://${PROJECT_NAME}:${DB_PASSWORD}@postgres:5432/${PROJECT_NAME} 81 | - ALLOWED_HOSTS=* 82 | - SECRET_KEY=notsafeforproduction 83 | - CORS_ORIGIN_ALLOW_ALL=True 84 | - RQ_HOST=redis 85 | - RQ_PORT=${DOCKER_REDIS_PORT} 86 | - DJANGO_SETTINGS_MODULE=conf.settings 87 | - PORT=${PORT} 88 | - HOSTNAME=${HOSTNAME} 89 | volumes: 90 | - .:/app 91 | networks: 92 | - default 93 | command: /bin/bash -c "python manage.py rqworker default" 94 | stdin_open: true 95 | tty: true 96 | depends_on: 97 | - postgres 98 | - redis 99 | 100 | networks: 101 | default: 102 | ipam: 103 | driver: default 104 | -------------------------------------------------------------------------------- /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", "conf.settings") 7 | # Avoid the use of "python manage.py test", we are using py.test for testing 8 | if sys.argv[0] == "manage.py" and sys.argv[1] == "test": 9 | print("TESTING is only supported with py.test!") 10 | sys.exit(-1) 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-saas-boilerplate" 3 | version = "0.1.0" 4 | description = "A Django + django-rest-framework + Heroku + SaaS application boilerplate" 5 | authors = ["Pixelpassion"] 6 | packages = [ 7 | { include="scripts", from="." }, 8 | ] 9 | 10 | [tool.poetry.dependencies] 11 | python = ">=3.7,<3.8" # Python versions must be set in dependencies for poetry 12 | django = "<3" # Basics and boilerplate stuff (BSD-3) 13 | django-environ = "^0.4.5" # Configuration by environment variables according to 12Factor model (MIT) 14 | sentry-sdk = "^0.14.2" # Error logging to Sentry 15 | django-rest-framework = "^0.1.0" # Tools for RESTful API (BSD-2) 16 | psycopg2-binary = "^2.8.4" # Database connector (LGPL) 17 | gunicorn = "^20.0.4" # Python WSGI HTTP Server 18 | django-cors-headers = "^3.2.1" 19 | django-extensions = "^2.2.8" # Different helpers for Django REST Framework 20 | django-rq = "^2.3.0" # Asynchronous tasks 21 | django-redis = "^4.11.0" # Redis cache backend (BSD-3) 22 | whitenoise = "^5.0.1" 23 | urllib3 = "^1.25.8" 24 | saasy = "^0.0.1" 25 | django-rest-auth = "^0.9.5" 26 | djangorestframework-simplejwt = "^4.4.0" 27 | django-trench = "^0.2.3" 28 | drf-yasg = "^1.17.1" 29 | 30 | 31 | [tool.poetry.dev-dependencies] 32 | django-werkzeug = "^1.0.0" 33 | ipdb = "^0.13.2" # Debugging 34 | django-coverage-plugin = "^1.8.0" 35 | django-pdb = "^0.6.2" 36 | werkzeug = "^1.0.0" # A flexible WSGI implementation and toolkit (BSD-3) 37 | mypy = "^0.770" 38 | factory_boy = "^2.12.0" # Mocked Test data 39 | coverage = "^5.0.4" 40 | ipython = "^7.13.0" # Better Python shell 41 | pre-commit = "^2.2.0" 42 | prospector = ">1.1.6.4" 43 | Faker = "^4.0.2" 44 | black = "^19.10b0" 45 | flake8 = "<=3.4.0" # Old version, because prospector stick with old deps of pyflake and pycodestyles 46 | pytest = "<5.4.0" # Better testing 47 | autopep8 = "<=1.3.5" # Old version, because prospector stick with old deps of pyflake and pycodestyles 48 | django-silk = "^4.0.1" 49 | pytest-django = "^3.8.0" 50 | pytest-xdist = "^1.31.0" 51 | pytest-factoryboy = "^2.0.3" 52 | pytest-freezegun = "^0.4.1" 53 | pytest-mock = "^2.0.0" 54 | pytest-sugar = "^0.9.2" 55 | pytest-env = "^0.6.2" 56 | 57 | 58 | [tool.poetry.scripts] 59 | go-docker = "scripts.poetry_scripts:go_docker" 60 | create-coverage = "scripts.poetry_scripts:coverage_report" 61 | check-coverage = "scripts.poetry_scripts:check_coverage" 62 | codecov-report = "scripts.poetry_scripts:coverage_xml" 63 | generate-requirements = "scripts.poetry_scripts:generate_requirements" 64 | 65 | [build-system] 66 | requires = ["poetry>=0.12"] 67 | build-backend = "poetry.masonry.api" 68 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | python_files = 4 | tests.py 5 | test_*.py 6 | *_tests.py 7 | 8 | addopts = 9 | --maxfail=9999 10 | --showlocals 11 | --color=yes 12 | --runxfail 13 | --strict 14 | --durations=30 15 | -r a 16 | --reuse-db 17 | --no-migrations 18 | --pdbcls=IPython.terminal.debugger:TerminalPdb 19 | 20 | DJANGO_SETTINGS_MODULE = conf.settings 21 | env = 22 | DEBUG=True 23 | ENV=test 24 | GDPR_ADMINISTRATOR_EMAIL=admin@mail.com 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2020.4.5.1 \ 2 | --hash=sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304 \ 3 | --hash=sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519 4 | chardet==3.0.4 \ 5 | --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ 6 | --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae 7 | click==7.1.1 \ 8 | --hash=sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a \ 9 | --hash=sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc 10 | coreapi==2.3.3 \ 11 | --hash=sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3 \ 12 | --hash=sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb 13 | coreschema==0.0.4 \ 14 | --hash=sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f \ 15 | --hash=sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607 16 | django==2.2.12 \ 17 | --hash=sha256:6ecd229e1815d4fc5240fc98f1cca78c41e7a8cd3e3f2eefadc4735031077916 \ 18 | --hash=sha256:69897097095f336d5aeef45b4103dceae51c00afa6d3ae198a2a18e519791b7a 19 | django-cors-headers==3.2.1 \ 20 | --hash=sha256:a5960addecc04527ab26617e51b8ed42f0adab4594b24bb0f3c33e2bd3857c3f \ 21 | --hash=sha256:a785b5f446f6635810776d9f5f5d23e6a2a2f728ea982648370afaf0dfdf2627 22 | django-environ==0.4.5 \ 23 | --hash=sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde \ 24 | --hash=sha256:c57b3c11ec1f319d9474e3e5a79134f40174b17c7cc024bbb2fad84646b120c4 25 | django-extensions==2.2.9 \ 26 | --hash=sha256:2f81b618ba4d1b0e58603e25012e5c74f88a4b706e0022a3b21f24f0322a6ce6 \ 27 | --hash=sha256:b19182d101a441fe001c5753553a901e2ef3ff60e8fbbe38881eb4a61fdd17c4 28 | django-redis==4.11.0 \ 29 | --hash=sha256:a5b1e3ffd3198735e6c529d9bdf38ca3fcb3155515249b98dc4d966b8ddf9d2b \ 30 | --hash=sha256:e1aad4cc5bd743d8d0b13d5cae0cef5410eaace33e83bff5fc3a139ad8db50b4 31 | django-rest-auth==0.9.5 \ 32 | --hash=sha256:f11e12175dafeed772f50d740d22caeab27e99a3caca24ec65e66a8d6de16571 33 | django-rest-framework==0.1.0 \ 34 | --hash=sha256:47a8f496fa69e3b6bd79f68dd7a1527d907d6b77f009e9db7cf9bb21cc565e4a 35 | django-rq==2.3.1 \ 36 | --hash=sha256:060bece17912c4188fc3166e79c8244dc0608fbacc68e1069a47ceb91759ffcf \ 37 | --hash=sha256:70e4e4a6566c0c1449a2f08ad74865a2045d74659da63aeea66681015ae1bbdb 38 | django-trench==0.2.3 \ 39 | --hash=sha256:63e189a057c45198d178ea79337e690250b484fcd8ff2057c9fd4b3699639853 40 | djangorestframework==3.11.0 \ 41 | --hash=sha256:05809fc66e1c997fd9a32ea5730d9f4ba28b109b9da71fccfa5ff241201fd0a4 \ 42 | --hash=sha256:e782087823c47a26826ee5b6fa0c542968219263fb3976ec3c31edab23a4001f 43 | djangorestframework-simplejwt==4.4.0 \ 44 | --hash=sha256:288ee78618d906f26abf6282b639b8f1806ce1d9a7578897a125cf79c609f259 \ 45 | --hash=sha256:c315be70aa12a5f5790c0ab9acd426c3a58eebea65a77d0893248c5144a5080c 46 | drf-yasg==1.17.1 \ 47 | --hash=sha256:5572e9d5baab9f6b49318169df9789f7399d0e3c7bdac8fdb8dfccf1d5d2b1ca \ 48 | --hash=sha256:7d7af27ad16e18507e9392b2afd6b218fbffc432ec8dbea053099a2241e184ff 49 | gunicorn==20.0.4 \ 50 | --hash=sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c \ 51 | --hash=sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626 52 | idna==2.9 \ 53 | --hash=sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa \ 54 | --hash=sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb 55 | inflection==0.4.0 \ 56 | --hash=sha256:9a15d3598f01220e93f2207c432cfede50daff53137ce660fb8be838ef1ca6cc \ 57 | --hash=sha256:32a5c3341d9583ec319548b9015b7fbdf8c429cbcb575d326c33ae3a0e90d52c 58 | itypes==1.1.0 \ 59 | --hash=sha256:c6e77bb9fd68a4bfeb9d958fea421802282451a25bac4913ec94db82a899c073 60 | jinja2==2.11.2 \ 61 | --hash=sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035 \ 62 | --hash=sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0 63 | markupsafe==1.1.1 \ 64 | --hash=sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161 \ 65 | --hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7 \ 66 | --hash=sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183 \ 67 | --hash=sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b \ 68 | --hash=sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e \ 69 | --hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \ 70 | --hash=sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1 \ 71 | --hash=sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5 \ 72 | --hash=sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1 \ 73 | --hash=sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735 \ 74 | --hash=sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21 \ 75 | --hash=sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235 \ 76 | --hash=sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b \ 77 | --hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \ 78 | --hash=sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905 \ 79 | --hash=sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1 \ 80 | --hash=sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d \ 81 | --hash=sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff \ 82 | --hash=sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473 \ 83 | --hash=sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e \ 84 | --hash=sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66 \ 85 | --hash=sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5 \ 86 | --hash=sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d \ 87 | --hash=sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e \ 88 | --hash=sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6 \ 89 | --hash=sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2 \ 90 | --hash=sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c \ 91 | --hash=sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15 \ 92 | --hash=sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2 \ 93 | --hash=sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42 \ 94 | --hash=sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b \ 95 | --hash=sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be \ 96 | --hash=sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b 97 | packaging==20.3 \ 98 | --hash=sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752 \ 99 | --hash=sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3 100 | psycopg2-binary==2.8.5 \ 101 | --hash=sha256:ccdc6a87f32b491129ada4b87a43b1895cf2c20fdb7f98ad979647506ffc41b6 \ 102 | --hash=sha256:96d3038f5bd061401996614f65d27a4ecb62d843eb4f48e212e6d129171a721f \ 103 | --hash=sha256:08507efbe532029adee21b8d4c999170a83760d38249936038bd0602327029b5 \ 104 | --hash=sha256:b9a8b391c2b0321e0cd7ec6b4cfcc3dd6349347bd1207d48bcb752aa6c553a66 \ 105 | --hash=sha256:3286541b9d85a340ee4ed42732d15fc1bb441dc500c97243a768154ab8505bb5 \ 106 | --hash=sha256:008da3ab51adc70a5f1cfbbe5db3a22607ab030eb44bcecf517ad11a0c2b3cac \ 107 | --hash=sha256:ba13346ff6d3eb2dca0b6fa0d8a9d999eff3dcd9b55f3a890f12b0b6362b2b38 \ 108 | --hash=sha256:c8830b7d5f16fd79d39b21e3d94f247219036b29b30c8270314c46bf8b732389 \ 109 | --hash=sha256:51f7823f1b087d2020d8e8c9e6687473d3d239ba9afc162d9b2ab6e80b53f9f9 \ 110 | --hash=sha256:107d9be3b614e52a192719c6bf32e8813030020ea1d1215daa86ded9a24d8b04 \ 111 | --hash=sha256:930315ac53dc65cbf52ab6b6d27422611f5fb461d763c531db229c7e1af6c0b3 \ 112 | --hash=sha256:6bb2dd006a46a4a4ce95201f836194eb6a1e863f69ee5bab506673e0ca767057 \ 113 | --hash=sha256:3939cf75fc89c5e9ed836e228c4a63604dff95ad19aed2bbf71d5d04c15ed5ce \ 114 | --hash=sha256:a20299ee0ea2f9cca494396ac472d6e636745652a64a418b39522c120fd0a0a4 \ 115 | --hash=sha256:cc30cb900f42c8a246e2cb76539d9726f407330bc244ca7729c41a44e8d807fb \ 116 | --hash=sha256:40abc319f7f26c042a11658bf3dd3b0b3bceccf883ec1c565d5c909a90204434 \ 117 | --hash=sha256:702f09d8f77dc4794651f650828791af82f7c2efd8c91ae79e3d9fe4bb7d4c98 \ 118 | --hash=sha256:d1a8b01f6a964fec702d6b6dac1f91f2b9f9fe41b310cbb16c7ef1fac82df06d \ 119 | --hash=sha256:17a0ea0b0eabf07035e5e0d520dabc7950aeb15a17c6d36128ba99b2721b25b1 \ 120 | --hash=sha256:e004db88e5a75e5fdab1620fb9f90c9598c2a195a594225ac4ed2a6f1c23e162 \ 121 | --hash=sha256:a34826d6465c2e2bbe9d0605f944f19d2480589f89863ed5f091943be27c9de4 \ 122 | --hash=sha256:cac918cd7c4c498a60f5d2a61d4f0a6091c2c9490d81bc805c963444032d0dab \ 123 | --hash=sha256:7b832d76cc65c092abd9505cc670c4e3421fd136fb6ea5b94efbe4c146572505 \ 124 | --hash=sha256:bb0608694a91db1e230b4a314e8ed00ad07ed0c518f9a69b83af2717e31291a3 \ 125 | --hash=sha256:eb2f43ae3037f1ef5e19339c41cf56947021ac892f668765cd65f8ab9814192e \ 126 | --hash=sha256:07cf82c870ec2d2ce94d18e70c13323c89f2f2a2628cbf1feee700630be2519a \ 127 | --hash=sha256:a69970ee896e21db4c57e398646af9edc71c003bc52a3cc77fb150240fefd266 \ 128 | --hash=sha256:7036ccf715925251fac969f4da9ad37e4b7e211b1e920860148a10c0de963522 \ 129 | --hash=sha256:8f74e631b67482d504d7e9cf364071fc5d54c28e79a093ff402d5f8f81e23bfa \ 130 | --hash=sha256:fa466306fcf6b39b8a61d003123d442b23707d635a5cb05ac4e1b62cc79105cd 131 | pyjwt==1.7.1 \ 132 | --hash=sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e \ 133 | --hash=sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96 134 | pyotp==2.3.0 \ 135 | --hash=sha256:c88f37fd47541a580b744b42136f387cdad481b560ef410c0d85c957eb2a2bc0 \ 136 | --hash=sha256:fc537e8acd985c5cbf51e11b7d53c42276fee017a73aec7c07380695671ca1a1 137 | pyparsing==2.4.7 \ 138 | --hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b \ 139 | --hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1 140 | pytz==2019.3 \ 141 | --hash=sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d \ 142 | --hash=sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be 143 | redis==3.4.1 \ 144 | --hash=sha256:b205cffd05ebfd0a468db74f0eedbff8df1a7bfc47521516ade4692991bb0833 \ 145 | --hash=sha256:0dcfb335921b88a850d461dc255ff4708294943322bd55de6cfd68972490ca1f 146 | requests==2.23.0 \ 147 | --hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \ 148 | --hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 149 | rq==1.3.0 \ 150 | --hash=sha256:c3e65a8ba5e59287308f23679f7fe729b9380531e4f6cdabb2dee99b82834811 \ 151 | --hash=sha256:49c9149fa9301f98d918f3042f36bed4252d37193d222a1ce8b0e25886442377 152 | ruamel.yaml==0.16.10 \ 153 | --hash=sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b \ 154 | --hash=sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954 155 | ruamel.yaml.clib==0.2.0; platform_python_implementation == "CPython" and python_version < "3.9" \ 156 | --hash=sha256:9c6d040d0396c28d3eaaa6cb20152cb3b2f15adf35a0304f4f40a3cf9f1d2448 \ 157 | --hash=sha256:4d55386129291b96483edcb93b381470f7cd69f97585829b048a3d758d31210a \ 158 | --hash=sha256:8073c8b92b06b572e4057b583c3d01674ceaf32167801fe545a087d7a1e8bf52 \ 159 | --hash=sha256:615b0396a7fad02d1f9a0dcf9f01202bf9caefee6265198f252c865f4227fcc6 \ 160 | --hash=sha256:a0ff786d2a7dbe55f9544b3f6ebbcc495d7e730df92a08434604f6f470b899c5 \ 161 | --hash=sha256:ea4362548ee0cbc266949d8a441238d9ad3600ca9910c3fe4e82ee3a50706973 \ 162 | --hash=sha256:77556a7aa190be9a2bd83b7ee075d3df5f3c5016d395613671487e79b082d784 \ 163 | --hash=sha256:392b7c371312abf27fb549ec2d5e0092f7ef6e6c9f767bfb13e83cb903aca0fd \ 164 | --hash=sha256:ed5b3698a2bb241b7f5cbbe277eaa7fe48b07a58784fba4f75224fd066d253ad \ 165 | --hash=sha256:7aee724e1ff424757b5bd8f6c5bbdb033a570b2b4683b17ace4dbe61a99a657b \ 166 | --hash=sha256:d0d3ac228c9bbab08134b4004d748cf9f8743504875b3603b3afbb97e3472947 \ 167 | --hash=sha256:f9dcc1ae73f36e8059589b601e8e4776b9976effd76c21ad6a855a74318efd6e \ 168 | --hash=sha256:1e77424825caba5553bbade750cec2277ef130647d685c2b38f68bc03453bac6 \ 169 | --hash=sha256:d10e9dd744cf85c219bf747c75194b624cc7a94f0c80ead624b06bfa9f61d3bc \ 170 | --hash=sha256:550168c02d8de52ee58c3d8a8193d5a8a9491a5e7b2462d27ac5bf63717574c9 \ 171 | --hash=sha256:57933a6986a3036257ad7bf283529e7c19c2810ff24c86f4a0cfeb49d2099919 \ 172 | --hash=sha256:b1b7fcee6aedcdc7e62c3a73f238b3d080c7ba6650cd808bce8d7761ec484070 \ 173 | --hash=sha256:be018933c2f4ee7de55e7bd7d0d801b3dfb09d21dad0cce8a97995fd3e44be30 \ 174 | --hash=sha256:b66832ea8077d9b3f6e311c4a53d06273db5dc2db6e8a908550f3c14d67e718c 175 | saasy==0.0.1 \ 176 | --hash=sha256:bd04101cbb066bf7db7d312d11729af570a97dc11a13a68b111bf5bb541acca6 177 | sentry-sdk==0.14.3 \ 178 | --hash=sha256:bb90a4e19c7233a580715fc986cc44be2c48fc10b31e71580a2037e1c94b6950 \ 179 | --hash=sha256:23808d571d2461a4ce3784ec12bbee5bdb8c026c143fe79d36cef8a6d653e71f 180 | six==1.14.0 \ 181 | --hash=sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c \ 182 | --hash=sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a 183 | smsapi-client==2.4.2 \ 184 | --hash=sha256:e6d66b464fc6e2edbf7d0638faed43bf323b218a27f01ba3e7c8d64ce6af95c7 \ 185 | --hash=sha256:3c851cdb7daf7410ebdac2d857384a2ed36f23d7416544b8fe704e64c7da2d6f 186 | sqlparse==0.3.1 \ 187 | --hash=sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e \ 188 | --hash=sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548 189 | twilio==6.38.0 \ 190 | --hash=sha256:39b949c671f1b29259c85569de58e6a4c8b04e13aba08d2d20ddba9d7c304573 191 | uritemplate==3.0.1 \ 192 | --hash=sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f \ 193 | --hash=sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae 194 | urllib3==1.25.8 \ 195 | --hash=sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc \ 196 | --hash=sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc 197 | whitenoise==5.0.1 \ 198 | --hash=sha256:62556265ec1011bd87113fb81b7516f52688887b7a010ee899ff1fd18fd22700 \ 199 | --hash=sha256:0f9137f74bd95fa54329ace88d8dc695fbe895369a632e35f7a136e003e41d73 200 | yubico-client==1.12.0 \ 201 | --hash=sha256:1d74c6341210c94b639f7c7c8930550e73d5c1be60402e418e9dc95e038f8527 \ 202 | --hash=sha256:c90c47ec4596f0508f2d202c9c216ca3854284f8c5833dc814c36089794e0aa2 203 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.7.3 2 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pixelpassion/django-saas-boilerplate/8888d67181c760708edb18a4832d9002340878fa/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/poetry_scripts.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def go_docker(): 5 | """Script for starting the containers in the background and leaving them running.""" 6 | os.system("docker-compose up -d") 7 | 8 | 9 | def coverage_report(): 10 | """Script for running coverage tests.""" 11 | os.system("coverage run -m py.test") 12 | 13 | 14 | def coverage_xml(): 15 | """Script for creating xml report on coverage.""" 16 | os.system("coverage xml") 17 | 18 | 19 | def check_coverage(): 20 | """Script for creating coverage report.""" 21 | os.system("coverage report --fail-under=95") 22 | 23 | 24 | def generate_requirements(): 25 | """Script for generating requirements.txt from pyproject.toml""" 26 | os.system("poetry export -f requirements.txt > requirements.txt") 27 | -------------------------------------------------------------------------------- /scripts/tests/test_poetry_scripts.py: -------------------------------------------------------------------------------- 1 | from scripts.poetry_scripts import ( 2 | check_coverage, 3 | coverage_report, 4 | coverage_xml, 5 | generate_requirements, 6 | go_docker, 7 | ) 8 | 9 | 10 | def test_go_docker(mocker): 11 | """Test go_docker function from scripts.poetry_scripts""" 12 | mocked_function = mocker.patch("os.system") 13 | go_docker() 14 | mocked_function.assert_called_once_with("docker-compose up -d") 15 | 16 | 17 | def test_coverage_report(mocker): 18 | """Test coverage_report function from scripts.poetry_scripts""" 19 | mocked_function = mocker.patch("os.system") 20 | coverage_report() 21 | mocked_function.assert_called_once_with("coverage run -m py.test") 22 | 23 | 24 | def test_coverage_xml(mocker): 25 | """Test coverage_xml function from scripts.poetry_scripts""" 26 | mocked_function = mocker.patch("os.system") 27 | coverage_xml() 28 | mocked_function.assert_called_once_with("coverage xml") 29 | 30 | 31 | def test_check_coverage(mocker): 32 | """Test check_coverage function from scripts.poetry_scripts""" 33 | mocked_function = mocker.patch("os.system") 34 | check_coverage() 35 | mocked_function.assert_called_once_with("coverage report --fail-under=95") 36 | 37 | 38 | def test_generate_requirements(mocker): 39 | """Test check_coverage function from scripts.poetry_scripts""" 40 | mocked_function = mocker.patch("os.system") 41 | generate_requirements() 42 | mocked_function.assert_called_once_with( 43 | "poetry export -f requirements.txt > requirements.txt" 44 | ) 45 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules, .txt 4 | 5 | [isort] 6 | known_django = django 7 | sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 8 | skip = .venv, .txt 9 | default_section = FIRSTPARTY 10 | 11 | multi_line_output = 3 12 | include_trailing_comma = True 13 | force_grid_wrap = 0 14 | use_parentheses = True 15 | line_length = 88 16 | -------------------------------------------------------------------------------- /static/welcome/style.css: -------------------------------------------------------------------------------- 1 | .fixed-bottom { 2 | position: fixed; 3 | bottom: 0px; 4 | padding: 17px 0 18px 0; 5 | text-align: center; 6 | width: 100%; 7 | bottom: 0px; 8 | display: flex; 9 | justify-content: space-evenly; 10 | background-color:#bec9d3; 11 | } 12 | .container { 13 | display: flex; 14 | flex-wrap: wrap; 15 | justify-content: space-between; 16 | } 17 | 18 | .item { 19 | flex: 0 32%; 20 | height: 100px; 21 | margin-bottom: 2%; /* (100-32*3)/2 */ 22 | } 23 | 24 | .exclamation{ 25 | color: red; 26 | } 27 | .check-mark{ 28 | color: green; 29 | } 30 | .footer-block { 31 | display: flex;align-items: center; 32 | } 33 | .footer-icon { 34 | margin: 10px; padding: 10px; margin-bottom: 8% 35 | } 36 | .footer-text { 37 | text-align: left; 38 | } -------------------------------------------------------------------------------- /templates/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% load static %} 5 | 6 | Django-saas-boilerplate 7 | 8 | 10 | 11 | 12 | 13 |

django-saas-boilerplate

is up and running

14 | 15 | 16 |
17 | {% if not APP_NAME %} 18 |
19 |
App Title
20 |
22 |
23 | 24 |
25 | {% endif %} 26 | {% if not API_URL %} 27 |
28 |
API URL
29 | 30 |
32 |
33 | 34 |
35 | {% endif %} 36 | {% if not SENTRY_DSN %} 37 |
38 |
Sentry
39 | 40 |
42 |
43 | 44 |
45 | {% endif %} 46 | {% if PUBLIC_API_DOCUMENTATION %} 47 |
48 |
Public API Documentation
49 | 50 |
52 |
53 | 54 |
55 | {% endif %} 56 | {% if DEBUG %} 57 |
58 |
DEBUG
59 | 60 |
62 |
63 | 64 |
65 | {% endif %} 66 |
67 | 76 | 90 | 99 |
100 |
101 | 102 | 104 | 106 | 108 | --------------------------------------------------------------------------------