From eeec372957afd30606f4ea546c554659d23a628e Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Wed, 3 Jan 2024 10:09:31 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(project)=20first=20proof=20of=20conce?= =?UTF-8?q?pt=20based=20of=20Joanie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Used https://github.com/openfun/joanie as boilerplate, ran a few transformations with ChapGPT and adapted models and endpoints to fit to my current vision of the project. --- .dockerignore | 33 + .github/ISSUE_TEMPLATE.md | 6 + .github/ISSUE_TEMPLATE/Bug_report.md | 28 + .github/ISSUE_TEMPLATE/Feature_request.md | 23 + .github/ISSUE_TEMPLATE/Support_question.md | 22 + .github/PULL_REQUEST_TEMPLATE.md | 11 + .github/workflows/people.yml | 231 ++++ .gitignore | 80 ++ .gitlint | 78 ++ CHANGELOG.md | 9 + Dockerfile | 150 +++ LICENSE | 2 +- Makefile | 280 ++++ README.md | 78 ++ UPGRADE.md | 17 + bin/_config.sh | 157 +++ bin/compose | 6 + bin/manage | 6 + bin/pylint | 39 + bin/pytest | 8 + bin/state | 25 + bin/terraform | 26 + bin/update_openapi_schema | 12 + crowdin/config.yml | 23 + docker-compose.yml | 135 ++ docker/files/etc/nginx/conf.d/default.conf | 19 + docker/files/usr/local/bin/entrypoint | 35 + docker/files/usr/local/etc/gunicorn/people.py | 16 + docs/models.md | 235 ++++ docs/tsclient.md | 25 + env.d/development/common.dist | 20 + env.d/development/crowdin.dist | 3 + env.d/development/postgresql.dist | 11 + gitlint/gitlint_emoji.py | 37 + renovate.json | 25 + src/backend/.pylintrc | 472 +++++++ src/backend/MANIFEST.in | 3 + src/backend/__init__.py | 0 src/backend/core/__init__.py | 0 src/backend/core/api/__init__.py | 39 + src/backend/core/api/permissions.py | 54 + src/backend/core/api/serializers.py | 145 +++ src/backend/core/api/utils.py | 18 + src/backend/core/api/viewsets.py | 364 ++++++ src/backend/core/apps.py | 11 + src/backend/core/authentication.py | 62 + src/backend/core/enums.py | 15 + src/backend/core/factories.py | 177 +++ src/backend/core/jsonschema/contact_data.json | 130 ++ src/backend/core/migrations/0001_initial.py | 147 +++ src/backend/core/migrations/__init__.py | 0 src/backend/core/models.py | 477 +++++++ src/backend/core/tests/__init__.py | 0 .../core/tests/swagger/test_openapi_schema.py | 21 + .../tests/teams/test_core_api_teams_create.py | 50 + .../tests/teams/test_core_api_teams_delete.py | 107 ++ .../tests/teams/test_core_api_teams_list.py | 118 ++ .../teams/test_core_api_teams_retrieve.py | 86 ++ .../tests/teams/test_core_api_teams_update.py | 176 +++ src/backend/core/tests/test_api_contacts.py | 694 ++++++++++ .../core/tests/test_api_team_accesses.py | 845 +++++++++++++ src/backend/core/tests/test_api_users.py | 375 ++++++ .../core/tests/test_models_contacts.py | 163 +++ .../core/tests/test_models_identities.py | 183 +++ .../core/tests/test_models_team_accesses.py | 262 ++++ src/backend/core/tests/test_models_teams.py | 135 ++ src/backend/core/tests/test_models_users.py | 84 ++ src/backend/core/tests/utils.py | 21 + src/backend/manage.py | 14 + src/backend/people/__init__.py | 0 src/backend/people/api_urls.py | 36 + src/backend/people/celery_app.py | 22 + src/backend/people/settings.py | 509 ++++++++ src/backend/people/urls.py | 50 + src/backend/people/wsgi.py | 17 + src/backend/pyproject.toml | 134 ++ src/backend/setup.py | 7 + src/mail/bin/html-to-plain-text | 22 + src/mail/bin/mjml-to-html | 9 + src/mail/html-to-text.config.json | 11 + src/mail/mjml/hello.mjml | 28 + src/mail/mjml/partial/footer.mjml | 9 + src/mail/mjml/partial/header.mjml | 48 + src/mail/package.json | 22 + src/mail/yarn.lock | 1126 +++++++++++++++++ src/tsclient/package.json | 25 + .../generate_api_client_local.sh | 8 + src/tsclient/yarn.lock | 133 ++ 88 files changed, 9574 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/ISSUE_TEMPLATE/Bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/Feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/Support_question.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/people.yml create mode 100644 .gitignore create mode 100644 .gitlint create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 UPGRADE.md create mode 100644 bin/_config.sh create mode 100755 bin/compose create mode 100755 bin/manage create mode 100755 bin/pylint create mode 100755 bin/pytest create mode 100755 bin/state create mode 100755 bin/terraform create mode 100755 bin/update_openapi_schema create mode 100644 crowdin/config.yml create mode 100644 docker-compose.yml create mode 100644 docker/files/etc/nginx/conf.d/default.conf create mode 100755 docker/files/usr/local/bin/entrypoint create mode 100644 docker/files/usr/local/etc/gunicorn/people.py create mode 100644 docs/models.md create mode 100644 docs/tsclient.md create mode 100644 env.d/development/common.dist create mode 100644 env.d/development/crowdin.dist create mode 100644 env.d/development/postgresql.dist create mode 100644 gitlint/gitlint_emoji.py create mode 100644 renovate.json create mode 100644 src/backend/.pylintrc create mode 100644 src/backend/MANIFEST.in create mode 100644 src/backend/__init__.py create mode 100644 src/backend/core/__init__.py create mode 100644 src/backend/core/api/__init__.py create mode 100644 src/backend/core/api/permissions.py create mode 100644 src/backend/core/api/serializers.py create mode 100644 src/backend/core/api/utils.py create mode 100644 src/backend/core/api/viewsets.py create mode 100644 src/backend/core/apps.py create mode 100644 src/backend/core/authentication.py create mode 100644 src/backend/core/enums.py create mode 100644 src/backend/core/factories.py create mode 100644 src/backend/core/jsonschema/contact_data.json create mode 100644 src/backend/core/migrations/0001_initial.py create mode 100644 src/backend/core/migrations/__init__.py create mode 100644 src/backend/core/models.py create mode 100644 src/backend/core/tests/__init__.py create mode 100644 src/backend/core/tests/swagger/test_openapi_schema.py create mode 100644 src/backend/core/tests/teams/test_core_api_teams_create.py create mode 100644 src/backend/core/tests/teams/test_core_api_teams_delete.py create mode 100644 src/backend/core/tests/teams/test_core_api_teams_list.py create mode 100644 src/backend/core/tests/teams/test_core_api_teams_retrieve.py create mode 100644 src/backend/core/tests/teams/test_core_api_teams_update.py create mode 100644 src/backend/core/tests/test_api_contacts.py create mode 100644 src/backend/core/tests/test_api_team_accesses.py create mode 100644 src/backend/core/tests/test_api_users.py create mode 100644 src/backend/core/tests/test_models_contacts.py create mode 100644 src/backend/core/tests/test_models_identities.py create mode 100644 src/backend/core/tests/test_models_team_accesses.py create mode 100644 src/backend/core/tests/test_models_teams.py create mode 100644 src/backend/core/tests/test_models_users.py create mode 100644 src/backend/core/tests/utils.py create mode 100644 src/backend/manage.py create mode 100644 src/backend/people/__init__.py create mode 100644 src/backend/people/api_urls.py create mode 100644 src/backend/people/celery_app.py create mode 100755 src/backend/people/settings.py create mode 100644 src/backend/people/urls.py create mode 100644 src/backend/people/wsgi.py create mode 100644 src/backend/pyproject.toml create mode 100644 src/backend/setup.py create mode 100755 src/mail/bin/html-to-plain-text create mode 100755 src/mail/bin/mjml-to-html create mode 100644 src/mail/html-to-text.config.json create mode 100644 src/mail/mjml/hello.mjml create mode 100644 src/mail/mjml/partial/footer.mjml create mode 100644 src/mail/mjml/partial/header.mjml create mode 100644 src/mail/package.json create mode 100644 src/mail/yarn.lock create mode 100644 src/tsclient/package.json create mode 100755 src/tsclient/scripts/openapi-typescript-codegen/generate_api_client_local.sh create mode 100644 src/tsclient/yarn.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..51d1670 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +# Python +__pycache__ +*.pyc +**/__pycache__ +**/*.pyc +venv +.venv + +# System-specific files +.DS_Store +**/.DS_Store + +# Docker +docker compose.* +env.d + +# Docs +docs +*.md +*.log + +# Development/test cache & configurations +data +.cache +.circleci +.git +.vscode +.iml +.idea +db.sqlite3 +.mypy_cache +.pylint.d +.pytest_cache diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..24e79d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,6 @@ + diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 0000000..887205b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,28 @@ +--- +name: 🐛 Bug Report +about: If something is not working as expected 🤔. + +--- + +## Bug Report + +**Problematic behavior** +A clear and concise description of the behavior. + +**Expected behavior/code** +A clear and concise description of what you expected to happen (or code). + +**Steps to Reproduce** +1. Do this... +2. Then this... +3. And then the bug happens! + +**Environment** +- People version: +- Platform: + +**Possible Solution** + + +**Additional context/Screenshots** +Add any other context about the problem here. If applicable, add screenshots to help explain. diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md new file mode 100644 index 0000000..51d3170 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,23 @@ +--- +name: ✨ Feature Request +about: I have a suggestion (and may want to build it 💪)! + +--- + +## Feature Request + +**Is your feature request related to a problem or unsupported use case? Please describe.** +A clear and concise description of what the problem is. For example: I need to do some task and I have an issue... + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. Add any considered drawbacks. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Discovery, Documentation, Adoption, Migration Strategy** +If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable). +Maybe a screenshot or design? + +**Do you want to work on it through a Pull Request?** + diff --git a/.github/ISSUE_TEMPLATE/Support_question.md b/.github/ISSUE_TEMPLATE/Support_question.md new file mode 100644 index 0000000..da7e7ba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Support_question.md @@ -0,0 +1,22 @@ +--- +name: 🤗 Support Question +about: If you have a question 💬, or something was not clear from the docs! + +--- + + + +--- + +Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people). + +Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues). + +If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌 + +**Topic** +What's the general area of your question: for example, docker setup, database schema, search functionality,... + +**Question** +Try to be as specific as possible so we can help you as best we can. Please be patient 🙏 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..85cfbe6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Purpose + +Description... + + +## Proposal + +Description... + +- [] item 1... +- [] item 2... diff --git a/.github/workflows/people.yml b/.github/workflows/people.yml new file mode 100644 index 0000000..b471b9d --- /dev/null +++ b/.github/workflows/people.yml @@ -0,0 +1,231 @@ +name: People Workflow + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint-git: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Enforce absence of print statements in code + run: | + ! git diff origin/main..HEAD -- . ':(exclude).circleci' | grep "print(" + - name: Check absence of fixup commits + run: | + ! git log | grep 'fixup!' + - name: Install gitlint + run: pip install --user requests gitlint + - name: Lint commit messages added to main + run: ~/.local/bin/gitlint --commits origin/main..HEAD + + check-changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Check that the CHANGELOG has been modified in the current branch + run: git whatchanged --name-only --pretty="" origin..HEAD | grep CHANGELOG + + lint-changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Check CHANGELOG max line length + run: | + max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L) + if [ $max_line_length -ge 80 ]; then + echo "ERROR: CHANGELOG has lines longer than 80 characters." + exit 1 + fi + + build-mails: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install yarn + run: npm install -g yarn + - name: Install node dependencies + run: yarn install --frozen-lockfile + - name: Build mails + run: yarn build + + build-docker: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Generate a version.json file describing app release + run: | + printf '{"commit":"${{ github.sha }}","version":"${{ github.ref }}","source":"https://github.com/${{ github.repository_owner }}/${{ github.repository }}","build":"${{ github.run_id }}"}\n' > src/backend/people/version.json + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Build production image + run: docker build -t people:${{ github.sha }} --target production . + - name: Check built image availability + run: docker images "people:${{ github.sha }}*" + + build-back: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Install Python + uses: actions/setup-python@v3 + with: + python-version: '3.10' + - name: Install development dependencies + run: pip install --user .[dev] + working-directory: src/backend + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ~/.local + key: v1-back-dependencies-${{ hashFiles('src/backend/requirements.txt') }} + restore-keys: | + v1-back-dependencies- + - name: Check code formatting with ruff + run: ~/.local/bin/ruff format people --diff + - name: Lint code with ruff + run: ~/.local/bin/ruff check people + - name: Lint code with pylint + run: ~/.local/bin/pylint people + + test-back: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: test_people + POSTGRES_USER: dinum + POSTGRES_PASSWORD: pass + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Install Python + uses: actions/setup-python@v3 + with: + python-version: '3.10' + - name: Install dependencies + run: pip install --user -r src/backend/requirements.txt + - name: Create writable /data + run: | + sudo mkdir -p /data/media && \ + sudo mkdir -p /data/static && \ + sudo chown -R $USER:$USER /data + - name: Install gettext (required to compile messages) + run: | + sudo apt-get update + sudo apt-get install -y gettext + - name: Generate a MO file from strings extracted from the project + run: python manage.py compilemessages + - name: Run tests + run: ~/.local/bin/pytest -n 2 + + build-back-i18n: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Attach workspace + uses: actions/checkout@v2 + with: + path: ~/people + - name: Install Python + uses: actions/setup-python@v3 + with: + python-version: '3.10' + - name: Install gettext (required to make messages) + run: | + sudo apt-get update + sudo apt-get install -y gettext + - name: Generate and persist the translations base file + run: ~/.local/bin/django-admin makemessages --keep-pot --all + - name: Persist translations to workspace + uses: actions/upload-artifact@v2 + with: + name: translations + path: src/backend/locale + + upload-i18n-strings: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Attach workspace + uses: actions/checkout@v2 + with: + path: ~/people + - name: Upload files to Crowdin + run: crowdin upload sources -c crowdin/config.yml + + package-back: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Install Python + uses: actions/setup-python@v3 + with: + python-version: '3.10' + - name: Build python package + run: python setup.py sdist bdist_wheel + - name: Persist build packages to workspace + uses: actions/upload-artifact@v2 + with: + name: packages + path: src/backend/dist + - name: Store packages as artifacts + uses: actions/upload-artifact@v2 + with: + name: packages + path: src/backend/dist + + hub: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Generate a version.json file describing app release + run: | + printf '{"commit":"${{ github.sha }}","version":"${{ github.ref }}","source":"https://github.com/${{ github.repository_owner }}/${{ github.repository }}","build":"${{ github.run_id }}"}\n' > src/backend/people/version.json + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Build production image + run: docker build -t people:${{ github.sha }} --target production . + - name: Check built images availability + run: docker images "people:${{ github.sha }}*" + - name: Login to DockerHub + run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin + - name: Tag images + run: | + DOCKER_TAG=$([[ -z "${{ github.event.ref }}" ]] && echo "${{ github.event.ref }}" || echo "${{ github.event.ref }}" | sed 's/^v//') + RELEASE_TYPE=$([[ -z "${{ github.event.ref }}" ]] && echo "branch" || echo "tag ") + echo "DOCKER_TAG: ${DOCKER_TAG} (Git ${RELEASE_TYPE}${{ github.event.ref }})" + docker tag people:${{ github.sha }} numerique-gouv/people:${DOCKER_TAG} + if [[ -n "${{ github.event.ref }}" ]]; then + docker tag people:${{ github.sha }} numerique-gouv/people:latest + fi + docker images | grep -E "^numerique-gouv/people\s*(${DOCKER_TAG}.*|latest|main)" + - name: Publish images + run: | + DOCKER_TAG=$([[ -z "${{ github.event.ref }}" ]] && echo "${{ github.event.ref }}" || echo "${{ github.event.ref }}" | sed 's/^v//') + RELEASE_TYPE=$([[ -z "${{ github.event.ref }}" ]] && echo "branch" || echo "tag ") + echo "DOCKER_TAG: ${DOCKER_TAG} (Git ${RELEASE_TYPE}${{ github.event.ref }})" + docker push numerique-gouv/people:${DOCKER_TAG} + if [[ -n "${{ github.event.ref }}" ]]; then + docker push numerique-gouv/people:latest + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c744f17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.DS_Store +.next/ + +# Translations # Translations +*.pot + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +env.d/development/* +!env.d/development/*.dist +env.d/terraform + +# npm +node_modules + +# Mails +src/backend/core/templates/mail/ + +# Typescript client +src/frontend/tsclient + +# Swagger +**/swagger.json + +# Logs +*.log + +# Terraform +.terraform +*.tfstate +*.tfstate.backup + +# Test & lint +.coverage +.pylint.d +.pytest_cache +db.sqlite3 +.mypy_cache + +# Site media +/data/ + +# IDEs +.idea/ +.vscode/ +*.iml diff --git a/.gitlint b/.gitlint new file mode 100644 index 0000000..f7373b6 --- /dev/null +++ b/.gitlint @@ -0,0 +1,78 @@ +# All these sections are optional, edit this file as you like. +[general] +# Ignore certain rules, you can reference them by their id or by their full name +# ignore=title-trailing-punctuation, T3 + +# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this +# verbosity = 2 + +# By default gitlint will ignore merge commits. Set to 'false' to disable. +# ignore-merge-commits=true + +# By default gitlint will ignore fixup commits. Set to 'false' to disable. +# ignore-fixup-commits=true + +# By default gitlint will ignore squash commits. Set to 'false' to disable. +# ignore-squash-commits=true + +# Enable debug mode (prints more output). Disabled by default. +# debug=true + +# Set the extra-path where gitlint will search for user defined rules +# See http://jorisroovers.github.io/gitlint/user_defined_rules for details +extra-path=gitlint/ + +# [title-max-length] +# line-length=80 + +[title-must-not-contain-word] +# Comma-separated list of words that should not occur in the title. Matching is case +# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING" +# will not cause a violation, but "WIP: my title" will. +words=wip + +#[title-match-regex] +# python like regex (https://docs.python.org/2/library/re.html) that the +# commit-msg title must be matched to. +# Note that the regex can contradict with other rules if not used correctly +# (e.g. title-must-not-contain-word). +#regex= + +# [B1] +# B1 = body-max-line-length +# line-length=120 +# [body-min-length] +# min-length=5 + +# [body-is-missing] +# Whether to ignore this rule on merge commits (which typically only have a title) +# default = True +# ignore-merge-commits=false + +# [body-changed-file-mention] +# List of files that need to be explicitly mentioned in the body when they are changed +# This is useful for when developers often erroneously edit certain files or git submodules. +# By specifying this rule, developers can only change the file when they explicitly reference +# it in the commit message. +# files=gitlint/rules.py,README.md + +# [author-valid-email] +# python like regex (https://docs.python.org/2/library/re.html) that the +# commit author email address should be matched to +# For example, use the following regex if you only want to allow email addresses from foo.com +# regex=[^@]+@foo.com + +[ignore-by-title] +# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper) +# upgrade dependencies +regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$ +ignore=B6,UC1 + +# [ignore-by-body] +# Ignore certain rules for commits of which the body has a line that matches a regex +# E.g. Match bodies that have a line that that contain "release" +# regex=(.*)release(.*) +# +# Ignore certain rules, you can reference them by their id or by their full name +# Use 'all' to ignore all rules +# ignore=T1,body-min-length diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0ce9544 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da75b75 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,150 @@ +# Django People + +# ---- base image to inherit from ---- +FROM python:3.10-slim-bullseye as base + +# Upgrade pip to its latest release to speed up dependencies installation +RUN python -m pip install --upgrade pip + +# Upgrade system packages to install security updates +RUN apt-get update && \ + apt-get -y upgrade && \ + rm -rf /var/lib/apt/lists/* + +# ---- Back-end builder image ---- +FROM base as back-builder + +WORKDIR /builder + +# Copy required python dependencies +COPY ./src/backend /builder + +RUN mkdir /install && \ + pip install --prefix=/install . + + +# ---- mails ---- +FROM node:18 as mail-builder + +COPY ./src/mail /mail/app + +WORKDIR /mail/app + +RUN yarn install --frozen-lockfile && \ + yarn build + + +# ---- static link collector ---- +FROM base as link-collector +ARG PEOPLE_STATIC_ROOT=/data/static + +# Install libpangocairo & rdfind +RUN apt-get update && \ + apt-get install -y \ + libpangocairo-1.0-0 \ + rdfind && \ + rm -rf /var/lib/apt/lists/* + +# Copy installed python dependencies +COPY --from=back-builder /install /usr/local + +# Copy people application (see .dockerignore) +COPY ./src/backend /app/ + +WORKDIR /app + +# collectstatic +RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \ + python manage.py collectstatic --noinput + +# Replace duplicated file by a symlink to decrease the overall size of the +# final image +RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${PEOPLE_STATIC_ROOT} + +# ---- Core application image ---- +FROM base as core + +ENV PYTHONUNBUFFERED=1 + +# Install required system libs +RUN apt-get update && \ + apt-get install -y \ + gettext \ + libcairo2 \ + libffi-dev \ + libgdk-pixbuf2.0-0 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + shared-mime-info && \ + rm -rf /var/lib/apt/lists/* + +# Copy entrypoint +COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint + +# Give the "root" group the same permissions as the "root" user on /etc/passwd +# to allow a user belonging to the root group to add new users; typically the +# docker user (see entrypoint). +RUN chmod g=u /etc/passwd + +# Copy installed python dependencies +COPY --from=back-builder /install /usr/local + +# Copy people application (see .dockerignore) +COPY ./src/backend /app/ + +WORKDIR /app + +# We wrap commands run in this container by the following entrypoint that +# creates a user on-the-fly with the container user ID (see USER) and root group +# ID. +ENTRYPOINT [ "/usr/local/bin/entrypoint" ] + +# ---- Development image ---- +FROM core as development + +# Switch back to the root user to install development dependencies +USER root:root + +# Install psql +RUN apt-get update && \ + apt-get install -y postgresql-client && \ + rm -rf /var/lib/apt/lists/* + +# Uninstall people and re-install it in editable mode along with development +# dependencies +RUN pip uninstall -y people +RUN pip install -e .[dev] + +# Restore the un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +# Target database host (e.g. database engine following docker compose services +# name) & port +ENV DB_HOST=postgresql \ + DB_PORT=5432 + +# Run django development server +CMD python manage.py runserver 0.0.0.0:8000 + +# ---- Production image ---- +FROM core as production + +ARG PEOPLE_STATIC_ROOT=/data/static + +# Gunicorn +RUN mkdir -p /usr/local/etc/gunicorn +COPY docker/files/usr/local/etc/gunicorn/people.py /usr/local/etc/gunicorn/people.py + +# Un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +# Copy statics +COPY --from=link-collector ${PEOPLE_STATIC_ROOT} ${PEOPLE_STATIC_ROOT} + +# Copy people mails +COPY --from=mail-builder /mail/backend/people/core/templates/mail /app/people/core/templates/mail + +# The default command runs gunicorn WSGI server in people's main module +CMD gunicorn -c /usr/local/etc/gunicorn/people.py people.wsgi:application diff --git a/LICENSE b/LICENSE index 0f8109d..d46fd06 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Direction Interministérielle du Numérique du Gouvernement Français +Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b90f08e --- /dev/null +++ b/Makefile @@ -0,0 +1,280 @@ +# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ +# +# This Makefile is only meant to be used for DEVELOPMENT purpose as we are +# changing the user id that will run in the container. +# +# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER... +# +# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ +# +# Note to developers: +# +# While editing this file, please respect the following statements: +# +# 1. Every variable should be defined in the ad hoc VARIABLES section with a +# relevant subsection +# 2. Every new rule should be defined in the ad hoc RULES section with a +# relevant subsection depending on the targeted service +# 3. Rules should be sorted alphabetically within their section +# 4. When a rule has multiple dependencies, you should: +# - duplicate the rule name to add the help string (if required) +# - write one dependency per line to increase readability and diffs +# 5. .PHONY rule statement should be written after the corresponding rule +# ============================================================================== +# VARIABLES + +BOLD := \033[1m +RESET := \033[0m +GREEN := \033[1;32m + + +# -- Database + +DB_HOST = postgresql +DB_PORT = 5432 + +# -- Docker +# Get the current user ID to use for docker run and docker exec commands +DOCKER_UID = $(shell id -u) +DOCKER_GID = $(shell id -g) +DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID) +COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose +COMPOSE_EXEC = $(COMPOSE) exec +COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev +COMPOSE_RUN = $(COMPOSE) run --rm +COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev +COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin +WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s + +# -- Backend +MANAGE = $(COMPOSE_RUN_APP) python manage.py +MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn +TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn + +# ============================================================================== +# RULES + +default: help + +data/media: + @mkdir -p data/media + +data/static: + @mkdir -p data/static + +# -- Project + +bootstrap: ## Prepare Docker images for the project +bootstrap: \ + data/media \ + data/static \ + env.d/development/common \ + env.d/development/crowdin \ + env.d/development/postgresql \ + build \ + run \ + migrate \ + i18n-compile \ + mails-install \ + mails-build +.PHONY: bootstrap + +# -- Docker/compose +build: ## build the app-dev container + @$(COMPOSE) build app-dev +.PHONY: build + +down: ## stop and remove containers, networks, images, and volumes + @$(COMPOSE) down +.PHONY: down + +logs: ## display app-dev logs (follow mode) + @$(COMPOSE) logs -f app-dev +.PHONY: logs + +run: ## start the wsgi (production) and development server + @$(COMPOSE) up --force-recreate -d nginx + @$(COMPOSE) up --force-recreate -d app-dev + @$(COMPOSE) up --force-recreate -d celery-dev + @echo "Wait for postgresql to be up..." + @$(WAIT_DB) +.PHONY: run + +status: ## an alias for "docker compose ps" + @$(COMPOSE) ps +.PHONY: status + +stop: ## stop the development server using Docker + @$(COMPOSE) stop +.PHONY: stop + +# -- Backend + +demo: ## flush db then create a demo for load testing purpose + @$(MAKE) resetdb + @$(MANAGE) create_demo +.PHONY: demo + +# Nota bene: Black should come after isort just in case they don't agree... +lint: ## lint back-end python sources +lint: \ + lint-ruff-format \ + lint-ruff-check \ + lint-pylint +.PHONY: lint + +lint-ruff-format: ## format back-end python sources with ruff + @echo 'lint:ruff-format started…' + @$(COMPOSE_RUN_APP) ruff format . +.PHONY: lint-ruff-format + +lint-ruff-check: ## lint back-end python sources with ruff + @echo 'lint:ruff-check started…' + @$(COMPOSE_RUN_APP) ruff check . --fix +.PHONY: lint-ruff-check + +lint-pylint: ## lint back-end python sources with pylint only on changed files from main + @echo 'lint:pylint started…' + bin/pylint --diff-only=origin/main +.PHONY: lint-pylint + +test: ## run project tests + @$(MAKE) test-back-parallel +.PHONY: test + +test-back: ## run back-end tests + @args="$(filter-out $@,$(MAKECMDGOALS))" && \ + bin/pytest $${args:-${1}} +.PHONY: test-back + +test-back-parallel: ## run all back-end tests in parallel + @args="$(filter-out $@,$(MAKECMDGOALS))" && \ + bin/pytest -n auto $${args:-${1}} +.PHONY: test-back-parallel + + +makemigrations: ## run django makemigrations for the people project. + @echo "$(BOLD)Running makemigrations$(RESET)" + @$(COMPOSE) up -d postgresql + @$(WAIT_DB) + @$(MANAGE) makemigrations +.PHONY: makemigrations + +migrate: ## run django migrations for the people project. + @echo "$(BOLD)Running migrations$(RESET)" + @$(COMPOSE) up -d postgresql + @$(WAIT_DB) + @$(MANAGE) migrate +.PHONY: migrate + +superuser: ## Create an admin superuser with password "admin" + @echo "$(BOLD)Creating a Django superuser$(RESET)" + @$(MANAGE) createsuperuser --username admin --email admin@example.com --no-input +.PHONY: superuser + +back-i18n-compile: ## compile the gettext files + @$(MANAGE) compilemessages --ignore="venv/**/*" +.PHONY: back-i18n-compile + +back-i18n-generate: ## create the .pot files used for i18n + @$(MANAGE) makemessages -a --keep-pot +.PHONY: back-i18n-generate + +shell: ## connect to database shell + @$(MANAGE) shell #_plus +.PHONY: dbshell + +# -- Database + +dbshell: ## connect to database shell + docker compose exec app-dev python manage.py dbshell +.PHONY: dbshell + +resetdb: ## flush database and create a superuser "admin" + @echo "$(BOLD)Flush database$(RESET)" + @$(MANAGE) flush + @${MAKE} superuser +.PHONY: resetdb + +env.d/development/common: + cp -n env.d/development/common.dist env.d/development/common + +env.d/development/postgresql: + cp -n env.d/development/postgresql.dist env.d/development/postgresql + +# -- Internationalization + +env.d/development/crowdin: + cp -n env.d/development/crowdin.dist env.d/development/crowdin + +crowdin-download: ## Download translated message from crowdin + @$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml +.PHONY: crowdin-download + +crowdin-upload: ## Upload source translations to crowdin + @$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml +.PHONY: crowdin-upload + +i18n-compile: ## compile all translations +i18n-compile: \ + back-i18n-compile \ + admin-i18n-compile +.PHONY: i18n-compile + +i18n-generate: ## create the .pot files and extract frontend messages +i18n-generate: \ + back-i18n-generate \ + admin-i18n-extract +.PHONY: i18n-generate + +i18n-download-and-compile: ## download all translated messages and compile them to be used by all applications +i18n-download-and-compile: \ + crowdin-download \ + i18n-compile +.PHONY: i18n-download-and-compile + +i18n-generate-and-upload: ## generate source translations for all applications and upload them to crowdin +i18n-generate-and-upload: \ + i18n-generate \ + crowdin-upload +.PHONY: i18n-generate-and-upload + + +# -- Mail generator + +mails-build: ## Convert mjml files to html and text + @$(MAIL_YARN) build +.PHONY: mails-build + +mails-build-html-to-plain-text: ## Convert html files to text + @$(MAIL_YARN) build-html-to-plain-text +.PHONY: mails-build-html-to-plain-text + +mails-build-mjml-to-html: ## Convert mjml files to html and text + @$(MAIL_YARN) build-mjml-to-html +.PHONY: mails-build-mjml-to-html + +mails-install: ## install the mail generator + @$(MAIL_YARN) install +.PHONY: mails-install + +# -- TS client generator + +tsclient-install: ## Install the Typescipt API client generator + @$(TSCLIENT_YARN) install +.PHONY: tsclient-install + +tsclient: tsclient-install ## Generate a Typescipt API client + @$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient +.PHONY: tsclient-install + +# -- Misc +clean: ## restore repository state as it was freshly cloned + git clean -idx +.PHONY: clean + +help: + @echo "$(BOLD)People Makefile" + @echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:" + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}' +.PHONY: help diff --git a/README.md b/README.md new file mode 100644 index 0000000..03cc1f1 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# People + +People is an application to handle users and teams. + +People is built on top of [Django Rest +Framework](https://www.django-rest-framework.org/). + +## Getting started + +### Prerequisite + +Make sure you have a recent version of Docker and [Docker +Compose](https://docs.docker.com/compose/install) installed on your laptop: + +```bash +$ docker -v + Docker version 20.10.2, build 2291f61 + +$ docker compose -v + docker compose version 1.27.4, build 40524192 +``` + +> ⚠️ You may need to run the following commands with `sudo` but this can be +> avoided by assigning your user to the `docker` group. + +### Project bootstrap + +The easiest way to start working on the project is to use GNU Make: + +```bash +$ make bootstrap +``` + +This command builds the `app` container, installs dependencies, performs +database migrations and compile translations. It's a good idea to use this +command each time you are pulling code from the project repository to avoid +dependency-releated or migration-releated issues. + +Your Docker services should now be up and running 🎉 + +Note that if you need to run them afterwards, you can use the eponym Make rule: + +```bash +$ make run +``` + +### Adding content + +You can create a basic demo site by running: + + $ make demo + +Finally, you can check all available Make rules using: + +```bash +$ make help +``` + +### Django admin + +You can access the Django admin site at +[http://localhost:8071/admin](http://localhost:8071/admin). + +You first need to create a superuser account: + +```bash +$ make superuser +``` + +## Contributing + +This project is intended to be community-driven, so please, do not hesitate to +get in touch if you have any question related to our implementation or design +decisions. + +## License + +This work is released under the MIT License (see [LICENSE](./LICENSE)). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..a905f77 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,17 @@ +# Upgrade + +All instructions to upgrade this project from one release to the next will be +documented in this file. Upgrades must be run sequentially, meaning you should +not skip minor/major releases while upgrading (fix releases can be skipped). + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +For most upgrades, you just need to run the django migrations with +the following command inside your docker container: + +`python manage.py migrate` + +(Note : in your development environment, you can `make migrate`.) + +## [Unreleased] diff --git a/bin/_config.sh b/bin/_config.sh new file mode 100644 index 0000000..d5d6106 --- /dev/null +++ b/bin/_config.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +set -eo pipefail + +REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)" +UNSET_USER=0 + +TERRAFORM_DIRECTORY="./env.d/terraform" +COMPOSE_FILE="${REPO_DIR}/docker-compose.yml" +COMPOSE_PROJECT="people" + + +# _set_user: set (or unset) default user id used to run docker commands +# +# usage: _set_user +# +# You can override default user ID (the current host user ID), by defining the +# USER_ID environment variable. +# +# To avoid running docker commands with a custom user, please set the +# $UNSET_USER environment variable to 1. +function _set_user() { + + if [ $UNSET_USER -eq 1 ]; then + USER_ID="" + return + fi + + # USER_ID = USER_ID or `id -u` if USER_ID is not set + USER_ID=${USER_ID:-$(id -u)} + + echo "🙋(user) ID: ${USER_ID}" +} + +# docker_compose: wrap docker compose command +# +# usage: docker_compose [options] [ARGS...] +# +# options: docker compose command options +# ARGS : docker compose command arguments +function _docker_compose() { + + echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'" + docker compose \ + -p "${COMPOSE_PROJECT}" \ + -f "${COMPOSE_FILE}" \ + --project-directory "${REPO_DIR}" \ + "$@" +} + +# _dc_run: wrap docker compose run command +# +# usage: _dc_run [options] [ARGS...] +# +# options: docker compose run command options +# ARGS : docker compose run command arguments +function _dc_run() { + _set_user + + user_args="--user=$USER_ID" + if [ -z $USER_ID ]; then + user_args="" + fi + + _docker_compose run --rm $user_args "$@" +} + +# _dc_exec: wrap docker compose exec command +# +# usage: _dc_exec [options] [ARGS...] +# +# options: docker compose exec command options +# ARGS : docker compose exec command arguments +function _dc_exec() { + _set_user + + echo "🐳(compose) exec command: '\$@'" + + user_args="--user=$USER_ID" + if [ -z $USER_ID ]; then + user_args="" + fi + + _docker_compose exec $user_args "$@" +} + +# _django_manage: wrap django's manage.py command with docker compose +# +# usage : _django_manage [ARGS...] +# +# ARGS : django's manage.py command arguments +function _django_manage() { + _dc_run "app-dev" python manage.py "$@" +} + +# _set_openstack_project: select an OpenStack project from the openrc files defined in the +# terraform directory. +# +# usage: _set_openstack_project +# +# If necessary the script will prompt the user to choose a project from those available +function _set_openstack_project() { + + declare prompt + declare -a projects + declare -i default=1 + declare -i choice=0 + declare -i n_projects + + # List projects by looking in the "./env.d/terraform" directory + # and store them in an array + read -r -a projects <<< "$( + find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d | + sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' | + xargs + )" + nb_projects=${#projects[@]} + + if [[ ${nb_projects} -le 0 ]]; then + echo "There are no OpenStack projects defined..." >&2 + echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \ + "of your project and copy your \"openrc.sh\" file into it." >&2 + exit 10 + fi + + if [[ ${nb_projects} -gt 1 ]]; then + prompt="Select an OpenStack project to target:\\n" + for (( i=0; i&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})") + exit 11 + fi + + if [[ ${choice} -le 0 ]]; then + choice=${default} + fi + fi + + project=${projects[$((choice-1))]} + # Check that the openrc.sh file exists for this project + if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then + (>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.") + exit 12 + fi + + echo "${project}" +} diff --git a/bin/compose b/bin/compose new file mode 100755 index 0000000..1adb3d8 --- /dev/null +++ b/bin/compose @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +# shellcheck source=bin/_config.sh +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_docker_compose "$@" diff --git a/bin/manage b/bin/manage new file mode 100755 index 0000000..b6c82d9 --- /dev/null +++ b/bin/manage @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +# shellcheck source=bin/_config.sh +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_django_manage "$@" diff --git a/bin/pylint b/bin/pylint new file mode 100755 index 0000000..6681142 --- /dev/null +++ b/bin/pylint @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# shellcheck source=bin/_config.sh +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +declare diff_from +declare -a paths +declare -a args + +# Parse options +for arg in "$@" +do + case $arg in + --diff-only=*) + diff_from="${arg#*=}" + shift + ;; + -*) + args+=("$arg") + shift + ;; + *) + paths+=("$arg") + shift + ;; + esac +done + +if [[ -n "${diff_from}" ]]; then + # Run pylint only on modified files located in src/backend/joanie + # (excluding deleted files and migration files) + # shellcheck disable=SC2207 + paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend/joanie ':!**/migrations/*.py' | grep -E '^src/backend/joanie/.*\.py$')) +fi + +# Fix docker vs local path when project sources are mounted as a volume +read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")" + +_dc_run app-dev pylint "${paths[@]}" "${args[@]}" diff --git a/bin/pytest b/bin/pytest new file mode 100755 index 0000000..8ed0d30 --- /dev/null +++ b/bin/pytest @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_dc_run \ + -e DJANGO_CONFIGURATION=Test \ + app-dev \ + pytest "$@" diff --git a/bin/state b/bin/state new file mode 100755 index 0000000..8188385 --- /dev/null +++ b/bin/state @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -eo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +project=$(_set_openstack_project) +echo "Using \"${project}\" project..." + +source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" + +# Run Terraform commands in the Hashicorp docker container via docker compose +# shellcheck disable=SC2068 +DOCKER_USER="$(id -u):$(id -g)" \ + PROJECT="${project}" \ + docker compose run --rm \ + -e OS_AUTH_URL \ + -e OS_IDENTITY_API_VERSION \ + -e OS_USER_DOMAIN_NAME \ + -e OS_PROJECT_DOMAIN_NAME \ + -e OS_TENANT_ID \ + -e OS_TENANT_NAME \ + -e OS_USERNAME \ + -e OS_PASSWORD \ + -e OS_REGION_NAME \ + terraform-state "$@" diff --git a/bin/terraform b/bin/terraform new file mode 100755 index 0000000..a871a0c --- /dev/null +++ b/bin/terraform @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -eo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +project=$(_set_openstack_project) +echo "Using \"${project}\" project..." + +source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" + +# Run Terraform commands in the Hashicorp docker container via docker compose +# shellcheck disable=SC2068 +DOCKER_USER="$(id -u):$(id -g)" \ + PROJECT="${project}" \ + docker compose run --rm \ + -e OS_AUTH_URL \ + -e OS_IDENTITY_API_VERSION \ + -e OS_USER_DOMAIN_NAME \ + -e OS_PROJECT_DOMAIN_NAME \ + -e OS_TENANT_ID \ + -e OS_TENANT_NAME \ + -e OS_USERNAME \ + -e OS_PASSWORD \ + -e OS_REGION_NAME \ + -e TF_VAR_user_name \ + terraform "$@" diff --git a/bin/update_openapi_schema b/bin/update_openapi_schema new file mode 100755 index 0000000..ed25974 --- /dev/null +++ b/bin/update_openapi_schema @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_dc_run \ + -e DJANGO_CONFIGURATION=Test \ + app-dev \ + python manage.py spectacular \ + --api-version 'v1.0' \ + --urlconf 'people.api_urls' \ + --format openapi-json \ + --file /app/core/tests/swagger/swagger.json diff --git a/crowdin/config.yml b/crowdin/config.yml new file mode 100644 index 0000000..7a87ee7 --- /dev/null +++ b/crowdin/config.yml @@ -0,0 +1,23 @@ +# +# Your crowdin's credentials +# +api_token_env: CROWDIN_API_TOKEN +project_id_env: CROWDIN_PROJECT_ID +base_path_env: CROWDIN_BASE_PATH + +# +# Choose file structure in crowdin +# e.g. true or false +# +preserve_hierarchy: true + +# +# Files configuration +# +files: [ + { + source : "/backend/locale/django.pot", + dest: "/backend.pot", + translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po" + }, +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..427a529 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,135 @@ +version: '3.8' + +services: + postgresql: + image: postgres:16 + env_file: + - env.d/development/postgresql + ports: + - "15432:5432" + + redis: + image: redis:5 + + mailcatcher: + image: sj26/mailcatcher:latest + ports: + - "1081:1080" + + app-dev: + build: + context: . + target: development + args: + DOCKER_USER: ${DOCKER_USER:-1000} + user: ${DOCKER_USER:-1000} + image: people:development + environment: + - PYLINTHOME=/app/.pylint.d + - DJANGO_CONFIGURATION=Development + env_file: + - env.d/development/common + - env.d/development/postgresql + ports: + - "8071:8000" + volumes: + - ./src/backend:/app + - ./data/media:/data/media + - ./data/static:/data/static + depends_on: + - postgresql + - mailcatcher + - redis + + celery-dev: + user: ${DOCKER_USER:-1000} + image: people:development + command: ["celery", "-A", "people.celery_app", "worker", "-l", "DEBUG"] + environment: + - DJANGO_CONFIGURATION=Development + env_file: + - env.d/development/common + - env.d/development/postgresql + volumes: + - ./src/backend:/app + - ./data/media:/data/media + - ./data/static:/data/static + depends_on: + - app-dev + + app: + build: + context: . + target: production + args: + DOCKER_USER: ${DOCKER_USER:-1000} + user: ${DOCKER_USER:-1000} + image: people:production + environment: + - DJANGO_CONFIGURATION=ContinuousIntegration + env_file: + - env.d/development/common + - env.d/development/postgresql + volumes: + - ./data/media:/data/media + depends_on: + - postgresql + - redis + + celery: + user: ${DOCKER_USER:-1000} + image: people:production + command: ["celery", "-A", "people.celery_app", "worker", "-l", "INFO"] + environment: + - DJANGO_CONFIGURATION=ContinuousIntegration + env_file: + - env.d/development/common + - env.d/development/postgresql + depends_on: + - app + + nginx: + image: nginx:1.25 + ports: + - "8082:8082" + volumes: + - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro + - ./data/media:/data/media:ro + depends_on: + - app + + dockerize: + image: jwilder/dockerize + + crowdin: + image: crowdin/cli:3.5.2 + volumes: + - ".:/app" + env_file: + - env.d/development/crowdin + user: "${DOCKER_USER:-1000}" + working_dir: /app + + node: + image: node:18 + user: "${DOCKER_USER:-1000}" + environment: + HOME: /tmp + volumes: + - ".:/app" + + terraform-state: + image: hashicorp/terraform:1.6 + environment: + - TF_WORKSPACE=${PROJECT:-} # avoid env conflict in local state + user: ${DOCKER_USER:-1000} + working_dir: /app + volumes: + - ./src/terraform/create_state_bucket:/app + + terraform: + image: hashicorp/terraform:1.6 + user: ${DOCKER_USER:-1000} + working_dir: /app + volumes: + - ./src/terraform:/app diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf new file mode 100644 index 0000000..9e631a2 --- /dev/null +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -0,0 +1,19 @@ +server { + + listen 8082; + server_name localhost; + charset utf-8; + + location /media { + alias /data/media; + } + + location / { + proxy_pass http://app:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + +} + diff --git a/docker/files/usr/local/bin/entrypoint b/docker/files/usr/local/bin/entrypoint new file mode 100755 index 0000000..60e68e7 --- /dev/null +++ b/docker/files/usr/local/bin/entrypoint @@ -0,0 +1,35 @@ +#!/bin/sh +# +# The container user (see USER in the Dockerfile) is an un-privileged user that +# does not exists and is not created during the build phase (see Dockerfile). +# Hence, we use this entrypoint to wrap commands that will be run in the +# container to create an entry for this user in the /etc/passwd file. +# +# The following environment variables may be passed to the container to +# customize running user account: +# +# * USER_NAME: container user name (default: default) +# * HOME : container user home directory (default: none) +# +# To pass environment variables, you can either use the -e option of the docker run command: +# +# docker run --rm -e USER_NAME=foo -e HOME='/home/foo' people:latest python manage.py migrate +# +# or define new variables in an environment file to use with docker or docker compose: +# +# # env.d/production +# USER_NAME=foo +# HOME=/home/foo +# +# docker run --rm --env-file env.d/production people:latest python manage.py migrate +# + +echo "🐳(entrypoint) creating user running in the container..." +if ! whoami > /dev/null 2>&1; then + if [ -w /etc/passwd ]; then + echo "${USER_NAME:-default}:x:$(id -u):$(id -g):${USER_NAME:-default} user:${HOME}:/sbin/nologin" >> /etc/passwd + fi +fi + +echo "🐳(entrypoint) running your command: ${*}" +exec "$@" diff --git a/docker/files/usr/local/etc/gunicorn/people.py b/docker/files/usr/local/etc/gunicorn/people.py new file mode 100644 index 0000000..8fafd5c --- /dev/null +++ b/docker/files/usr/local/etc/gunicorn/people.py @@ -0,0 +1,16 @@ +# Gunicorn-django settings +bind = ["0.0.0.0:8000"] +name = "people" +python_path = "/app" + +# Run +graceful_timeout = 90 +timeout = 90 +workers = 3 + +# Logging +# Using '-' for the access log file makes gunicorn log accesses to stdout +accesslog = "-" +# Using '-' for the error log file makes gunicorn log errors to stderr +errorlog = "-" +loglevel = "info" diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 0000000..0bcaffb --- /dev/null +++ b/docs/models.md @@ -0,0 +1,235 @@ +# What is People? + +Space Odyssey is a dynamic organization. They use the People application to enhance teamwork and +streamline communication among their co-workers. Let's explore how this application helps them +interact efficiently. + +Let's see how we could interact with Django's shell to recreate David's environment in the app. + +## Base contacts from the organization records + +David Bowman is an exemplary employee at Space Odyssey Corporation. His email is +`david.bowman@spaceodyssey.com` and he is registered in the organization's records via a base +contact as follows: + +```python +david_base_contact = Contact.objects.create( + full_name="David Bowman", + short_name="David", + data={ + "emails": [ + {"type": "Work", "value": "david.bowman@spaceodyssey.com"}, + ], + "phones": [ + {"type": "Work", "value": "(123) 456-7890"}, + ], + "addresses": [ + { + "type": "Work", + "street": "123 Main St", + "city": "Cityville", + "state": "CA", + "zip": "12345", + "country": "USA", + } + ], + "links": [ + {"type": "Website", "value": "http://www.spaceodyssey.com"}, + {"type": "Twitter", "value": "https://www.twitter.com/dbowman"}, + ], + "organizations": [ + { + "name": "Space Odyssey Corporation", + "department": "IT", + "jobTitle": "AI Engineer", + }, + ], + } +) +``` + +When David logs-in to the People application for the first time using the corporation's OIDC +Single Sign-On service. A user is created for him on the fly by the system, together with an +identity record representing the OIDC session: + +```python +david_user = User.objects.create( + language="en-us", + timezone="America/Los_Angeles", +) +david_identity = Identity.objects.create( + "user": david_user, + "sub": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "email" : "david.bowman@spaceodyssey.com", + "is_main": True, +) +``` + +## Profile contact + +The system identifies Dave through the email associated with his OIDC session and prompts him to +confirm the details of the base contact stored in the database. + +When David confirms, giving an alternative short name that he prefers, a contact override is +created on top of the organization's base contact. This new contact is marked as David's profile +on the user: + +```python +david_contact = Contact.objects.create( + base=david_base_contact, + owner=david_user, + full_name="David Bowman", + short_name="Dave", + data={} +) +david_user.profile_contact = david_contact +david_user.save() +``` + +If Dave had not had any existing contact in the organization's records, the profile contact would +have been created independently, without any connection to a base contact: + +```python +david_contact = Contact.objects.create( + base=None, + owner=david_user, + full_name="David Bowman", + short_name="Dave", + data={} +) +``` + +Now, Dave feels like sharing his mobile phone number with his colleagues. He can do this +by editing his contact in the application: + +```python +contact.data["phones"] = [ + {"type": "Mobile", "value": "(123) 456-7890"}, +] +contact.save() +``` + +## Contact override + +During a Space conference he attended, Dave met Dr Ryan Stone, a medical engineer who gave him +her professional email address. Ryan is already present in the system but her email is missing. +Dave can add it to his private version of the contact: + +```python +ryan_base_contact = Contact.objects.create( + full_name="Ryan Stone", + data={} +) +ryan_contact = Contact.objects.create( + base=ryan_base_contact, + owner=david_user, + full_name="Ryan Stone", + short_name="Dr Ryan", + data={ + "emails": [ + {"type": "Work", "value": "ryan.stone@hubblestation.com"}, + ], + } +) +``` + +## Team Collaboration + +Dave wants to form a team with Ryan and other colleagues to work together better on using the organization's digital tools for their projects. + +Dave would like to create a team with Ryan and some other colleagues, to enhance collaboration +throughout their projects: + +```python +projectx = Team.objects.create(name="Project X") +``` + +A team can for example be used to create an email alias or to define role based access rights +(RBAC) in a specific application or all applications of the organization's digital Suite. + +Having created he team, Dave is automatically assigned the "owner" role. He invites Ryan, +granting an "administrator" role to her so she can invite her own colleagues. Both of them can +then proceed to invite other colleagues as simple members. If Ryan wants, she can upgrade a +colleague to "administrator" but only David can upgrade someone to the "owner" status: + +```python +TeamAccess.objects.create(user=david_user, team=projectx, role="owner") +TeamAccess.objects.create(user=ryan_user, team=projectx, role="administrator") +TeamAccess.objects.create(user=julie_user, team=projectx, role="member") +``` + +| Role | Member | Administrator | Owner | +|-----------------------------------|--------|---------------|-------| +| Can view team | ✔ | ✔ | ✔ | +| Can set roles except for owners | | ✔ | ✔ | +| Can set roles for owners | | | ✔ | +| Can delete team | | | ✔ | + +Importantly, the system ensures that there is always at least one owner left to maintain control +of the team. + +# Models overview + +The following graph represents the application's models and their relationships: + +```mermaid +erDiagram + %% Models + + Contact { + UUID id PK + Contact base + User owner + string full_name + string short_name + json data + DateTime created_at + DateTime updated_at + } + + User { + UUID id PK + Contact profile_contact + string language + string timezone + boolean is_device + boolean is_staff + boolean is_active + DateTime created_at + DateTime updated_at + } + + Identity { + UUID id PK + User user + string sub + Email email + boolean is_main + DateTime created_at + DateTime updated_at + } + + Team { + UUID id PK + string name + DateTime created_at + DateTime updated_at + } + + TeamAccess { + UUID id PK + Team team + User user + string role + DateTime created_at + DateTime updated_at + } + + %% Relations + User ||--o{ Contact : "owns" + Contact ||--o{ User : "profile for" + User ||--o{ TeamAccess : "" + Team ||--o{ TeamAccess : "" + Identity ||--o{ User : "connects" + Contact }o--|| Contact : "overrides" +``` diff --git a/docs/tsclient.md b/docs/tsclient.md new file mode 100644 index 0000000..75f18a3 --- /dev/null +++ b/docs/tsclient.md @@ -0,0 +1,25 @@ +# Api client TypeScript + +The backend application can automatically create a TypeScript client to be used in frontend +applications. It is used in the People front application itself. + +This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen) +and People's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running). + +## Requirements + +We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to +install the backend application. + +## Install openApiClientJs + +```sh +$ cd src/tsclient +$ yarn install +``` + +## Generate the client + +```sh +yarn generate:api:client:local +``` diff --git a/env.d/development/common.dist b/env.d/development/common.dist new file mode 100644 index 0000000..5d21bba --- /dev/null +++ b/env.d/development/common.dist @@ -0,0 +1,20 @@ +# Django +DJANGO_ALLOWED_HOSTS=* +DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly +DJANGO_SETTINGS_MODULE=people.settings +DJANGO_SUPERUSER_PASSWORD=admin + +# Python +PYTHONPATH=/app + +#JWT +DJANGO_JWT_PRIVATE_SIGNING_KEY=ThisIsAnExampleKeyForDevPurposeOnly + +# People settings + +# Mail +DJANGO_EMAIL_HOST="mailcatcher" +DJANGO_EMAIL_PORT=1025 + +# Backend url +PEOPLE_BASE_URL="http://localhost:8072" diff --git a/env.d/development/crowdin.dist b/env.d/development/crowdin.dist new file mode 100644 index 0000000..1218e3b --- /dev/null +++ b/env.d/development/crowdin.dist @@ -0,0 +1,3 @@ +CROWDIN_API_TOKEN=Your-Api-Token +CROWDIN_PROJECT_ID=Your-Project-Id +CROWDIN_BASE_PATH=/app/src diff --git a/env.d/development/postgresql.dist b/env.d/development/postgresql.dist new file mode 100644 index 0000000..f956e50 --- /dev/null +++ b/env.d/development/postgresql.dist @@ -0,0 +1,11 @@ +# Postgresql db container configuration +POSTGRES_DB=people +POSTGRES_USER=dinum +POSTGRES_PASSWORD=pass + +# App database configuration +DB_HOST=postgresql +DB_NAME=people +DB_USER=dinum +DB_PASSWORD=pass +DB_PORT=5432 \ No newline at end of file diff --git a/gitlint/gitlint_emoji.py b/gitlint/gitlint_emoji.py new file mode 100644 index 0000000..59c86ea --- /dev/null +++ b/gitlint/gitlint_emoji.py @@ -0,0 +1,37 @@ +""" +Gitlint extra rule to validate that the message title is of the form +"() " +""" +from __future__ import unicode_literals + +import re + +import requests + +from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation + + +class GitmojiTitle(LineRule): + """ + This rule will enforce that each commit title is of the form "() " + where gitmoji is an emoji from the list defined in https://gitmoji.carloscuesta.me and + subject should be all lowercase + """ + + id = "UC1" + name = "title-should-have-gitmoji-and-scope" + target = CommitMessageTitle + + def validate(self, title, _commit): + """ + Download the list possible gitmojis from the project's github repository and check that + title contains one of them. + """ + gitmojis = requests.get( + "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json" + ).json()["gitmojis"] + emojis = [item["emoji"] for item in gitmojis] + pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis)) + if not re.search(pattern, title): + violation_msg = 'Title does not match regex "() "' + return [RuleViolation(self.id, violation_msg, title)] diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..7f2a11e --- /dev/null +++ b/renovate.json @@ -0,0 +1,25 @@ +{ + "extends": [ + "github>numerique-gouv/renovate-configuration" + ], + "packageRules": [ + { + "enabled": false, + "groupName": "ignored python dependencies", + "matchManagers": [ + "setup-cfg" + ], + "matchPackageNames": [] + }, + { + "enabled": false, + "groupName": "ignored js dependencies", + "matchManagers": [ + "npm" + ], + "matchPackageNames": [ + "node" + ] + } + ] +} diff --git a/src/backend/.pylintrc b/src/backend/.pylintrc new file mode 100644 index 0000000..4416e3e --- /dev/null +++ b/src/backend/.pylintrc @@ -0,0 +1,472 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=migrations + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=0 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins=pylint_django,pylint.extensions.no_self_use + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=bad-inline-option, + deprecated-pragma, + django-not-configured, + file-ignored, + locally-disabled, + no-self-use, + raw-checker-failed, + suppressed-message, + useless-suppression + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=optparse.Values,sys.exit + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,responses, + Team,Contact + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=yes + +# Minimum lines number of a similarity. +# First implementations of CMS wizards have common fields we do not want to factorize for now +min-similarity-lines=35 + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + cm, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=builtins.Exception diff --git a/src/backend/MANIFEST.in b/src/backend/MANIFEST.in new file mode 100644 index 0000000..5b63440 --- /dev/null +++ b/src/backend/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include README.md +recursive-include src/backend/people *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2 diff --git a/src/backend/__init__.py b/src/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/core/__init__.py b/src/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py new file mode 100644 index 0000000..d1a9f05 --- /dev/null +++ b/src/backend/core/api/__init__.py @@ -0,0 +1,39 @@ +"""People core API endpoints""" +from django.conf import settings +from django.core.exceptions import ValidationError + +from rest_framework import exceptions as drf_exceptions +from rest_framework import views as drf_views +from rest_framework.decorators import api_view +from rest_framework.response import Response + + +def exception_handler(exc, context): + """Handle Django ValidationError as an accepted exception. + + For the parameters, see ``exception_handler`` + This code comes from twidi's gist: + https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f + """ + if isinstance(exc, ValidationError): + if hasattr(exc, "message_dict"): + detail = exc.message_dict + elif hasattr(exc, "message"): + detail = exc.message + elif hasattr(exc, "messages"): + detail = exc.messages + + exc = drf_exceptions.ValidationError(detail=detail) + + return drf_views.exception_handler(exc, context) + + +# pylint: disable=unused-argument +@api_view(["GET"]) +def get_frontend_configuration(request): + """Returns the frontend configuration dict as configured in settings.""" + frontend_configuration = { + "LANGUAGE_CODE": settings.LANGUAGE_CODE, + } + frontend_configuration.update(settings.FRONTEND_CONFIGURATION) + return Response(frontend_configuration) diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py new file mode 100644 index 0000000..8063144 --- /dev/null +++ b/src/backend/core/api/permissions.py @@ -0,0 +1,54 @@ +"""Permission handlers for the People core app.""" +from django.core import exceptions + +from rest_framework import permissions + + +class IsAuthenticated(permissions.BasePermission): + """ + Allows access only to authenticated users. Alternative method checking the presence + of the auth token to avoid hitting the database. + """ + + def has_permission(self, request, view): + return bool(request.auth) if request.auth else request.user.is_authenticated + + +class IsSelf(IsAuthenticated): + """ + Allows access only to authenticated users. Alternative method checking the presence + of the auth token to avoid hitting the database. + """ + + def has_object_permission(self, request, view, obj): + """Write permissions are only allowed to the user itself.""" + return obj == request.user + + +class IsOwnedOrPublic(IsAuthenticated): + """ + Allows access to authenticated users only for objects that are owned or not related to any user via* + the "owner" field. + """ + + def has_object_permission(self, request, view, obj): + """Unsafe permissions are only allowed for the owner of the object.""" + if obj.owner == request.user: + return True + + if request.method in permissions.SAFE_METHODS and obj.owner is None: + return True + + try: + return obj.user == request.user + except exceptions.ObjectDoesNotExist: + return False + + +class AccessPermission(IsAuthenticated): + """Permission class for access objects.""" + + def has_object_permission(self, request, view, obj): + """Check permission for a given object.""" + abilities = obj.get_abilities(request.user) + return abilities.get(request.method.lower(), False) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py new file mode 100644 index 0000000..0f2c1b0 --- /dev/null +++ b/src/backend/core/api/serializers.py @@ -0,0 +1,145 @@ +"""Client serializers for the People core app.""" +from drf_spectacular.utils import extend_schema_field +from rest_framework import exceptions, serializers +from timezone_field.rest_framework import TimeZoneSerializerField + +from core import models + + +class ContactSerializer(serializers.ModelSerializer): + """Serialize contacts.""" + + class Meta: + model = models.Contact + fields = [ + "id", + "base", + "data", + "full_name", + "owner", + "short_name", + ] + read_only_fields = ["id", "owner"] + + def update(self, instance, validated_data): + """Make "base" field readonly but only for update/patch.""" + validated_data.pop("base", None) + return super().update(instance, validated_data) + + +class UserSerializer(serializers.ModelSerializer): + """Serialize users.""" + + data = serializers.SerializerMethodField(read_only=True) + timezone = TimeZoneSerializerField(use_pytz=False, required=True) + + class Meta: + model = models.User + fields = [ + "id", + "data", + "language", + "timezone", + "is_device", + "is_staff", + ] + read_only_fields = ["id", "data", "is_device", "is_staff"] + + def get_data(self, user) -> dict: + """Return contact data for the user.""" + return user.profile_contact.data if user.profile_contact else {} + + +class TeamAccessSerializer(serializers.ModelSerializer): + """Serialize team accesses.""" + + abilities = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.TeamAccess + fields = ["id", "user", "role", "abilities"] + read_only_fields = ["id", "abilities"] + + def update(self, instance, validated_data): + """Make "user" field is readonly but only on update.""" + validated_data.pop("user", None) + return super().update(instance, validated_data) + + def get_abilities(self, access) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return access.get_abilities(request.user) + return {} + + def validate(self, attrs): + """ + Check access rights specific to writing (create/update) + """ + request = self.context.get("request") + user = getattr(request, "user", None) + role = attrs.get("role") + + # Update + if self.instance: + can_set_role_to = self.instance.get_abilities(user)["set_role_to"] + + if role and role not in can_set_role_to: + message = ( + f"You are only allowed to set role to {', '.join(can_set_role_to)}" + if can_set_role_to + else "You are not allowed to set this role for this team." + ) + raise exceptions.PermissionDenied(message) + + # Create + else: + try: + team_id = self.context["team_id"] + except KeyError as exc: + raise exceptions.ValidationError( + "You must set a team ID in kwargs to create a new team access." + ) from exc + + if not models.TeamAccess.objects.filter( + team=team_id, + user=user, + role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN], + ).exists(): + raise exceptions.PermissionDenied( + "You are not allowed to manage accesses for this team." + ) + + if ( + role == models.RoleChoices.OWNER + and not models.TeamAccess.objects.filter( + team=team_id, + user=user, + role=models.RoleChoices.OWNER, + ).exists() + ): + raise exceptions.PermissionDenied( + "Only owners of a team can assign other users as owners." + ) + + attrs["team_id"] = self.context["team_id"] + return attrs + + +class TeamSerializer(serializers.ModelSerializer): + """Serialize teams.""" + + abilities = serializers.SerializerMethodField(read_only=True) + accesses = TeamAccessSerializer(many=True, read_only=True) + + class Meta: + model = models.Team + fields = ["id", "name", "accesses", "abilities"] + read_only_fields = ["id", "accesses", "abilities"] + + def get_abilities(self, team) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return team.get_abilities(request.user) + return {} diff --git a/src/backend/core/api/utils.py b/src/backend/core/api/utils.py new file mode 100644 index 0000000..5c2ee8d --- /dev/null +++ b/src/backend/core/api/utils.py @@ -0,0 +1,18 @@ +""" +Utils that can be useful throughout the People core app +""" +from django.conf import settings +from django.utils import timezone + +import jwt +from rest_framework_simplejwt.tokens import RefreshToken + + +def get_tokens_for_user(user): + """Get JWT tokens for user authentication.""" + refresh = RefreshToken.for_user(user) + + return { + "refresh": str(refresh), + "access": str(refresh.access_token), + } diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py new file mode 100644 index 0000000..ae8971b --- /dev/null +++ b/src/backend/core/api/viewsets.py @@ -0,0 +1,364 @@ +"""API endpoints""" +import io +import uuid +from http import HTTPStatus + +from django.contrib.postgres.search import TrigramSimilarity +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.db.models import ( + CharField, + Count, + F, + Func, + OuterRef, + Prefetch, + Q, + Subquery, + TextField, + Value, + functions, +) + +from rest_framework import decorators, mixins, pagination, response, viewsets +from rest_framework import permissions as drf_permissions +from rest_framework.exceptions import ValidationError as DRFValidationError + +from core import enums, models + +from . import permissions, serializers + + +class NestedGenericViewSet(viewsets.GenericViewSet): + """ + A generic Viewset aims to be used in a nested route context. + e.g: `/api/v1.0/resource_1//resource_2//` + + It allows to define all url kwargs and lookup fields to perform the lookup. + """ + + lookup_fields: list[str] = ["pk"] + lookup_url_kwargs: list[str] = [] + + def __getattribute__(self, item): + """ + This method is overridden to allow to get the last lookup field or lookup url kwarg + when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful + to keep compatibility with all methods used by the parent class `GenericViewSet`. + """ + if item in ["lookup_field", "lookup_url_kwarg"]: + return getattr(self, item + "s", [None])[-1] + + return super().__getattribute__(item) + + def get_queryset(self): + """ + Get the list of items for this view. + + `lookup_fields` attribute is enumerated here to perform the nested lookup. + """ + queryset = super().get_queryset() + + # The last lookup field is removed to perform the nested lookup as it corresponds + # to the object pk, it is used within get_object method. + lookup_url_kwargs = ( + self.lookup_url_kwargs[:-1] + if self.lookup_url_kwargs + else self.lookup_fields[:-1] + ) + + filter_kwargs = {} + for index, lookup_url_kwarg in enumerate(lookup_url_kwargs): + if lookup_url_kwarg not in self.kwargs: + raise KeyError( + f"Expected view {self.__class__.__name__} to be called with a URL " + f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or ' + "set the `.lookup_fields` attribute on the view correctly." + ) + + filter_kwargs.update( + {self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]} + ) + + return queryset.filter(**filter_kwargs) + + +class SerializerPerActionMixin: + """ + A mixin to allow to define serializer classes for each action. + + This mixin is useful to avoid to define a serializer class for each action in the + `get_serializer_class` method. + """ + + serializer_classes: dict[str, type] = {} + default_serializer_class: type = None + + def get_serializer_class(self): + """ + Return the serializer class to use depending on the action. + """ + return self.serializer_classes.get(self.action, self.default_serializer_class) + + +class Pagination(pagination.PageNumberPagination): + """Pagination to display no more than 100 objects per page sorted by creation date.""" + + ordering = "-created_on" + max_page_size = 100 + page_size_query_param = "page_size" + + +class ContactViewSet( + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """Contact ViewSet""" + + permission_classes = [permissions.IsOwnedOrPublic] + queryset = models.Contact.objects.all() + serializer_class = serializers.ContactSerializer + + def list(self, request, *args, **kwargs): + """Limit listed users by a query with throttle protection.""" + user = self.request.user + queryset = self.filter_queryset(self.get_queryset()) + + if not user.is_authenticated: + return queryset.none() + + # Exclude contacts that: + queryset = queryset.filter( + # - belong to another user (keep public and owned contacts) + Q(owner__isnull=True) | Q(owner=user), + # - are profile contacts for a user + user__isnull=True, + # - are overriden base contacts + overriding_contacts__isnull=True, + ) + + # Search by case-insensitive and accent-insensitive trigram similarity + if query := self.request.GET.get("q", ""): + query = Func(Value(query), function="unaccent") + similarity = TrigramSimilarity( + Func("full_name", function="unaccent"), + query, + ) + TrigramSimilarity(Func("short_name", function="unaccent"), query) + queryset = ( + queryset.annotate(similarity=similarity) + .filter( + similarity__gte=0.05 + ) # Value determined by testing (test_api_contacts.py) + .order_by("-similarity") + ) + + # Throttle protection + key_base = f"throttle-contact-list-{user.id!s}" + key_minute = f"{key_base:s}-minute" + key_hour = f"{key_base:s}-hour" + key_day = f"{key_base:s}-day" + + try: + count_minute = cache.incr(key_minute) + except ValueError: + cache.set(key_minute, 1, 60) + count_minute = 1 + + try: + count_hour = cache.incr(key_hour) + except ValueError: + cache.set(key_hour, 1, 3600) + count_hour = 1 + + try: + count_day = cache.incr(key_day) + except ValueError: + cache.set(key_day, 1, 86400) + count_day = 1 + + if count_minute > 20 or count_hour > 150 or count_day > 500: + raise drf_exceptions.Throttled() + + serializer = self.get_serializer(queryset, many=True) + return response.Response(serializer.data) + + def perform_create(self, serializer): + """Set the current user as owner of the newly created contact.""" + user = self.request.user + serializer.validated_data["owner"] = user + return super().perform_create(serializer) + + +class UserViewSet( + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """User ViewSet""" + + permission_classes = [permissions.IsSelf] + queryset = models.User.objects.all().select_related("profile_contact") + serializer_class = serializers.UserSerializer + + @decorators.action( + detail=False, + methods=["get"], + url_name="me", + url_path="me", + permission_classes=[permissions.IsAuthenticated], + ) + def get_me(self, request): + """ + Return information on currently logged user + """ + context = {"request": request} + return response.Response( + self.serializer_class(request.user, context=context).data + ) + + +class TeamViewSet( + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """Team ViewSet""" + + permission_classes = [permissions.AccessPermission] + serializer_class = serializers.TeamSerializer + queryset = models.Team.objects.all() + + def get_queryset(self): + """Custom queryset to get user related teams.""" + user_role_query = models.TeamAccess.objects.filter( + user=self.request.user, team=OuterRef("pk") + ).values("role")[:1] + return models.Team.objects.filter(accesses__user=self.request.user).annotate( + user_role=Subquery(user_role_query) + ) + + def perform_create(self, serializer): + """Set the current user as owner of the newly created team.""" + team = serializer.save() + models.TeamAccess.objects.create( + team=team, + user=self.request.user, + role=models.RoleChoices.OWNER, + ) + + +class TeamAccessViewSet( + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """ + API ViewSet for all interactions with team accesses. + + GET /api/v1.0/teams//accesses/: + Return list of all team accesses related to the logged-in user or one + team access if an id is provided. + + POST /api/v1.0/teams//accesses/ with expected data: + - user: str + - role: str [owner|admin|member] + Return newly created team access + + PUT /api/v1.0/teams//accesses// with expected data: + - role: str [owner|admin|member] + Return updated team access + + PATCH /api/v1.0/teams//accesses// with expected data: + - role: str [owner|admin|member] + Return partially updated team access + + DELETE /api/v1.0/teams//accesses// + Delete targeted team access + """ + + lookup_field = "pk" + pagination_class = Pagination + permission_classes = [permissions.AccessPermission] + queryset = models.TeamAccess.objects.all().select_related("user") + serializer_class = serializers.TeamAccessSerializer + + def get_permissions(self): + """User only needs to be authenticated to list team accesses""" + if self.action == "list": + permission_classes = [permissions.IsAuthenticated] + else: + return super().get_permissions() + + return [permission() for permission in permission_classes] + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["team_id"] = self.kwargs["team_id"] + return context + + def get_queryset(self): + """Return the queryset according to the action.""" + queryset = super().get_queryset() + queryset = queryset.filter(team=self.kwargs["team_id"]) + + if self.action == "list": + # Limit to team access instances related to a team THAT also has a team access + # instance for the logged-in user (we don't want to list only the team access + # instances pointing to the logged-in user) + user_role_query = models.TeamAccess.objects.filter( + team__accesses__user=self.request.user + ).values("role")[:1] + queryset = ( + queryset.filter( + team__accesses__user=self.request.user, + ) + .annotate(user_role=Subquery(user_role_query)) + .distinct() + ) + return queryset + + def destroy(self, request, *args, **kwargs): + """Forbid deleting the last owner access""" + instance = self.get_object() + team = instance.team + + # Check if the access being deleted is the last owner access for the team + if instance.role == "owner" and team.accesses.filter(role="owner").count() == 1: + return Response( + {"detail": "Cannot delete the last owner access for the team."}, + status=400, + ) + + return super().destroy(request, *args, **kwargs) + + def perform_update(self, serializer): + """Check that we don't change the role if it leads to losing the last owner.""" + instance = serializer.instance + + # Check if the role is being updated and the new role is not "owner" + if ( + "role" in self.request.data + and self.request.data["role"] != models.RoleChoices.OWNER + ): + team = instance.team + # Check if the access being updated is the last owner access for the team + if ( + instance.role == models.RoleChoices.OWNER + and team.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 + ): + raise serializers.ValidationError( + { + "role": "Cannot change the role to a non-owner role for the last owner access." + } + ) + + serializer.save() diff --git a/src/backend/core/apps.py b/src/backend/core/apps.py new file mode 100644 index 0000000..d7da9f8 --- /dev/null +++ b/src/backend/core/apps.py @@ -0,0 +1,11 @@ +"""People Core application""" +# from django.apps import AppConfig +# from django.utils.translation import gettext_lazy as _ + + +# class CoreConfig(AppConfig): +# """Configuration class for the People core app.""" + +# name = "core" +# app_label = "core" +# verbose_name = _("People core application") diff --git a/src/backend/core/authentication.py b/src/backend/core/authentication.py new file mode 100644 index 0000000..7df6b7e --- /dev/null +++ b/src/backend/core/authentication.py @@ -0,0 +1,62 @@ +"""Authentication for the People core app.""" +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.functional import SimpleLazyObject +from django.utils.module_loading import import_string +from django.utils.translation import get_supported_language_variant +from django.utils.translation import gettext_lazy as _ + +from drf_spectacular.authentication import SessionScheme, TokenScheme +from drf_spectacular.plumbing import build_bearer_security_scheme_object +from rest_framework import authentication +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken + + +class DelegatedJWTAuthentication(JWTAuthentication): + """Override JWTAuthentication to create missing users on the fly.""" + + def get_user(self, validated_token): + """ + Return the user related to the given validated token, creating or updating it if necessary. + """ + get_user = import_string(settings.JWT_USER_GETTER) + return SimpleLazyObject(lambda: get_user(validated_token)) + + +class OpenApiJWTAuthenticationExtension(TokenScheme): + """Extension for specifying JWT authentication schemes.""" + + target_class = "core.authentication.DelegatedJWTAuthentication" + name = "DelegatedJWTAuthentication" + + def get_security_definition(self, auto_schema): + """Return the security definition for JWT authentication.""" + return build_bearer_security_scheme_object( + header_name="Authorization", + token_prefix="Bearer", # noqa S106 + ) + + +class SessionAuthenticationWithAuthenticateHeader(authentication.SessionAuthentication): + """ + This class is needed, because REST Framework's default SessionAuthentication does + never return 401's, because they cannot fill the WWW-Authenticate header with a + valid value in the 401 response. As a result, we cannot distinguish calls that are + not unauthorized (401 unauthorized) and calls for which the user does not have + permission (403 forbidden). + See https://github.com/encode/django-rest-framework/issues/5968 + + We do set authenticate_header function in SessionAuthentication, so that a value + for the WWW-Authenticate header can be retrieved and the response code is + automatically set to 401 in case of unauthenticated requests. + """ + + def authenticate_header(self, request): + return "Session" + + +class OpenApiSessionAuthenticationExtension(SessionScheme): + """Extension for specifying session authentication schemes.""" + + target_class = "core.api.authentication.SessionAuthenticationWithAuthenticateHeader" diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py new file mode 100644 index 0000000..f4e0e11 --- /dev/null +++ b/src/backend/core/enums.py @@ -0,0 +1,15 @@ +""" +Core application enums declaration +""" +from django.conf import global_settings, settings +from django.utils.translation import gettext_lazy as _ + +# Django sets `LANGUAGES` by default with all supported languages. We can use it for +# the choice of languages which should not be limited to the few languages active in +# the app. +# pylint: disable=no-member +ALL_LANGUAGES = getattr( + settings, + "ALL_LANGUAGES", + [(language, _(name)) for language, name in global_settings.LANGUAGES], +) diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py new file mode 100644 index 0000000..e82a675 --- /dev/null +++ b/src/backend/core/factories.py @@ -0,0 +1,177 @@ +# ruff: noqa: S311 +""" +Core application factories +""" +import hashlib +import json +import random +from datetime import datetime, timedelta, timezone + +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.utils import timezone as django_timezone + +import factory.fuzzy +from faker import Faker + +from core import enums, models + +fake = Faker() + + +class BaseContactFactory(factory.django.DjangoModelFactory): + """A factory to create contacts for a user""" + + class Meta: + model = models.Contact + + full_name = factory.Faker("name") + short_name = factory.LazyAttributeSequence( + lambda o, n: o.full_name.split()[0] if o.full_name else f"user{n!s}" + ) + + data = factory.Dict( + { + "emails": factory.LazyAttribute( + lambda x: [ + { + "type": fake.random_element(["Home", "Work", "Other"]), + "value": fake.email(), + } + for _ in range(fake.random_int(1, 3)) + ] + ), + "phones": factory.LazyAttribute( + lambda x: [ + { + "type": fake.random_element( + [ + "Mobile", + "Home", + "Work", + "Main", + "Work Fax", + "Home Fax", + "Pager", + "Other", + ] + ), + "value": fake.phone_number(), + } + for _ in range(fake.random_int(1, 3)) + ] + ), + "addresses": factory.LazyAttribute( + lambda x: [ + { + "type": fake.random_element(["Home", "Work", "Other"]), + "street": fake.street_address(), + "city": fake.city(), + "state": fake.state(), + "zip": fake.zipcode(), + "country": fake.country(), + } + for _ in range(fake.random_int(1, 3)) + ] + ), + "links": factory.LazyAttribute( + lambda x: [ + { + "type": fake.random_element( + [ + "Profile", + "Blog", + "Website", + "Twitter", + "Facebook", + "Instagram", + "LinkedIn", + "Other", + ] + ), + "value": fake.url(), + } + for _ in range(fake.random_int(1, 3)) + ] + ), + "customFields": factory.LazyAttribute( + lambda x: { + f"custom_field_{i:d}": fake.word() + for i in range(fake.random_int(1, 3)) + }, + ), + "organizations": factory.LazyAttribute( + lambda x: [ + { + "name": fake.company(), + "department": fake.word(), + "jobTitle": fake.job(), + } + for _ in range(fake.random_int(1, 3)) + ] + ), + } + ) + + +class ContactFactory(BaseContactFactory): + """A factory to create contacts for a user""" + + class Meta: + model = models.Contact + + base = factory.SubFactory("core.factories.ContactFactory", base=None, owner=None) + owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None) + + +class UserFactory(factory.django.DjangoModelFactory): + """A factory to random users for testing purposes.""" + + class Meta: + model = models.User + + language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES]) + password = make_password("password") + + +class IdentityFactory(factory.django.DjangoModelFactory): + """A factory to create identities for a user""" + + class Meta: + model = models.Identity + django_get_or_create = ("sub",) + + user = factory.SubFactory(UserFactory) + sub = factory.Sequence(lambda n: f"user{n!s}") + email = factory.Faker("email") + + +class TeamFactory(factory.django.DjangoModelFactory): + """A factory to create teams""" + + class Meta: + model = models.Team + django_get_or_create = ("name",) + + name = factory.Sequence(lambda n: f"team{n}") + + @factory.post_generation + def users(self, create, extracted, **kwargs): + """Add users to team from a given list of users with or without roles.""" + if create and extracted: + for item in extracted: + if isinstance(item, models.User): + TeamAccessFactory(team=self, user=item) + else: + TeamAccessFactory(team=self, user=item[0], role=item[1]) + + +class TeamAccessFactory(factory.django.DjangoModelFactory): + """Create fake team user accesses for testing.""" + + class Meta: + model = models.TeamAccess + + team = factory.SubFactory(TeamFactory) + user = factory.SubFactory(UserFactory) + role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) diff --git a/src/backend/core/jsonschema/contact_data.json b/src/backend/core/jsonschema/contact_data.json new file mode 100644 index 0000000..ebdf3bb --- /dev/null +++ b/src/backend/core/jsonschema/contact_data.json @@ -0,0 +1,130 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Contact Information", + "properties": { + "emails": { + "type": "array", + "title": "Emails", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "Type", + "enum": ["Work", "Home", "Other"] + }, + "value": { + "type": "string", + "title": "Email Address", + "format": "email" + } + }, + "required": ["type", "value"] + } + }, + "phones": { + "type": "array", + "title": "Phones", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "Type", + "enum": ["Mobile", "Home", "Work", "Main", "Work Fax", "Home Fax", "Pager", "Other"] + }, + "value": { + "type": "string", + "title": "Phone Number" + } + }, + "required": ["type", "value"] + } + }, + "addresses": { + "type": "array", + "title": "Addresses", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "Type", + "enum": ["Home", "Work", "Other"] + }, + "street": { + "type": "string", + "title": "Street" + }, + "city": { + "type": "string", + "title": "City" + }, + "state": { + "type": "string", + "title": "State" + }, + "zip": { + "type": "string", + "title": "ZIP Code" + }, + "country": { + "type": "string", + "title": "Country" + } + } + } + }, + "links": { + "type": "array", + "title": "Links", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "Type", + "enum": ["Profile", "Blog", "Website", "Twitter", "Facebook", "Instagram", "LinkedIn", "Other"] + }, + "value": { + "type": "string", + "title": "URL", + "format": "uri" + } + }, + "required": ["type", "value"] + } + }, + "customFields": { + "type": "object", + "title": "Custom Fields", + "additionalProperties": { + "type": "string" + } + }, + "organizations": { + "type": "array", + "title": "Organizations", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Organization Name" + }, + "department": { + "type": "string", + "title": "Department" + }, + "jobTitle": { + "type": "string", + "title": "Job Title" + } + }, + "required": ["name"] + } + } + }, + "additionalProperties": false +} diff --git a/src/backend/core/migrations/0001_initial.py b/src/backend/core/migrations/0001_initial.py new file mode 100644 index 0000000..8d3af27 --- /dev/null +++ b/src/backend/core/migrations/0001_initial.py @@ -0,0 +1,147 @@ +# Generated by Django 5.0 on 2023-12-31 17:11 + +import core.models +import django.core.validators +import django.db.models.deletion +import timezone_field.fields +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.RunSQL('CREATE EXTENSION IF NOT EXISTS pg_trgm;', 'DROP EXTENSION IF EXISTS pg_trgm;'), + migrations.RunSQL('CREATE EXTENSION IF NOT EXISTS unaccent;', 'DROP EXTENSION IF EXISTS unaccent;'), + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('name', models.CharField(max_length=100)), + ], + options={ + 'verbose_name': 'Team', + 'verbose_name_plural': 'Teams', + 'db_table': 'people_team', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')), + ('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)), + ('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')), + ('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'db_table': 'people_user', + }, + managers=[ + ('objects', core.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('full_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='full name')), + ('short_name', models.CharField(blank=True, max_length=30, null=True, verbose_name='short name')), + ('data', models.JSONField(blank=True, help_text='A JSON object containing the contact information', verbose_name='contact information')), + ('base', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='overriding_contacts', to='core.contact')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'contact', + 'verbose_name_plural': 'contacts', + 'db_table': 'people_contact', + 'ordering': ('full_name', 'short_name'), + }, + ), + migrations.AddField( + model_name='user', + name='profile_contact', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='core.contact'), + ), + migrations.CreateModel( + name='Identity', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')), + ('email', models.EmailField(max_length=254, verbose_name='email address')), + ('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'identity', + 'verbose_name_plural': 'identities', + 'db_table': 'people_identity', + 'ordering': ('-is_main', 'email'), + }, + ), + migrations.CreateModel( + name='TeamAccess', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.team')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Team/user relation', + 'verbose_name_plural': 'Team/user relations', + 'db_table': 'people_team_access', + }, + ), + migrations.AddField( + model_name='team', + name='users', + field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', to=settings.AUTH_USER_MODEL), + ), + migrations.AddConstraint( + model_name='contact', + constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'), + ), + migrations.AddConstraint( + model_name='contact', + constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'), + ), + migrations.AlterUniqueTogether( + name='contact', + unique_together={('owner', 'base')}, + ), + migrations.AddConstraint( + model_name='identity', + constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'), + ), + migrations.AddConstraint( + model_name='teamaccess', + constraint=models.UniqueConstraint(fields=('user', 'team'), name='unique_team_user', violation_error_message='This user is already in this team.'), + ), + ] diff --git a/src/backend/core/migrations/__init__.py b/src/backend/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/core/models.py b/src/backend/core/models.py new file mode 100644 index 0000000..f31afe3 --- /dev/null +++ b/src/backend/core/models.py @@ -0,0 +1,477 @@ +""" +Declare and configure the models for the People core application +""" +import json +import os +import uuid +from datetime import timedelta +from zoneinfo import ZoneInfo + +from django.conf import settings +from django.contrib.auth import models as auth_models +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex +from django.core import exceptions, mail, validators +from django.db import models +from django.db.models import F, Q +from django.utils import timezone as timezone_util +from django.utils.functional import lazy +from django.utils.text import capfirst, slugify +from django.utils.translation import gettext_lazy as _ + +import jsonschema +from dateutil.relativedelta import relativedelta +from rest_framework_simplejwt.settings import api_settings +from timezone_field import TimeZoneField + +current_dir = os.path.dirname(os.path.abspath(__file__)) +contact_schema_path = os.path.join(current_dir, "jsonschema", "contact_data.json") +with open(contact_schema_path, "r") as contact_schema_file: + contact_schema = json.load(contact_schema_file) + + +class RoleChoices(models.TextChoices): + """Defines the possible roles a user can have in a team.""" + + MEMBER = "member", _("Member") + ADMIN = "administrator", _("Administrator") + OWNER = "owner", _("Owner") + + +class BaseModel(models.Model): + """ + Serves as an abstract base model for other models, ensuring that records are validated + before saving as Django doesn't do it by default. + + Includes fields common to all models: a UUID primary key and creation/update timestamps. + """ + + id = models.UUIDField( + verbose_name=_("id"), + help_text=_("primary key for the record as UUID"), + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + created_at = models.DateTimeField( + verbose_name=_("created on"), + help_text=_("date and time at which a record was created"), + auto_now_add=True, + editable=False, + ) + updated_at = models.DateTimeField( + verbose_name=_("updated on"), + help_text=_("date and time at which a record was last updated"), + auto_now=True, + editable=False, + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + """Call `full_clean` before saving.""" + self.full_clean() + super().save(*args, **kwargs) + + +class Contact(BaseModel): + """User contacts""" + + base = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + related_name="overriding_contacts", + null=True, + blank=True, + ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="contacts", + null=True, + blank=True, + ) + full_name = models.CharField(_("full name"), max_length=150, null=True, blank=True) + short_name = models.CharField(_("short name"), max_length=30, null=True, blank=True) + + # avatar = + # notes = + data = models.JSONField( + _("contact information"), + help_text=_("A JSON object containing the contact information"), + blank=True, + ) + + class Meta: + db_table = "people_contact" + # indexes = [ + # GinIndex( + # fields=["full_name", "short_name"], + # name="names_gin_trgm_idx", + # opclasses=['gin_trgm_ops', 'gin_trgm_ops'] + # ), + # ] + ordering = ("full_name", "short_name") + verbose_name = _("contact") + verbose_name_plural = _("contacts") + unique_together = ("owner", "base") + constraints = [ + models.CheckConstraint( + check=~models.Q(base__isnull=False, owner__isnull=True), + name="base_owner_constraint", + violation_error_message="A contact overriding a base contact must be owned.", + ), + models.CheckConstraint( + check=~models.Q(base=models.F("id")), + name="base_not_self", + violation_error_message="A contact cannot be based on itself.", + ), + ] + + def __str__(self): + return self.full_name or self.short_name + + def clean(self): + """Validate fields.""" + super().clean() + + # Check if the contact points to a base contact that itself points to another base contact + if self.base_id and self.base.base_id: + raise exceptions.ValidationError( + "A contact cannot point to a base contact that itself points to another base contact." + ) + + # Validate the content of the "data" field against our jsonschema definition + try: + jsonschema.validate(self.data, contact_schema) + except jsonschema.ValidationError as e: + # Specify the property in the data in which the error occured + field_path = ".".join(map(str, e.path)) + error_message = f"Validation error in '{field_path:s}': {e.message}" + raise exceptions.ValidationError({"data": [error_message]}) from e + + +class UserManager(auth_models.UserManager): + """ + Override user manager to get the related contact in the same query by default (Contact model) + """ + + def get_queryset(self): + return super().get_queryset().select_related("profile_contact") + + +class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): + """User model to work with OIDC only authentication.""" + + profile_contact = models.OneToOneField( + Contact, + on_delete=models.SET_NULL, + related_name="user", + blank=True, + null=True, + ) + language = models.CharField( + max_length=10, + choices=lazy(lambda: settings.LANGUAGES, tuple)(), + default=settings.LANGUAGE_CODE, + verbose_name=_("language"), + help_text=_("The language in which the user wants to see the interface."), + ) + timezone = TimeZoneField( + choices_display="WITH_GMT_OFFSET", + use_pytz=False, + default=settings.TIME_ZONE, + help_text=_("The timezone in which the user wants to see times."), + ) + is_device = models.BooleanField( + _("device"), + default=False, + help_text=_("Whether the user is a device or a real user."), + ) + is_staff = models.BooleanField( + _("staff status"), + default=False, + help_text=_("Whether the user can log into this admin site."), + ) + is_active = models.BooleanField( + _("active"), + default=True, + help_text=_( + "Whether this user should be treated as active. " + "Unselect this instead of deleting accounts." + ), + ) + + objects = UserManager() + + USERNAME_FIELD = "id" + REQUIRED_FIELDS = [] + + class Meta: + db_table = "people_user" + verbose_name = _("user") + verbose_name_plural = _("users") + + def __str__(self): + return str(self.profile_contact) if self.profile_contact else str(self.id) + + def clean(self): + """Validate fields.""" + super().clean() + + if self.profile_contact_id and not self.profile_contact.owner == self: + raise exceptions.ValidationError( + "Users can only declare as profile a contact they own." + ) + + def email_user(self, subject, message, from_email=None, **kwargs): + """Send an email to this user.""" + main_identity = self.identities.get(is_main=True) + mail.send_mail(subject, message, from_email, [main_identity.email], **kwargs) + + @classmethod + def get_email_field_name(cls): + """ + Raise error when trying to get email field name from the user as we are using + a separate Email model to allow several emails per user. + """ + raise NotImplementedError( + "This feature is deactivated to allow several emails per user." + ) + + +class Identity(BaseModel): + """User identity""" + + sub_validator = validators.RegexValidator( + regex=r"^[\w.@+-]+\Z", + message=_( + "Enter a valid sub. This value may contain only letters, " + "numbers, and @/./+/-/_ characters." + ), + ) + + user = models.ForeignKey(User, related_name="identities", on_delete=models.CASCADE) + sub = models.CharField( + _("sub"), + help_text=_( + "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only." + ), + max_length=255, + unique=True, + validators=[sub_validator], + ) + email = models.EmailField(_("email address")) + is_main = models.BooleanField( + _("main"), + default=False, + help_text=_("Designates whether the email is the main one."), + ) + + class Meta: + db_table = "people_identity" + ordering = ("-is_main", "email") + verbose_name = _("identity") + verbose_name_plural = _("identities") + constraints = [ + # Uniqueness + models.UniqueConstraint( + fields=["user", "email"], + name="unique_user_email", + violation_error_message=_( + "This email address is already declared for this user." + ), + ), + ] + + def __str__(self): + main_str = "[main]" if self.is_main else "" + return f"{self.email:s}{main_str:s}" + + def clean(self): + """Normalize the email field and clean the 'is_main' field.""" + self.email = User.objects.normalize_email(self.email) + if not self.user.identities.exclude(pk=self.pk).filter(is_main=True).exists(): + if not self.created_at: + self.is_main = True + elif not self.is_main: + raise exceptions.ValidationError( + {"is_main": "A user should have one and only one main identity."} + ) + super().clean() + + def save(self, *args, **kwargs): + """Ensure users always have one and only one main identity.""" + super().save(*args, **kwargs) + if self.is_main is True: + self.user.identities.exclude(id=self.id).update(is_main=False) + + +class Team(BaseModel): + """ + Represents the link between teams and users, specifying the role a user has in a team. + """ + + name = models.CharField(max_length=100) + + users = models.ManyToManyField( + User, + through="TeamAccess", + through_fields=("team", "user"), + related_name="teams", + ) + + class Meta: + db_table = "people_team" + ordering = ("name",) + verbose_name = _("Team") + verbose_name_plural = _("Teams") + + def __str__(self): + return self.name + + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the team. + """ + is_owner_or_admin = False + role = None + + if user.is_authenticated: + try: + role = self.user_role + except AttributeError: + try: + role = self.accesses.filter(user=user).values("role")[0]["role"] + except (TeamAccess.DoesNotExist, IndexError): + role = None + + is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN] + + return { + "get": True, + "patch": is_owner_or_admin, + "put": is_owner_or_admin, + "delete": role == RoleChoices.OWNER, + "manage_accesses": is_owner_or_admin, + } + + +class TeamAccess(BaseModel): + """Link table between teams and contacts.""" + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="accesses", + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="accesses", + ) + role = models.CharField( + max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER + ) + + class Meta: + db_table = "people_team_access" + verbose_name = _("Team/user relation") + verbose_name_plural = _("Team/user relations") + constraints = [ + models.UniqueConstraint( + fields=["user", "team"], + name="unique_team_user", + violation_error_message=_("This user is already in this team."), + ), + ] + + def __str__(self): + return f"{self.user!s} is {self.role:s} in team {self.team!s}" + + def get_abilities(self, user): + """ + Compute and return abilities for a given user taking into account + the current state of the object. + """ + is_team_owner_or_admin = False + role = None + + if user.is_authenticated: + try: + role = self.user_role + except AttributeError: + try: + role = self._meta.model.objects.filter( + team=self.team_id, user=user + ).values("role")[0]["role"] + except (self._meta.model.DoesNotExist, IndexError): + role = None + + is_team_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN] + + if self.role == RoleChoices.OWNER: + can_delete = ( + user.id == self.user_id + and self.team.accesses.filter(role=RoleChoices.OWNER).count() > 1 + ) + set_role_to = [RoleChoices.ADMIN, RoleChoices.MEMBER] if can_delete else [] + else: + can_delete = is_team_owner_or_admin + set_role_to = [] + if role == RoleChoices.OWNER: + set_role_to.append(RoleChoices.OWNER) + if is_team_owner_or_admin: + set_role_to.extend([RoleChoices.ADMIN, RoleChoices.MEMBER]) + + # Remove the current role as we don't want to propose it as an option + try: + set_role_to.remove(self.role) + except ValueError: + pass + + return { + "delete": can_delete, + "get": bool(role), + "patch": bool(set_role_to), + "put": bool(set_role_to), + "set_role_to": set_role_to, + } + + +def oidc_user_getter(validated_token): + """ + Given a valid OIDC token , retrieve, create or update corresponding user/contact/email from db. + + The token is expected to have the following fields in payload: + - sub + - email + - ... + """ + try: + user_id = validated_token[api_settings.USER_ID_CLAIM] + except KeyError as exc: + raise InvalidToken( + _("Token contained no recognizable user identification") + ) from exc + + try: + user = User.objects.select_related("profile_contact").get( + **{api_settings.USER_ID_FIELD: user_id} + ) + except User.DoesNotExist: + contact = Contact.objects.create() + user = User.objects.create( + **{api_settings.USER_ID_FIELD: user_id}, profile_contact=contact + ) + + # If the identity in the token is seen for the first time, make it the main email. Otherwise, update the + # email and respect the main identity set by the user + if email := validated_token["email"]: + Identity.objects.update_or_create( + user=user, email=email, create_defaults={"is_main": True} + ) + + return user diff --git a/src/backend/core/tests/__init__.py b/src/backend/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/core/tests/swagger/test_openapi_schema.py b/src/backend/core/tests/swagger/test_openapi_schema.py new file mode 100644 index 0000000..c6c8bfa --- /dev/null +++ b/src/backend/core/tests/swagger/test_openapi_schema.py @@ -0,0 +1,21 @@ +""" +Test suite for generated openapi schema. +""" +import json + +from django.test import Client + +import pytest + +pytestmark = pytest.mark.django_db + + +def test_openapi_client_schema(): + """ + Generated OpenAPI client schema should be correct. + """ + response = Client().get("/v1.0/swagger.json") + + assert response.status_code == 200 + with open("core/tests/swagger/swagger.json") as expected_schema: + assert response.json() == json.load(expected_schema) diff --git a/src/backend/core/tests/teams/test_core_api_teams_create.py b/src/backend/core/tests/teams/test_core_api_teams_create.py new file mode 100644 index 0000000..9bc196c --- /dev/null +++ b/src/backend/core/tests/teams/test_core_api_teams_create.py @@ -0,0 +1,50 @@ +""" +Tests for Teams API endpoint in People's core app: create +""" +import pytest +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from core.factories import IdentityFactory, TeamFactory, UserFactory +from core.models import Team + +from ..utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_teams_create_anonymous(): + """Anonymous users should not be allowed to create teams.""" + response = APIClient().post( + "/api/v1.0/teams/", + { + "name": "my team", + }, + ) + + assert response.status_code == 401 + assert not Team.objects.exists() + + +def test_api_teams_create_authenticated(): + """ + Authenticated users should be able to create teams and should automatically be declared + as the owner of the newly created team. + """ + identity = IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + response = APIClient().post( + "/api/v1.0/teams/", + { + "name": "my team", + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 201 + team = Team.objects.get() + assert team.name == "my team" + assert team.accesses.filter(role="owner", user=user).exists() diff --git a/src/backend/core/tests/teams/test_core_api_teams_delete.py b/src/backend/core/tests/teams/test_core_api_teams_delete.py new file mode 100644 index 0000000..816e443 --- /dev/null +++ b/src/backend/core/tests/teams/test_core_api_teams_delete.py @@ -0,0 +1,107 @@ +""" +Tests for Teams API endpoint in People's core app: delete +""" +import pytest +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from core import factories, models + +from ..utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_teams_delete_anonymous(): + """Anonymous users should not be allowed to destroy a team.""" + team = factories.TeamFactory() + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id!s}/", + ) + + assert response.status_code == 401 + assert models.Team.objects.count() == 1 + + +def test_api_teams_delete_authenticated_unrelated(): + """ + Authenticated users should not be allowed to delete a team to which they are not + related. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + assert models.Team.objects.count() == 1 + + +def test_api_teams_delete_authenticated_member(): + """ + Authenticated users should not be allowed to delete a team for which they are + only a member. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "member")]) + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.Team.objects.count() == 1 + + +def test_api_teams_delete_authenticated_administrator(): + """ + Authenticated users should not be allowed to delete a team for which they are + administrator. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "administrator")]) + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.Team.objects.count() == 1 + + +def test_api_teams_delete_authenticated_owner(): + """ + Authenticated users should be able to delete a team for which they are directly + owner. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "owner")]) + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 204 + assert models.Team.objects.exists() is False diff --git a/src/backend/core/tests/teams/test_core_api_teams_list.py b/src/backend/core/tests/teams/test_core_api_teams_list.py new file mode 100644 index 0000000..44dad04 --- /dev/null +++ b/src/backend/core/tests/teams/test_core_api_teams_list.py @@ -0,0 +1,118 @@ +""" +Tests for Teams API endpoint in People's core app: list +""" +from unittest import mock + +import pytest +from rest_framework.pagination import PageNumberPagination +from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers + +from ..utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_teams_list_anonymous(): + """Anonymous users should not be allowed to list teams.""" + factories.TeamFactory.create_batch(2) + + response = APIClient().get("/api/v1.0/teams/") + + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_teams_list_authenticated(): + """Authenticated users should be able to list teams they are an owner/administrator/member of.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + expected_ids = { + str(access.team.id) + for access in factories.TeamAccessFactory.create_batch(5, user=user) + } + factories.TeamFactory.create_batch(2) # Other teams + + response = APIClient().get( + "/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 5 + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_teams_list_pagination( + _mock_page_size, +): + """Pagination should work as expected.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team_ids = [ + str(access.team.id) + for access in factories.TeamAccessFactory.create_batch(3, user=user) + ] + + # Get page 1 + response = APIClient().get( + "/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/teams/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + team_ids.remove(item["id"]) + + # Get page 2 + response = APIClient().get( + "/api/v1.0/teams/?page=2", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"] == "http://testserver/api/v1.0/teams/" + + assert len(content["results"]) == 1 + team_ids.remove(content["results"][0]["id"]) + assert team_ids == [] + + +def test_api_teams_list_authenticated_distinct(): + """A team with several related users should only be listed once.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + other_user = factories.UserFactory() + + team = factories.TeamFactory(users=[user, other_user]) + + response = APIClient().get( + "/api/v1.0/teams/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == HTTP_200_OK + content = response.json() + assert len(content["results"]) == 1 + assert content["results"][0]["id"] == str(team.id) diff --git a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py new file mode 100644 index 0000000..1351880 --- /dev/null +++ b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py @@ -0,0 +1,86 @@ +""" +Tests for Teams API endpoint in People's core app: retrieve +""" +import random +from collections import Counter +from unittest import mock + +import pytest +from rest_framework.test import APIClient + +from core import factories + +from ..utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_teams_retrieve_anonymous(): + """Anonymous users should not be allowed to retrieve a team.""" + team = factories.TeamFactory() + response = APIClient().get(f"/api/v1.0/teams/{team.id}/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_teams_retrieve_authenticated_unrelated(): + """ + Authenticated users should not be allowed to retrieve a team to which they are + not related. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + + response = APIClient().get( + f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_teams_retrieve_authenticated_related(): + """ + Authenticated users should be allowed to retrieve a team to which they + are related whatever the role. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + access1 = factories.TeamAccessFactory(team=team, user=user) + access2 = factories.TeamAccessFactory(team=team) + + response = APIClient().get( + f"/api/v1.0/teams/{team.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + assert response.status_code == 200 + content = response.json() + assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted( + [ + { + "id": str(access1.id), + "user": str(user.id), + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": str(access2.user.id), + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + ], + key=lambda x: x["user"], + ) + assert response.json() == { + "id": str(team.id), + "name": team.name, + "abilities": team.get_abilities(user), + } diff --git a/src/backend/core/tests/teams/test_core_api_teams_update.py b/src/backend/core/tests/teams/test_core_api_teams_update.py new file mode 100644 index 0000000..58a36a9 --- /dev/null +++ b/src/backend/core/tests/teams/test_core_api_teams_update.py @@ -0,0 +1,176 @@ +""" +Tests for Teams API endpoint in People's core app: update +""" +import random + +import pytest +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from core import factories, models +from core.api import serializers + +from ..utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_teams_update_anonymous(): + """Anonymous users should not be allowed to update a team.""" + team = factories.TeamFactory() + old_team_values = serializers.TeamSerializer(instance=team).data + + new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data + response = APIClient().put( + f"/api/v1.0/teams/{team.id!s}/", + new_team_values, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + team.refresh_from_db() + team_values = serializers.TeamSerializer(instance=team).data + assert team_values == old_team_values + + +def test_api_teams_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a team to which they are not related. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + old_team_values = serializers.TeamSerializer(instance=team).data + + new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data + response = APIClient().put( + f"/api/v1.0/teams/{team.id!s}/", + new_team_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + team.refresh_from_db() + team_values = serializers.TeamSerializer(instance=team).data + assert team_values == old_team_values + + +def test_api_teams_update_authenticated_members(): + """ + Users who are members of a team but not administrators should + not be allowed to update it. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "member")]) + old_team_values = serializers.TeamSerializer(instance=team).data + + new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data + response = APIClient().put( + f"/api/v1.0/teams/{team.id!s}/", + new_team_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + team.refresh_from_db() + team_values = serializers.TeamSerializer(instance=team).data + assert team_values == old_team_values + + +def test_api_teams_update_authenticated_administrators(): + """Administrators of a team should be allowed to update it.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "administrator")]) + old_team_values = serializers.TeamSerializer(instance=team).data + + new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data + response = APIClient().put( + f"/api/v1.0/teams/{team.id!s}/", + new_team_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 200 + + team.refresh_from_db() + team_values = serializers.TeamSerializer(instance=team).data + for key, value in team_values.items(): + if key in ["id", "accesses"]: + assert value == old_team_values[key] + else: + assert value == new_team_values[key] + + +def test_api_teams_update_authenticated_owners(): + """Administrators of a team should be allowed to update it.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "owner")]) + old_team_values = serializers.TeamSerializer(instance=team).data + + new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data + response = APIClient().put( + f"/api/v1.0/teams/{team.id!s}/", + new_team_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 200 + + team.refresh_from_db() + team_values = serializers.TeamSerializer(instance=team).data + for key, value in team_values.items(): + if key in ["id", "accesses"]: + assert value == old_team_values[key] + else: + assert value == new_team_values[key] + + +def test_api_teams_update_administrator_or_owner_of_another(): + """ + Being administrator or owner of a team should not grant authorization to update + another team. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + factories.TeamFactory(users=[(user, random.choice(["administrator", "owner"]))]) + team = factories.TeamFactory(name="Old name") + old_team_values = serializers.TeamSerializer(instance=team).data + + new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data + response = APIClient().put( + f"/api/v1.0/teams/{team.id!s}/", + new_team_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + team.refresh_from_db() + team_values = serializers.TeamSerializer(instance=team).data + assert team_values == old_team_values diff --git a/src/backend/core/tests/test_api_contacts.py b/src/backend/core/tests/test_api_contacts.py new file mode 100644 index 0000000..1148295 --- /dev/null +++ b/src/backend/core/tests/test_api_contacts.py @@ -0,0 +1,694 @@ +""" +Test contacts API endpoints in People's core app. +""" +import random +from unittest import mock + +from django.test.utils import override_settings + +import pytest +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from core import factories, models +from core.api import serializers + +from .utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +CONTACT_DATA = { + "emails": [ + {"type": "Work", "value": "john.doe@work.com"}, + {"type": "Home", "value": "john.doe@home.com"}, + ], + "phones": [ + {"type": "Work", "value": "(123) 456-7890"}, + {"type": "Other", "value": "(987) 654-3210"}, + ], + "addresses": [ + { + "type": "Home", + "street": "123 Main St", + "city": "Cityville", + "state": "CA", + "zip": "12345", + "country": "USA", + } + ], + "links": [ + {"type": "Blog", "value": "http://personalwebsite.com"}, + {"type": "Website", "value": "http://workwebsite.com"}, + ], + "customFields": {"custom_field_1": "value1", "custom_field_2": "value2"}, + "organizations": [ + { + "name": "ACME Corporation", + "department": "IT", + "jobTitle": "Software Engineer", + }, + { + "name": "XYZ Ltd", + "department": "Marketing", + "jobTitle": "Marketing Specialist", + }, + ], +} + + +def test_api_contacts_list_anonymous(): + """Anonymous users should not be allowed to list contacts.""" + factories.ContactFactory.create_batch(2) + + response = APIClient().get("/api/v1.0/contacts/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_contacts_list_authenticated_no_query(): + """ + Authenticated users should be able to list contacts without applying a query. + Profile and base contacts should be excluded. + """ + identity = factories.IdentityFactory() + user = identity.user + contact = factories.ContactFactory(owner=user) + user.profile_contact = contact + user.save() + jwt_token = OIDCToken.for_user(user) + + # Let's have 5 contacts in database: + assert user.profile_contact is not None # Excluded because profile contact + base_contact = factories.BaseContactFactory() # Excluded because overriden + factories.ContactFactory( + base=base_contact + ) # Excluded because belongs to other user + contact2 = factories.ContactFactory( + base=base_contact, owner=user, full_name="Bernard" + ) # Included + + response = APIClient().get( + "/api/v1.0/contacts/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + assert response.json() == [ + { + "id": str(contact2.id), + "base": str(base_contact.id), + "owner": str(contact2.owner.id), + "data": contact2.data, + "full_name": contact2.full_name, + "short_name": contact2.short_name, + }, + ] + + +def test_api_contacts_list_authenticated_by_full_name(): + """ + Authenticated users should be able to search users with a case insensitive and + partial query on the full name. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + dave = factories.BaseContactFactory(full_name="David Bowman") + nicole = factories.BaseContactFactory(full_name="Nicole Foole") + frank = factories.BaseContactFactory(full_name="Frank Poole") + heywood = factories.BaseContactFactory(full_name="Heywood Floyd") + + # Full query should work + response = APIClient().get( + "/api/v1.0/contacts/?q=David%20Bowman", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + # Partial query should work + response = APIClient().get( + "/api/v1.0/contacts/?q=ank", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(frank.id)] + + # Result that matches a trigram twice ranks better than result that matches once + response = APIClient().get( + "/api/v1.0/contacts/?q=ole", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + # "Nicole Foole" matches twice on "ole" + assert contact_ids == [str(nicole.id), str(frank.id)] + + response = APIClient().get( + "/api/v1.0/contacts/?q=ool", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(nicole.id), str(frank.id)] + + +def test_api_contacts_list_authenticated_uppercase_content(): + """Upper case content should be found by lower case query.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA") + + # Unaccented full name + response = APIClient().get( + "/api/v1.0/contacts/?q=eee", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + # Unaccented short name + response = APIClient().get( + "/api/v1.0/contacts/?q=aaa", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + +def test_api_contacts_list_authenticated_capital_query(): + """Upper case query should find lower case content.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + dave = factories.BaseContactFactory(full_name="eee", short_name="aaa") + + # Unaccented full name + response = APIClient().get( + "/api/v1.0/contacts/?q=EEE", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + # Unaccented short name + response = APIClient().get( + "/api/v1.0/contacts/?q=AAA", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + +def test_api_contacts_list_authenticated_accented_content(): + """Accented content should be found by unaccented query.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà") + + # Unaccented full name + response = APIClient().get( + "/api/v1.0/contacts/?q=eee", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + # Unaccented short name + response = APIClient().get( + "/api/v1.0/contacts/?q=aaa", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + +def test_api_contacts_list_authenticated_accented_query(): + """Accented query should find unaccented content.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + dave = factories.BaseContactFactory(full_name="eee", short_name="aaa") + + # Unaccented full name + response = APIClient().get( + "/api/v1.0/contacts/?q=ééé", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + # Unaccented short name + response = APIClient().get( + "/api/v1.0/contacts/?q=ààà", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + contact_ids = [contact["id"] for contact in response.json()] + assert contact_ids == [str(dave.id)] + + +def test_api_contacts_retrieve_anonymous(): + """Anonymous users should not be allowed to retrieve a user.""" + client = APIClient() + contact = factories.ContactFactory() + response = client.get(f"/api/v1.0/contacts/{contact.id!s}/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_contacts_retrieve_authenticated_owned(): + """ + Authenticated users should be allowed to retrieve a contact they own. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + contact = factories.ContactFactory(owner=user) + + response = APIClient().get( + f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(contact.id), + "base": str(contact.base.id), + "owner": str(contact.owner.id), + "data": contact.data, + "full_name": contact.full_name, + "short_name": contact.short_name, + } + + +def test_api_contacts_retrieve_authenticated_public(): + """ + Authenticated users should be able to retrieve public contacts. + """ + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + + contact = factories.BaseContactFactory() + + response = APIClient().get( + f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(contact.id), + "base": None, + "owner": None, + "data": contact.data, + "full_name": contact.full_name, + "short_name": contact.short_name, + } + + +def test_api_contacts_retrieve_authenticated_other(): + """ + Authenticated users should not be allowed to retrieve another user's contacts. + """ + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + + contact = factories.ContactFactory() + + response = APIClient().get( + f"/api/v1.0/contacts/{contact.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_contacts_create_anonymous_forbidden(): + """Anonymous users should not be able to create contacts via the API.""" + response = APIClient().post( + "/api/v1.0/contacts/", + { + "full_name": "David", + "short_name": "Bowman", + }, + ) + assert response.status_code == 401 + assert not models.Contact.objects.exists() + + +def test_api_contacts_create_authenticated_missing_base(): + """Anonymous users should be able to create users.""" + identity = factories.IdentityFactory(user__profile_contact=None) + user = identity.user + jwt_token = OIDCToken.for_user(user) + + response = APIClient().post( + "/api/v1.0/contacts/", + { + "full_name": "David Bowman", + "short_name": "Dave", + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 400 + assert models.Contact.objects.exists() is False + + assert response.json() == {"base": ["This field is required."]} + + +def test_api_contacts_create_authenticated_successful(): + """Authenticated users should be able to create contacts.""" + identity = factories.IdentityFactory(user__profile_contact=None) + user = identity.user + jwt_token = OIDCToken.for_user(user) + + base_contact = factories.BaseContactFactory() + + # Existing override for another user should not interfere + factories.ContactFactory(base=base_contact) + + response = APIClient().post( + "/api/v1.0/contacts/", + { + "base": str(base_contact.id), + "full_name": "David Bowman", + "short_name": "Dave", + "data": CONTACT_DATA, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 201 + assert models.Contact.objects.count() == 3 + + contact = models.Contact.objects.get(owner=user) + assert response.json() == { + "id": str(contact.id), + "base": str(base_contact.id), + "data": CONTACT_DATA, + "full_name": "David Bowman", + "owner": str(user.id), + "short_name": "Dave", + } + + assert contact.full_name == "David Bowman" + assert contact.short_name == "Dave" + assert contact.data == CONTACT_DATA + assert contact.base == base_contact + assert contact.owner == user + + +@override_settings(ALLOW_API_USER_CREATE=True) +def test_api_contacts_create_authenticated_existing_override(): + """ + Trying to create a contact for base contact that is already overriden by the user + should receive a 400 error. + """ + identity = factories.IdentityFactory(user__profile_contact=None) + user = identity.user + jwt_token = OIDCToken.for_user(user) + + base_contact = factories.BaseContactFactory() + contact = factories.ContactFactory(base=base_contact, owner=user) + + response = APIClient().post( + "/api/v1.0/contacts/", + { + "base": str(base_contact.id), + "full_name": "David Bowman", + "short_name": "Dave", + "data": CONTACT_DATA, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 400 + assert models.Contact.objects.count() == 2 + + assert response.json() == { + "__all__": ["Contact with this Owner and Base already exists."] + } + + +def test_api_contacts_update_anonymous(): + """Anonymous users should not be allowed to update a contact.""" + contact = factories.ContactFactory() + old_contact_values = serializers.ContactSerializer(instance=contact).data + + new_contact_values = serializers.ContactSerializer( + instance=factories.ContactFactory() + ).data + new_contact_values["base"] = str(factories.ContactFactory().id) + response = APIClient().put( + f"/api/v1.0/contacts/{contact.id!s}/", + new_contact_values, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + contact.refresh_from_db() + contact_values = serializers.ContactSerializer(instance=contact).data + assert contact_values == old_contact_values + + +def test_api_contacts_update_authenticated_owned(): + """ + Authenticated users should be allowed to update their own contacts. + """ + identity = factories.IdentityFactory(user__profile_contact=None) + user = identity.user + jwt_token = OIDCToken.for_user(user) + + contact = factories.ContactFactory(owner=user) # Owned by the logged-in user + old_contact_values = serializers.ContactSerializer(instance=contact).data + + new_contact_values = serializers.ContactSerializer( + instance=factories.ContactFactory() + ).data + new_contact_values["base"] = str(factories.ContactFactory().id) + + response = APIClient().put( + f"/api/v1.0/contacts/{contact.id!s}/", + new_contact_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + + contact.refresh_from_db() + contact_values = serializers.ContactSerializer(instance=contact).data + for key, value in contact_values.items(): + if key in ["base", "owner", "id"]: + assert value == old_contact_values[key] + else: + assert value == new_contact_values[key] + + +def test_api_contacts_update_authenticated_profile(): + """ + Authenticated users should be allowed to update their prodile contact. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + contact = factories.ContactFactory(owner=user) + user.profile_contact = contact + user.save() + + old_contact_values = serializers.ContactSerializer(instance=contact).data + new_contact_values = serializers.ContactSerializer( + instance=factories.ContactFactory() + ).data + new_contact_values["base"] = str(factories.ContactFactory().id) + + response = APIClient().put( + f"/api/v1.0/contacts/{contact.id!s}/", + new_contact_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + contact.refresh_from_db() + contact_values = serializers.ContactSerializer(instance=contact).data + for key, value in contact_values.items(): + if key in ["base", "owner", "id"]: + assert value == old_contact_values[key] + else: + assert value == new_contact_values[key] + + +def test_api_contacts_update_authenticated_other(): + """ + Authenticated users should not be allowed to update contacts owned by other users. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + contact = factories.ContactFactory() # owned by another user + old_contact_values = serializers.ContactSerializer(instance=contact).data + + new_contact_values = serializers.ContactSerializer( + instance=factories.ContactFactory() + ).data + new_contact_values["base"] = str(factories.ContactFactory().id) + + response = APIClient().put( + f"/api/v1.0/contacts/{contact.id!s}/", + new_contact_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + + contact.refresh_from_db() + contact_values = serializers.ContactSerializer(instance=contact).data + assert contact_values == old_contact_values + + +def test_api_contacts_delete_list_anonymous(): + """Anonymous users should not be allowed to delete a list of contacts.""" + factories.ContactFactory.create_batch(2) + + response = APIClient().delete("/api/v1.0/contacts/") + + assert response.status_code == 401 + assert models.Contact.objects.count() == 4 + + +def test_api_contacts_delete_list_authenticated(): + """Authenticated users should not be allowed to delete a list of contacts.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + factories.ContactFactory.create_batch(2) + + response = APIClient().delete( + "/api/v1.0/contacts/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 405 + assert models.Contact.objects.count() == 4 + + +def test_api_contacts_delete_anonymous(): + """Anonymous users should not be allowed to delete a contact.""" + contact = factories.ContactFactory() + + client = APIClient() + response = client.delete(f"/api/v1.0/contacts/{contact.id!s}/") + + assert response.status_code == 401 + assert models.Contact.objects.count() == 2 + + +def test_api_contacts_delete_authenticated_public(): + """ + Authenticated users should not be allowed to delete a public contact. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + contact = factories.BaseContactFactory() + + response = APIClient().delete( + f"/api/v1.0/contacts/{contact.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.Contact.objects.count() == 1 + + +def test_api_contacts_delete_authenticated_owner(): + """ + Authenticated users should be allowed to delete a contact they own. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + contact = factories.ContactFactory(owner=user) + + response = APIClient().delete( + f"/api/v1.0/contacts/{contact.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 204 + assert models.Contact.objects.count() == 1 + assert models.Contact.objects.filter(id=contact.id).exists() is False + + +def test_api_contacts_delete_authenticated_profile(): + """ + Authenticated users should be allowed to delete their profile contact. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + contact = factories.ContactFactory(owner=user, base=None) + user.profile_contact = contact + user.save() + + response = APIClient().delete( + f"/api/v1.0/contacts/{contact.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 204 + assert models.Contact.objects.exists() is False + + +def test_api_contacts_delete_authenticated_other(): + """ + Authenticated users should not be allowed to delete a contact they don't own. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + contact = factories.ContactFactory() + + response = APIClient().delete( + f"/api/v1.0/contacts/{contact.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.Contact.objects.count() == 2 diff --git a/src/backend/core/tests/test_api_team_accesses.py b/src/backend/core/tests/test_api_team_accesses.py new file mode 100644 index 0000000..fe573fd --- /dev/null +++ b/src/backend/core/tests/test_api_team_accesses.py @@ -0,0 +1,845 @@ +""" +Test team accesses API endpoints for users in People's core app. +""" +import random +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from core import factories, models +from core.api import serializers + +from .utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_team_accesses_list_anonymous(): + """Anonymous users should not be allowed to list team accesses.""" + team = factories.TeamFactory() + factories.TeamAccessFactory.create_batch(2, team=team) + + response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_team_accesses_list_authenticated_unrelated(): + """ + Authenticated users should not be allowed to list team accesses for a team + to which they are not related. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + accesses = factories.TeamAccessFactory.create_batch(3, team=team) + + # Accesses for other teams to which the user is related should not be listed either + other_access = factories.TeamAccessFactory(user=user) + factories.TeamAccessFactory(team=other_access.team) + + response = APIClient().get( + f"/api/v1.0/teams/{team.id!s}/accesses/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +def test_api_team_accesses_list_authenticated_related(): + """ + Authenticated users should be able to list team accesses for a team + to which they are related, whatever their role in the team. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + user_access = models.TeamAccess.objects.create(team=team, user=user) # random role + access1, access2 = factories.TeamAccessFactory.create_batch(2, team=team) + + # Accesses for other teams to which the user is related should not be listed either + other_access = factories.TeamAccessFactory(user=user) + factories.TeamAccessFactory(team=other_access.team) + + response = APIClient().get( + f"/api/v1.0/teams/{team.id!s}/accesses/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 3 + id_sorter = lambda x: x["id"] + assert sorted(content["results"], key=id_sorter) == sorted( + [ + { + "id": str(user_access.id), + "user": str(user.id), + "role": user_access.role, + "abilities": user_access.get_abilities(user), + }, + { + "id": str(access1.id), + "user": str(access1.user.id), + "role": access1.role, + "abilities": access1.get_abilities(user), + }, + { + "id": str(access2.id), + "user": str(access2.user.id), + "role": access2.role, + "abilities": access2.get_abilities(user), + }, + ], + key=id_sorter, + ) + + +def test_api_team_accesses_retrieve_anonymous(): + """ + Anonymous users should not be allowed to retrieve a team access. + """ + access = factories.TeamAccessFactory() + + response = APIClient().get( + f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_team_accesses_retrieve_authenticated_unrelated(): + """ + Authenticated users should not be allowed to retrieve a team access for + a team to which they are not related. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + access = factories.TeamAccessFactory(team=team) + + response = APIClient().get( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + # Accesses related to another team should be excluded even if the user is related to it + for access in [ + factories.TeamAccessFactory(), + factories.TeamAccessFactory(user=user), + ]: + response = APIClient().get( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_team_accesses_retrieve_authenticated_related(): + """ + A user who is related to a team should be allowed to retrieve the + associated team user accesses. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[user]) + access = factories.TeamAccessFactory(team=team) + + response = APIClient().get( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(access.id), + "user": str(access.user.id), + "role": access.role, + "abilities": access.get_abilities(user), + } + + +def test_api_team_accesses_create_anonymous(): + """Anonymous users should not be allowed to create team accesses.""" + user = factories.UserFactory() + team = factories.TeamFactory() + + response = APIClient().post( + f"/api/v1.0/teams/{team.id!s}/accesses/", + { + "user": str(user.id), + "team": str(team.id), + "role": random.choice(models.RoleChoices.choices)[0], + }, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + assert models.TeamAccess.objects.exists() is False + + +def test_api_team_accesses_create_authenticated_unrelated(): + """ + Authenticated users should not be allowed to create team accesses for a team to + which they are not related. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + other_user = factories.UserFactory() + team = factories.TeamFactory() + + response = APIClient().post( + f"/api/v1.0/teams/{team.id!s}/accesses/", + { + "user": str(other_user.id), + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You are not allowed to manage accesses for this team." + } + assert not models.TeamAccess.objects.filter(user=other_user).exists() + + +def test_api_team_accesses_create_authenticated_member(): + """Members of a team should not be allowed to create team accesses.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "member")]) + other_user = factories.UserFactory() + + api_client = APIClient() + for role in [role[0] for role in models.RoleChoices.choices]: + response = api_client.post( + f"/api/v1.0/teams/{team.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You are not allowed to manage accesses for this team." + } + + assert not models.TeamAccess.objects.filter(user=other_user).exists() + + +def test_api_team_accesses_create_authenticated_administrator(): + """ + Administrators of a team should be able to create team accesses except for the "owner" role. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "administrator")]) + other_user = factories.UserFactory() + + api_client = APIClient() + + # It should not be allowed to create an owner access + response = api_client.post( + f"/api/v1.0/teams/{team.id!s}/accesses/", + { + "user": str(other_user.id), + "role": "owner", + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Only owners of a team can assign other users as owners." + } + + # It should be allowed to create a lower access + role = random.choice( + [role[0] for role in models.RoleChoices.choices if role[0] != "owner"] + ) + + response = api_client.post( + f"/api/v1.0/teams/{team.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 201 + assert models.TeamAccess.objects.filter(user=other_user).count() == 1 + new_team_access = models.TeamAccess.objects.filter(user=other_user).get() + assert response.json() == { + "abilities": new_team_access.get_abilities(user), + "id": str(new_team_access.id), + "role": role, + "user": str(other_user.id), + } + + +def test_api_team_accesses_create_authenticated_owner(): + """ + Owners of a team should be able to create team accesses whatever the role. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "owner")]) + other_user = factories.UserFactory() + + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + response = APIClient().post( + f"/api/v1.0/teams/{team.id!s}/accesses/", + { + "user": str(other_user.id), + "role": role, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 201 + assert models.TeamAccess.objects.filter(user=other_user).count() == 1 + new_team_access = models.TeamAccess.objects.filter(user=other_user).get() + assert response.json() == { + "abilities": new_team_access.get_abilities(user), + "id": str(new_team_access.id), + "role": role, + "user": str(other_user.id), + } + + +def test_api_team_accesses_update_anonymous(): + """Anonymous users should not be allowed to update a team access.""" + access = factories.TeamAccessFactory() + old_values = serializers.TeamAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 401 + + access.refresh_from_db() + updated_values = serializers.TeamAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_team_accesses_update_authenticated_unrelated(): + """ + Authenticated users should not be allowed to update a team access for a team to which + they are not related. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + access = factories.TeamAccessFactory() + old_values = serializers.TeamAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.TeamAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_team_accesses_update_authenticated_member(): + """Members of a team should not be allowed to update its accesses.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "member")]) + access = factories.TeamAccessFactory(team=team) + old_values = serializers.TeamAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + + access.refresh_from_db() + updated_values = serializers.TeamAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_team_accesses_update_administrator_except_owner(): + """ + A user who is an administrator in a team should be allowed to update a user + access for this team, as long as they don't try to set the role to owner. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "administrator")]) + access = factories.TeamAccessFactory( + team=team, + role=random.choice(["administrator", "member"]), + ) + old_values = serializers.TeamAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(["administrator", "member"]), + } + + api_client = APIClient() + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = api_client.put( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TeamAccessSerializer(instance=access).data + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +def test_api_team_accesses_update_administrator_from_owner(): + """ + A user who is an administrator in a team, should not be allowed to update + the user access of an "owner" for this team. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "administrator")]) + other_user = factories.UserFactory() + access = factories.TeamAccessFactory(team=team, user=other_user, role="owner") + old_values = serializers.TeamAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + data={**old_values, field: value}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + access.refresh_from_db() + updated_values = serializers.TeamAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_team_accesses_update_administrator_to_owner(): + """ + A user who is an administrator in a team, should not be allowed to update + the user access of another user to grant team ownership. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "administrator")]) + other_user = factories.UserFactory() + access = factories.TeamAccessFactory( + team=team, + user=other_user, + role=random.choice(["administrator", "member"]), + ) + old_values = serializers.TeamAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": "owner", + } + + api_client = APIClient() + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = api_client.put( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + # We are not allowed or not really updating the role + if field == "role" or new_data["role"] == old_values["role"]: + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TeamAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_team_accesses_update_owner_except_owner(): + """ + A user who is an owner in a team should be allowed to update + a user access for this team except for existing "owner" accesses. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "owner")]) + other_user = factories.UserFactory() + access = factories.TeamAccessFactory( + team=team, + role=random.choice(["administrator", "member"]), + ) + old_values = serializers.TeamAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + new_data = {**old_values, field: value} + response = api_client.put( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + data=new_data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + if ( + new_data["role"] == old_values["role"] + ): # we are not really updating the role + assert response.status_code == 403 + else: + assert response.status_code == 200 + + access.refresh_from_db() + updated_values = serializers.TeamAccessSerializer(instance=access).data + + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +def test_api_team_accesses_update_owner_for_owners(): + """ + A user who is "owner" of a team should not be allowed to update + an existing owner access for this team. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "owner")]) + access = factories.TeamAccessFactory(team=team, role="owner") + old_values = serializers.TeamAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "user_id": factories.UserFactory().id, + "role": random.choice(models.RoleChoices.choices)[0], + } + + api_client = APIClient() + for field, value in new_values.items(): + response = api_client.put( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + data={**old_values, field: value}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + access.refresh_from_db() + updated_values = serializers.TeamAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_team_accesses_update_owner_self(): + """ + A user who is owner of a team should be allowed to update + their own user access provided there are other owners in the team. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + access = factories.TeamAccessFactory(team=team, user=user, role="owner") + old_values = serializers.TeamAccessSerializer(instance=access).data + new_role = random.choice(["administrator", "member"]) + + api_client = APIClient() + response = api_client.put( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + access.refresh_from_db() + assert access.role == "owner" + + # Add another owner and it should now work + factories.TeamAccessFactory(team=team, role="owner") + + response = api_client.put( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role + + +# Delete + + +def test_api_team_accesses_delete_anonymous(): + """Anonymous users should not be allowed to destroy a team access.""" + access = factories.TeamAccessFactory() + + response = APIClient().delete( + f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert models.TeamAccess.objects.count() == 1 + + +def test_api_team_accesses_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a team access for a + team to which they are not related. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + access = factories.TeamAccessFactory() + + response = APIClient().delete( + f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.TeamAccess.objects.count() == 1 + + +def test_api_team_accesses_delete_member(): + """ + Authenticated users should not be allowed to delete a team access for a + team in which they are a simple member. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "member")]) + access = factories.TeamAccessFactory(team=team) + + assert models.TeamAccess.objects.count() == 2 + assert models.TeamAccess.objects.filter(user=access.user).exists() + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.TeamAccess.objects.count() == 2 + + +def test_api_team_accesses_delete_administrators(): + """ + Users who are administrators in a team should be allowed to delete an access + from the team provided it is not ownership. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "administrator")]) + access = factories.TeamAccessFactory( + team=team, role=random.choice(["member", "administrator"]) + ) + + assert models.TeamAccess.objects.count() == 2 + assert models.TeamAccess.objects.filter(user=access.user).exists() + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 204 + assert models.TeamAccess.objects.count() == 1 + + +def test_api_team_accesses_delete_owners_except_owners(): + """ + Users should be able to delete the team access of another user + for a team of which they are owner provided it is not an owner access. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "owner")]) + access = factories.TeamAccessFactory( + team=team, role=random.choice(["member", "administrator"]) + ) + + assert models.TeamAccess.objects.count() == 2 + assert models.TeamAccess.objects.filter(user=access.user).exists() + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 204 + assert models.TeamAccess.objects.count() == 1 + + +def test_api_team_accesses_delete_owners_for_owners(): + """ + Users should not be allowed to delete the team access of another owner + even for a team in which they are direct owner. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory(users=[(user, "owner")]) + access = factories.TeamAccessFactory(team=team, role="owner") + + assert models.TeamAccess.objects.count() == 2 + assert models.TeamAccess.objects.filter(user=access.user).exists() + + response = APIClient().delete( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.TeamAccess.objects.count() == 2 + + +def test_api_team_accesses_delete_owners_last_owner(): + """ + It should not be possible to delete the last owner access from a team + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + team = factories.TeamFactory() + access = factories.TeamAccessFactory(team=team, user=user, role="owner") + + assert models.TeamAccess.objects.count() == 1 + response = APIClient().delete( + f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + assert models.TeamAccess.objects.count() == 1 diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py new file mode 100644 index 0000000..0adf78b --- /dev/null +++ b/src/backend/core/tests/test_api_users.py @@ -0,0 +1,375 @@ +""" +Test users API endpoints in the People core app. +""" +import random +from unittest import mock +from zoneinfo import ZoneInfo + +from django.test.utils import override_settings + +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers + +from .utils import OIDCToken + +pytestmark = pytest.mark.django_db + + +def test_api_users_list_anonymous(): + """Anonymous users should not be allowed to list users.""" + factories.UserFactory() + client = APIClient() + response = client.get("/api/v1.0/users/") + assert response.status_code == 404 + assert "Not Found" in response.content.decode("utf-8") + + +def test_api_users_list_authenticated(): + """ + Authenticated users should not be able to list users. + """ + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + + factories.UserFactory.create_batch(2) + response = APIClient().get( + "/api/v1.0/users/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + assert response.status_code == 404 + assert "Not Found" in response.content.decode("utf-8") + + +def test_api_users_retrieve_me_anonymous(): + """Anonymous users should not be allowed to list users.""" + factories.UserFactory.create_batch(2) + client = APIClient() + response = client.get("/api/v1.0/users/me/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_users_retrieve_me_authenticated(): + """Authenticated users should be able to retrieve their own user via the "/users/me" path.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + # Define profile contact + contact = factories.ContactFactory(owner=user) + user.profile_contact = contact + user.save() + + factories.UserFactory.create_batch(2) + response = APIClient().get( + "/api/v1.0/users/me/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(user.id), + "language": user.language, + "timezone": str(user.timezone), + "is_device": False, + "is_staff": False, + "data": user.profile_contact.data, + } + + +def test_api_users_retrieve_anonymous(): + """Anonymous users should not be allowed to retrieve a user.""" + client = APIClient() + user = factories.UserFactory() + response = client.get(f"/api/v1.0/users/{user.id!s}/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_users_retrieve_authenticated_self(): + """ + Authenticated users should be allowed to retrieve their own user. + The returned object should not contain the password. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + response = APIClient().get( + f"/api/v1.0/users/{user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "GET" not allowed.'} + + +def test_api_users_retrieve_authenticated_other(): + """ + Authenticated users should be able to retrieve another user's detail view with + limited information. + """ + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + + other_user = factories.UserFactory() + + response = APIClient().get( + f"/api/v1.0/users/{other_user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "GET" not allowed.'} + + +def test_api_users_create_anonymous(): + """Anonymous users should not be able to create users via the API.""" + response = APIClient().post( + "/api/v1.0/users/", + { + "language": "fr-fr", + "password": "mypassword", + }, + ) + assert response.status_code == 404 + assert "Not Found" in response.content.decode("utf-8") + assert models.User.objects.exists() is False + + +def test_api_users_create_authenticated(): + """Authenticated users should not be able to create users via the API.""" + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + response = APIClient().post( + "/api/v1.0/users/", + { + "language": "fr-fr", + "password": "mypassword", + }, + ) + assert response.status_code == 404 + assert "Not Found" in response.content.decode("utf-8") + assert models.User.objects.exclude(id=user.id).exists() is False + + +def test_api_users_update_anonymous(): + """Anonymous users should not be able to update users via the API.""" + user = factories.UserFactory() + + old_user_values = serializers.UserSerializer(instance=user).data + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + response = APIClient().put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + user.refresh_from_db() + user_values = serializers.UserSerializer(instance=user).data + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_update_authenticated_self(): + """ + Authenticated users should be able to update their own user but only "language" and "timezone" fields. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + old_user_values = serializers.UserSerializer(instance=user).data + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + response = APIClient().put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 200 + user.refresh_from_db() + user_values = serializers.UserSerializer(instance=user).data + for key, value in user_values.items(): + if key in ["language", "timezone"]: + assert value == new_user_values[key] + else: + assert value == old_user_values[key] + + +def test_api_users_update_authenticated_other(): + """Authenticated users should not be allowed to update other users.""" + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + + user = factories.UserFactory() + old_user_values = serializers.UserSerializer(instance=user).data + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + response = APIClient().put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 403 + user.refresh_from_db() + user_values = serializers.UserSerializer(instance=user).data + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_patch_anonymous(): + """Anonymous users should not be able to patch users via the API.""" + user = factories.UserFactory() + + old_user_values = serializers.UserSerializer(instance=user).data + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + for key, new_value in new_user_values.items(): + response = APIClient().patch( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + user.refresh_from_db() + user_values = serializers.UserSerializer(instance=user).data + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_patch_authenticated_self(): + """ + Authenticated users should be able to patch their own user but only "language" and "timezone" fields. + """ + identity = factories.IdentityFactory() + user = identity.user + jwt_token = OIDCToken.for_user(user) + + old_user_values = serializers.UserSerializer(instance=user).data + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + for key, new_value in new_user_values.items(): + response = APIClient().patch( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 200 + + user.refresh_from_db() + user_values = serializers.UserSerializer(instance=user).data + for key, value in user_values.items(): + if key in ["language", "timezone"]: + assert value == new_user_values[key] + else: + assert value == old_user_values[key] + + +def test_api_users_patch_authenticated_other(): + """Authenticated users should not be allowed to patch other users.""" + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + + user = factories.UserFactory() + old_user_values = serializers.UserSerializer(instance=user).data + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + for key, new_value in new_user_values.items(): + response = APIClient().put( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + assert response.status_code == 403 + + user.refresh_from_db() + user_values = serializers.UserSerializer(instance=user).data + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_delete_list_anonymous(): + """Anonymous users should not be allowed to delete a list of users.""" + factories.UserFactory.create_batch(2) + + client = APIClient() + response = client.delete("/api/v1.0/users/") + + assert response.status_code == 404 + assert models.User.objects.count() == 2 + + +def test_api_users_delete_list_authenticated(): + """Authenticated users should not be allowed to delete a list of users.""" + factories.UserFactory.create_batch(2) + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + + client = APIClient() + response = client.delete( + "/api/v1.0/users/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 404 + assert models.User.objects.count() == 3 + + +def test_api_users_delete_anonymous(): + """Anonymous users should not be allowed to delete a user.""" + user = factories.UserFactory() + + response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/") + + assert response.status_code == 401 + assert models.User.objects.count() == 1 + + +def test_api_users_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a user other than themselves. + """ + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + other_user = factories.UserFactory() + + response = APIClient().delete( + f"/api/v1.0/users/{other_user.id!s}/", HTTP_AUTHORIZATION=f"Bearer {jwt_token}" + ) + + assert response.status_code == 405 + assert models.User.objects.count() == 2 + + +def test_api_users_delete_self(): + """Authenticated users should not be able to delete their own user.""" + identity = factories.IdentityFactory() + jwt_token = OIDCToken.for_user(identity.user) + + response = APIClient().delete( + f"/api/v1.0/users/{identity.user.id!s}/", + HTTP_AUTHORIZATION=f"Bearer {jwt_token}", + ) + + assert response.status_code == 405 + assert models.User.objects.count() == 1 diff --git a/src/backend/core/tests/test_models_contacts.py b/src/backend/core/tests/test_models_contacts.py new file mode 100644 index 0000000..641aba3 --- /dev/null +++ b/src/backend/core/tests/test_models_contacts.py @@ -0,0 +1,163 @@ +""" +Unit tests for the Contact model +""" +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_contacts_str_full_name(): + """The str representation should be the contact's full name.""" + contact = factories.ContactFactory(full_name="David Bowman") + assert str(contact) == "David Bowman" + + +def test_models_contacts_str_short_name(): + """The str representation should be the contact's short name if full name is not set.""" + contact = factories.ContactFactory(full_name=None, short_name="Dave") + assert str(contact) == "Dave" + + +def test_models_contacts_base_self(): + """A contact should not point to itself as a base contact.""" + contact = factories.ContactFactory() + contact.base = contact + + with pytest.raises(ValidationError) as excinfo: + contact.save() + + error_message = ( + "{'__all__': ['A contact cannot point to a base contact that itself points to another " + "base contact.', 'A contact cannot be based on itself.']}" + ) + assert str(excinfo.value) == error_message + + +def test_models_contacts_base_to_base(): + """A contact should not point to a base contact that is itself derived from a base contact.""" + contact = factories.ContactFactory() + + with pytest.raises(ValidationError) as excinfo: + factories.ContactFactory(base=contact) + + error_message = ( + "{'__all__': ['A contact cannot point to a base contact that itself points to another " + "base contact.']}" + ) + assert str(excinfo.value) == error_message + + +def test_models_contacts_owner_base_unique(): + """Their should be only one contact deriving from a given base contact for a given owner.""" + contact = factories.ContactFactory() + + with pytest.raises(ValidationError) as excinfo: + factories.ContactFactory(base=contact.base, owner=contact.owner) + + assert ( + str(excinfo.value) + == "{'__all__': ['Contact with this Owner and Base already exists.']}" + ) + + +def test_models_contacts_base_not_owned(): + """A contact cannot have a base and not be owned.""" + with pytest.raises(ValidationError) as excinfo: + factories.ContactFactory(owner=None) + + assert ( + str(excinfo.value) + == "{'__all__': ['A contact overriding a base contact must be owned.']}" + ) + + +def test_models_contacts_profile_not_owned(): + """A contact cannot be defined as profile for a user if is not owned.""" + base_contact = factories.ContactFactory(owner=None, base=None) + + with pytest.raises(ValidationError) as excinfo: + factories.UserFactory(profile_contact=base_contact) + + assert ( + str(excinfo.value) + == "{'__all__': ['Users can only declare as profile a contact they own.']}" + ) + + +def test_models_contacts_profile_owned_by_other(): + """A contact cannot be defined as profile for a user if is owned by another user.""" + contact = factories.ContactFactory() + + with pytest.raises(ValidationError) as excinfo: + factories.UserFactory(profile_contact=contact) + + assert ( + str(excinfo.value) + == "{'__all__': ['Users can only declare as profile a contact they own.']}" + ) + + +def test_models_contacts_data_valid(): + """Contact information matching the jsonschema definition should be valid""" + contact = factories.ContactFactory( + data={ + "emails": [ + {"type": "Work", "value": "john.doe@work.com"}, + {"type": "Home", "value": "john.doe@home.com"}, + ], + "phones": [ + {"type": "Work", "value": "(123) 456-7890"}, + {"type": "Other", "value": "(987) 654-3210"}, + ], + "addresses": [ + { + "type": "Home", + "street": "123 Main St", + "city": "Cityville", + "state": "CA", + "zip": "12345", + "country": "USA", + } + ], + "links": [ + {"type": "Blog", "value": "http://personalwebsite.com"}, + {"type": "Website", "value": "http://workwebsite.com"}, + {"type": "LinkedIn", "value": "https://www.linkedin.com/in/johndoe"}, + {"type": "Facebook", "value": "https://www.facebook.com/in/johndoe"}, + ], + "customFields": {"custom_field_1": "value1", "custom_field_2": "value2"}, + "organizations": [ + { + "name": "ACME Corporation", + "department": "IT", + "jobTitle": "Software Engineer", + }, + { + "name": "XYZ Ltd", + "department": "Marketing", + "jobTitle": "Marketing Specialist", + }, + ], + } + ) + + +def test_models_contacts_data_invalid(): + """Invalid contact information should be rejected with a clear error message.""" + with pytest.raises(ValidationError) as excinfo: + factories.ContactFactory( + data={ + "emails": [ + {"type": "invalid type", "value": "john.doe@work.com"}, + ], + } + ) + + assert ( + str(excinfo.value) + == "{'data': [\"Validation error in 'emails.0.type': 'invalid type' is not one of ['Work', 'Home', 'Other']\"]}" + ) diff --git a/src/backend/core/tests/test_models_identities.py b/src/backend/core/tests/test_models_identities.py new file mode 100644 index 0000000..5b7d152 --- /dev/null +++ b/src/backend/core/tests/test_models_identities.py @@ -0,0 +1,183 @@ +""" +Unit tests for the Identity model +""" +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_identities_str_main(): + """The str representation should be the email address with indication that it is main.""" + identity = factories.IdentityFactory(email="david@example.com") + assert str(identity) == "david@example.com[main]" + + +def test_models_identities_str_secondary(): + """The str representation of a secondary email should be the email address.""" + main_identity = factories.IdentityFactory() + secondary_identity = factories.IdentityFactory( + user=main_identity.user, email="david@example.com" + ) + assert str(secondary_identity) == "david@example.com" + + +def test_models_identities_is_main_automatic(): + """The first identity created for a user should automatically be set as main.""" + user = factories.UserFactory() + identity = models.Identity.objects.create( + user=user, sub="123", email="david@example.com" + ) + assert identity.is_main is True + + +def test_models_identities_is_main_exists(): + """A user should always keep one and only one of its identities as main.""" + user = factories.UserFactory() + main_identity, secondary_identity = factories.IdentityFactory.create_batch( + 2, user=user + ) + + assert main_identity.is_main is True + + main_identity.is_main = False + with pytest.raises( + ValidationError, match="A user should have one and only one main identity." + ): + main_identity.save() + + +def test_models_identities_is_main_switch(): + """Setting a secondary identity as main should reset the existing main identity.""" + user = factories.UserFactory() + first_identity, second_identity = factories.IdentityFactory.create_batch( + 2, user=user + ) + + assert first_identity.is_main is True + + second_identity.is_main = True + second_identity.save() + + second_identity.refresh_from_db() + assert second_identity.is_main is True + + first_identity.refresh_from_db() + assert first_identity.is_main is False + + +def test_models_identities_email_required(): + """The "email" field is required.""" + user = factories.UserFactory() + with pytest.raises(ValidationError, match="This field cannot be null."): + models.Identity.objects.create(user=user, email=None) + + +def test_models_identities_user_required(): + """The "user" field is required.""" + with pytest.raises(models.User.DoesNotExist, match="Identity has no user."): + models.Identity.objects.create(user=None, email="david@example.com") + + +def test_models_identities_email_unique_same_user(): + """The "email" field should be unique for a given user.""" + email = factories.IdentityFactory() + + with pytest.raises( + ValidationError, + match="Identity with this User and Email address already exists.", + ): + factories.IdentityFactory(user=email.user, email=email.email) + + +def test_models_identities_email_unique_different_users(): + """The "email" field should not be unique among users.""" + email = factories.IdentityFactory() + factories.IdentityFactory(email=email.email) + + +def test_models_identities_email_normalization(): + """The email field should be automatically normalized upon saving.""" + email = factories.IdentityFactory() + email.email = "Thomas.Jefferson@Example.com" + email.save() + assert email.email == "Thomas.Jefferson@example.com" + + +def test_models_identities_ordering(): + """Identitys should be returned ordered by main status then by their email address.""" + user = factories.UserFactory() + factories.IdentityFactory.create_batch(5, user=user) + + emails = models.Identity.objects.all() + + assert emails[0].is_main is True + for i in range(3): + assert emails[i + 1].is_main is False + assert emails[i + 2].email >= emails[i + 1].email + + +def test_models_identities_sub_null(): + """The "sub" field should not be null.""" + user = factories.UserFactory() + with pytest.raises(ValidationError, match="This field cannot be null."): + models.Identity.objects.create(user=user, sub=None) + + +def test_models_identities_sub_null(): + """The "sub" field should not be null.""" + user = factories.UserFactory() + with pytest.raises(ValidationError, match="This field cannot be blank."): + models.Identity.objects.create(user=user, email="david@example.com", sub="") + + +def test_models_identities_sub_unique(): + """The "sub" field should be unique.""" + user = factories.UserFactory() + identity = factories.IdentityFactory() + with pytest.raises(ValidationError, match="Identity with this Sub already exists."): + models.Identity.objects.create(user=user, sub=identity.sub) + + +def test_models_identities_sub_max_length(): + """The sub field should be 255 characters maximum.""" + factories.IdentityFactory(sub="a" * 255) + with pytest.raises(ValidationError) as excinfo: + factories.IdentityFactory(sub="a" * 256) + + assert ( + str(excinfo.value) + == "{'sub': ['Ensure this value has at most 255 characters (it has 256).']}" + ) + + +def test_models_identities_sub_special_characters(): + """The sub field should accept periods, dashes, +, @ and underscores.""" + identity = factories.IdentityFactory(sub="dave.bowman-1+2@hal_9000") + assert identity.sub == "dave.bowman-1+2@hal_9000" + + +def test_models_identities_sub_spaces(): + """The sub field should not accept spaces.""" + with pytest.raises(ValidationError) as excinfo: + factories.IdentityFactory(sub="a b") + + assert ( + str(excinfo.value) + == "{'sub': ['Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.']}" + ) + + +def test_models_identities_sub_upper_case(): + """The sub field should accept upper case characters.""" + identity = factories.IdentityFactory(sub="John") + assert identity.sub == "John" + + +def test_models_identities_sub_ascii(): + """The sub field should accept non ASCII letters.""" + identity = factories.IdentityFactory(sub="rené") + assert identity.sub == "rené" diff --git a/src/backend/core/tests/test_models_team_accesses.py b/src/backend/core/tests/test_models_team_accesses.py new file mode 100644 index 0000000..80adec7 --- /dev/null +++ b/src/backend/core/tests/test_models_team_accesses.py @@ -0,0 +1,262 @@ +""" +Unit tests for the TeamAccess model +""" +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_team_accesses_str(): + """ + The str representation should include user name, team full name and role. + """ + contact = factories.ContactFactory(full_name="David Bowman") + user = contact.owner + user.profile_contact = contact + user.save() + access = factories.TeamAccessFactory( + role="member", + user=user, + team__name="admins", + ) + assert str(access) == "David Bowman is member in team admins" + + +def test_models_team_accesses_unique(): + """Team accesses should be unique for a given couple of user and team.""" + access = factories.TeamAccessFactory() + + with pytest.raises( + ValidationError, + match="Team/user relation with this User and Team already exists.", + ): + factories.TeamAccessFactory(user=access.user, team=access.team) + + +# get_abilities + + +def test_models_team_access_get_abilities_anonymous(): + """Check abilities returned for an anonymous user.""" + access = factories.TeamAccessFactory() + abilities = access.get_abilities(AnonymousUser()) + assert abilities == { + "delete": False, + "get": False, + "patch": False, + "put": False, + "set_role_to": [], + } + + +def test_models_team_access_get_abilities_authenticated(): + """Check abilities returned for an authenticated user.""" + access = factories.TeamAccessFactory() + user = factories.UserFactory() + abilities = access.get_abilities(user) + assert abilities == { + "delete": False, + "get": False, + "patch": False, + "put": False, + "set_role_to": [], + } + + +# - for owner + + +def test_models_team_access_get_abilities_for_owner_of_self_allowed(): + """Check abilities of self access for the owner of a team when there is more than one user left.""" + access = factories.TeamAccessFactory(role="owner") + factories.TeamAccessFactory(team=access.team, role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "delete": True, + "get": True, + "patch": True, + "put": True, + "set_role_to": ["administrator", "member"], + } + + +def test_models_team_access_get_abilities_for_owner_of_self_last(): + """Check abilities of self access for the owner of a team when there is only one owner left.""" + access = factories.TeamAccessFactory(role="owner") + abilities = access.get_abilities(access.user) + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "set_role_to": [], + } + + +def test_models_team_access_get_abilities_for_owner_of_owner(): + """Check abilities of owner access for the owner of a team.""" + access = factories.TeamAccessFactory(role="owner") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="owner").user + abilities = access.get_abilities(user) + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "set_role_to": [], + } + + +def test_models_team_access_get_abilities_for_owner_of_administrator(): + """Check abilities of administrator access for the owner of a team.""" + access = factories.TeamAccessFactory(role="administrator") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="owner").user + abilities = access.get_abilities(user) + assert abilities == { + "delete": True, + "get": True, + "patch": True, + "put": True, + "set_role_to": ["owner", "member"], + } + + +def test_models_team_access_get_abilities_for_owner_of_member(): + """Check abilities of member access for the owner of a team.""" + access = factories.TeamAccessFactory(role="member") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="owner").user + abilities = access.get_abilities(user) + assert abilities == { + "delete": True, + "get": True, + "patch": True, + "put": True, + "set_role_to": ["owner", "administrator"], + } + + +# - for administrator + + +def test_models_team_access_get_abilities_for_administrator_of_owner(): + """Check abilities of owner access for the administrator of a team.""" + access = factories.TeamAccessFactory(role="owner") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="administrator").user + abilities = access.get_abilities(user) + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "set_role_to": [], + } + + +def test_models_team_access_get_abilities_for_administrator_of_administrator(): + """Check abilities of administrator access for the administrator of a team.""" + access = factories.TeamAccessFactory(role="administrator") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="administrator").user + abilities = access.get_abilities(user) + assert abilities == { + "delete": True, + "get": True, + "patch": True, + "put": True, + "set_role_to": ["member"], + } + + +def test_models_team_access_get_abilities_for_administrator_of_member(): + """Check abilities of member access for the administrator of a team.""" + access = factories.TeamAccessFactory(role="member") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="administrator").user + abilities = access.get_abilities(user) + assert abilities == { + "delete": True, + "get": True, + "patch": True, + "put": True, + "set_role_to": ["administrator"], + } + + +# - for member + + +def test_models_team_access_get_abilities_for_member_of_owner(): + """Check abilities of owner access for the member of a team.""" + access = factories.TeamAccessFactory(role="owner") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="member").user + abilities = access.get_abilities(user) + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "set_role_to": [], + } + + +def test_models_team_access_get_abilities_for_member_of_administrator(): + """Check abilities of administrator access for the member of a team.""" + access = factories.TeamAccessFactory(role="administrator") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="member").user + abilities = access.get_abilities(user) + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "set_role_to": [], + } + + +def test_models_team_access_get_abilities_for_member_of_member_user( + django_assert_num_queries +): + """Check abilities of member access for the member of a team.""" + access = factories.TeamAccessFactory(role="member") + factories.TeamAccessFactory(team=access.team) # another one + user = factories.TeamAccessFactory(team=access.team, role="member").user + + with django_assert_num_queries(1): + abilities = access.get_abilities(user) + + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "set_role_to": [], + } + + +def test_models_team_access_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset, e.g., with a query annotation.""" + access = factories.TeamAccessFactory(role="member") + user = factories.TeamAccessFactory(team=access.team, role="member").user + access.user_role = "member" + + with django_assert_num_queries(0): + abilities = access.get_abilities(user) + + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "set_role_to": [], + } diff --git a/src/backend/core/tests/test_models_teams.py b/src/backend/core/tests/test_models_teams.py new file mode 100644 index 0000000..d6e79c7 --- /dev/null +++ b/src/backend/core/tests/test_models_teams.py @@ -0,0 +1,135 @@ +""" +Unit tests for the Team model +""" +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_teams_str(): + """The str representation should be the name of the team.""" + team = factories.TeamFactory(name="admins") + assert str(team) == "admins" + + +def test_models_teams_id_unique(): + """The "id" field should be unique.""" + team = factories.TeamFactory() + with pytest.raises(ValidationError, match="Team with this Id already exists."): + factories.TeamFactory(id=team.id) + + +def test_models_teams_name_null(): + """The "name" field should not be null.""" + with pytest.raises(ValidationError, match="This field cannot be null."): + models.Team.objects.create(name=None) + + +def test_models_teams_name_empty(): + """The "name" field should not be empty.""" + with pytest.raises(ValidationError, match="This field cannot be blank."): + models.Team.objects.create(name="") + + +def test_models_teams_name_max_length(): + """The "name" field should be 100 characters maximum.""" + factories.TeamFactory(name="a " * 50) + with pytest.raises( + ValidationError, + match="Ensure this value has at most 100 characters \(it has 102\).", + ): + factories.TeamFactory(name="a " * 51) + + +# get_abilities + + +def test_models_teams_get_abilities_anonymous(): + """Check abilities returned for an anonymous user.""" + team = factories.TeamFactory() + abilities = team.get_abilities(AnonymousUser()) + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "manage_accesses": False, + } + + +def test_models_teams_get_abilities_authenticated(): + """Check abilities returned for an authenticated user.""" + team = factories.TeamFactory() + abilities = team.get_abilities(factories.UserFactory()) + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "manage_accesses": False, + } + + +def test_models_teams_get_abilities_owner(): + """Check abilities returned for the owner of a team.""" + user = factories.UserFactory() + access = factories.TeamAccessFactory(role="owner", user=user) + abilities = access.team.get_abilities(access.user) + assert abilities == { + "delete": True, + "get": True, + "patch": True, + "put": True, + "manage_accesses": True, + } + + +def test_models_teams_get_abilities_administrator(): + """Check abilities returned for the administrator of a team.""" + access = factories.TeamAccessFactory(role="administrator") + abilities = access.team.get_abilities(access.user) + assert abilities == { + "delete": False, + "get": True, + "patch": True, + "put": True, + "manage_accesses": True, + } + + +def test_models_teams_get_abilities_member_user(django_assert_num_queries): + """Check abilities returned for the member of a team.""" + access = factories.TeamAccessFactory(role="member") + + with django_assert_num_queries(1): + abilities = access.team.get_abilities(access.user) + + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "manage_accesses": False, + } + + +def test_models_teams_get_abilities_preset_role(django_assert_num_queries): + """No query is done if the role is preset e.g. with query annotation.""" + access = factories.TeamAccessFactory(role="member") + access.team.user_role = "member" + + with django_assert_num_queries(0): + abilities = access.team.get_abilities(access.user) + + assert abilities == { + "delete": False, + "get": True, + "patch": False, + "put": False, + "manage_accesses": False, + } diff --git a/src/backend/core/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py new file mode 100644 index 0000000..f9469e0 --- /dev/null +++ b/src/backend/core/tests/test_models_users.py @@ -0,0 +1,84 @@ +""" +Unit tests for the User model +""" +from unittest import mock + +from django.core.exceptions import ValidationError +from django.test.utils import override_settings + +import pytest + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def test_models_users_str(): + """The str representation should be the full name.""" + user = factories.UserFactory() + contact = factories.ContactFactory(full_name="david bowman", owner=user) + user.profile_contact = contact + user.save() + + assert str(user) == "david bowman" + + +def test_models_users_id_unique(): + """The "id" field should be unique.""" + user = factories.UserFactory() + with pytest.raises(ValidationError, match="User with this Id already exists."): + factories.UserFactory(id=user.id) + + +def test_models_users_profile_not_owned(): + """A user cannot declare as profile a contact that not is owned.""" + user = factories.UserFactory() + contact = factories.ContactFactory(base=None, owner=None) + + user.profile_contact = contact + with pytest.raises(ValidationError) as excinfo: + user.save() + + assert ( + str(excinfo.value) + == "{'__all__': ['Users can only declare as profile a contact they own.']}" + ) + + +def test_models_users_profile_owned_by_other(): + """A user cannot declare as profile a contact that is owned by another user.""" + user = factories.UserFactory() + contact = factories.ContactFactory() + + user.profile_contact = contact + with pytest.raises(ValidationError) as excinfo: + user.save() + + assert ( + str(excinfo.value) + == "{'__all__': ['Users can only declare as profile a contact they own.']}" + ) + + +def test_models_users_send_mail_main_existing(): + """The "email_user' method should send mail to the user's main email address.""" + main_email = factories.IdentityFactory(email="dave@example.com") + user = main_email.user + factories.IdentityFactory.create_batch(2, user=user) + + with mock.patch("django.core.mail.send_mail") as mock_send: + user.email_user("my subject", "my message") + + mock_send.assert_called_once_with( + "my subject", "my message", None, ["dave@example.com"] + ) + + +def test_models_users_send_mail_main_missing(): + """The "email_user' method should fail if the user has no email address.""" + user = factories.UserFactory() + + with pytest.raises(models.Identity.DoesNotExist) as excinfo: + user.email_user("my subject", "my message") + + assert str(excinfo.value) == "Identity matching query does not exist." diff --git a/src/backend/core/tests/utils.py b/src/backend/core/tests/utils.py new file mode 100644 index 0000000..76cc381 --- /dev/null +++ b/src/backend/core/tests/utils.py @@ -0,0 +1,21 @@ +"""Utils for tests in the People core application""" +from rest_framework_simplejwt.tokens import AccessToken + + +class OIDCToken(AccessToken): + """Set payload on token from user/contact/email""" + + @classmethod + def for_user(cls, user): + token = super().for_user(user) + identity = user.identities.filter(is_main=True).first() + token["first_name"] = ( + user.profile_contact.short_name if user.profile_contact else "David" + ) + token["last_name"] = ( + " ".join(user.profile_contact.full_name.split()[1:]) + if user.profile_contact + else "Bowman" + ) + token["email"] = identity.email + return token diff --git a/src/backend/manage.py b/src/backend/manage.py new file mode 100644 index 0000000..0b5b150 --- /dev/null +++ b/src/backend/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +""" +People's sandbox management script. +""" +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "people.settings") + os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + + from configurations.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/src/backend/people/__init__.py b/src/backend/people/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/people/api_urls.py b/src/backend/people/api_urls.py new file mode 100644 index 0000000..d3ddb75 --- /dev/null +++ b/src/backend/people/api_urls.py @@ -0,0 +1,36 @@ +"""API URL Configuration""" +from django.conf import settings +from django.urls import include, path, re_path + +from rest_framework.routers import DefaultRouter + +from core.api import viewsets + +# - Main endpoints +router = DefaultRouter() +router.register("contacts", viewsets.ContactViewSet, basename="contacts") +router.register("teams", viewsets.TeamViewSet, basename="teams") +router.register("users", viewsets.UserViewSet, basename="users") + +# - Routes nested under a team +team_related_router = DefaultRouter() +team_related_router.register( + "accesses", + viewsets.TeamAccessViewSet, + basename="team_accesses", +) + +urlpatterns = [ + path( + f"api/{settings.API_VERSION}/", + include( + [ + *router.urls, + re_path( + r"^teams/(?P[0-9a-z-]*)/", + include(team_related_router.urls), + ), + ] + ), + ) +] diff --git a/src/backend/people/celery_app.py b/src/backend/people/celery_app.py new file mode 100644 index 0000000..420588a --- /dev/null +++ b/src/backend/people/celery_app.py @@ -0,0 +1,22 @@ +"""People celery configuration file.""" +import os + +from celery import Celery +from configurations.importer import install + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "people.settings") +os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + +install(check_options=True) + +app = Celery("people") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py new file mode 100755 index 0000000..91b3a1a --- /dev/null +++ b/src/backend/people/settings.py @@ -0,0 +1,509 @@ +""" +Django settings for People project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" +import json +import os + +from django.utils.translation import gettext_lazy as _ + +import sentry_sdk +from configurations import Configuration, values +from sentry_sdk.integrations.django import DjangoIntegration + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.path.join("/", "data") + + +def get_release(): + """ + Get the current release of the application + + By release, we mean the release from the version.json file à la Mozilla [1] + (if any). If this file has not been found, it defaults to "NA". + + [1] + https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md + """ + # Try to get the current release from the version.json file generated by the + # CI during the Docker image build + try: + with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version: + return json.load(version)["version"] + except FileNotFoundError: + return "NA" # Default: not available + + +class Base(Configuration): + """ + This is the base configuration every configuration (aka environnement) should inherit from. It + is recommended to configure third-party applications by creating a configuration mixins in + ./configurations and compose the Base configuration with those mixins. + + It depends on an environment variable that SHOULD be defined: + + * DJANGO_SECRET_KEY + + You may also want to override default configuration by setting the following environment + variables: + + * DJANGO_SENTRY_DSN + * DB_NAME + * DB_HOST + * DB_PASSWORD + * DB_USER + """ + + DEBUG = False + USE_SWAGGER = False + + API_VERSION = "v1.0" + + # Security + ALLOWED_HOSTS = values.ListValue([]) + SECRET_KEY = values.Value(None) + + # Application definition + ROOT_URLCONF = "people.urls" + WSGI_APPLICATION = "people.wsgi.application" + + # Database + DATABASES = { + "default": { + "ENGINE": values.Value( + "django.db.backends.postgresql_psycopg2", + environ_name="DB_ENGINE", + environ_prefix=None, + ), + "NAME": values.Value("people", environ_name="DB_NAME", environ_prefix=None), + "USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None), + "PASSWORD": values.Value( + "pass", environ_name="DB_PASSWORD", environ_prefix=None + ), + "HOST": values.Value( + "localhost", environ_name="DB_HOST", environ_prefix=None + ), + "PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None), + } + } + DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + + # Static files (CSS, JavaScript, Images) + STATIC_URL = "/static/" + STATIC_ROOT = os.path.join(DATA_DIR, "static") + MEDIA_URL = "/media/" + MEDIA_ROOT = os.path.join(DATA_DIR, "media") + + SITE_ID = 1 + + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + # Internationalization + # https://docs.djangoproject.com/en/3.1/topics/i18n/ + + # Languages + LANGUAGE_CODE = values.Value("en-us") + + DRF_NESTED_MULTIPART_PARSER = { + # output of parser is converted to querydict + # if is set to False, dict python is returned + "querydict": False, + } + + # Careful! Languages should be ordered by priority, as this tuple is used to get + # fallback/default languages throughout the app. + LANGUAGES = values.SingleNestedTupleValue( + ( + ("en-us", _("English")), + ("fr-fr", _("French")), + ) + ) + + LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),) + + TIME_ZONE = "UTC" + USE_I18N = True + USE_TZ = True + + # Templates + TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "templates")], + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.csrf", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.request", + "django.template.context_processors.tz", + ], + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + }, + }, + ] + + MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + ] + + AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + ] + + # Django applications from the highest priority to the lowest + INSTALLED_APPS = [ + # People + "core", + "drf_spectacular", + # Third party apps + "corsheaders", + "dockerflow.django", + "rest_framework", + "parler", + "easy_thumbnails", + # Django + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.postgres", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + ] + + # Cache + CACHES = { + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, + } + + REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "core.authentication.DelegatedJWTAuthentication", + ), + "DEFAULT_PARSER_CLASSES": [ + "rest_framework.parsers.JSONParser", + "nested_multipart_parser.drf.DrfNestedParser", + ], + "EXCEPTION_HANDLER": "core.api.exception_handler", + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + } + + SPECTACULAR_SETTINGS = { + "TITLE": "People API", + "DESCRIPTION": "This is the People API schema.", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "ENABLE_DJANGO_DEPLOY_CHECK": values.BooleanValue( + default=False, + environ_name="SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK", + ), + "COMPONENT_SPLIT_REQUEST": True, + # OTHER SETTINGS + "SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead + "SWAGGER_UI_FAVICON_HREF": "SIDECAR", + "REDOC_DIST": "SIDECAR", + } + + SIMPLE_JWT = { + "ALGORITHM": values.Value("HS256", environ_name="JWT_ALGORITHM"), + "SIGNING_KEY": values.SecretValue( + environ_name="JWT_PRIVATE_SIGNING_KEY", + ), + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "sub", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + } + JWT_USER_GETTER = values.Value( + "core.models.oidc_user_getter", + environ_name="PEOPLE_JWT_USER_GETTER", + environ_prefix=None, + ) + + # Mail + EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend") + EMAIL_HOST = values.Value(None) + EMAIL_HOST_USER = values.Value(None) + EMAIL_HOST_PASSWORD = values.Value(None) + EMAIL_PORT = values.PositiveIntegerValue(None) + EMAIL_USE_TLS = values.BooleanValue(False) + EMAIL_FROM = values.Value("from@example.com") + + AUTH_USER_MODEL = "core.User" + + # CORS + CORS_ALLOW_CREDENTIALS = True + CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False) + CORS_ALLOWED_ORIGINS = values.ListValue([]) + CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([]) + + # Sentry + SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN") + + # Easy thumbnails + THUMBNAIL_EXTENSION = "webp" + THUMBNAIL_TRANSPARENCY_EXTENSION = "webp" + THUMBNAIL_ALIASES = {} + + # Celery + CELERY_BROKER_URL = values.Value("redis://redis:6379/0") + CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}) + + # pylint: disable=invalid-name + @property + def ENVIRONMENT(self): + """Environment in which the application is launched.""" + return self.__class__.__name__.lower() + + # pylint: disable=invalid-name + @property + def RELEASE(self): + """ + Return the release information. + + Delegate to the module function to enable easier testing. + """ + return get_release() + + # pylint: disable=invalid-name + @property + def PARLER_LANGUAGES(self): + """ + Return languages for Parler computed from the LANGUAGES and LANGUAGE_CODE settings. + """ + return { + self.SITE_ID: tuple({"code": code} for code, _name in self.LANGUAGES), + "default": { + "fallbacks": [self.LANGUAGE_CODE], + "hide_untranslated": False, + }, + } + + @classmethod + def post_setup(cls): + """Post setup configuration. + This is the place where you can configure settings that require other + settings to be loaded. + """ + super().post_setup() + + # The SENTRY_DSN setting should be available to activate sentry for an environment + if cls.SENTRY_DSN is not None: + sentry_sdk.init( + dsn=cls.SENTRY_DSN, + environment=cls.__name__.lower(), + release=get_release(), + integrations=[DjangoIntegration()], + ) + with sentry_sdk.configure_scope() as scope: + scope.set_extra("application", "backend") + + +class Build(Base): + """Settings used when the application is built. + + This environment should not be used to run the application. Just to build it with non-blocking + settings. + """ + + SECRET_KEY = values.Value("DummyKey") + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": values.Value( + "whitenoise.storage.CompressedManifestStaticFilesStorage", + environ_name="STORAGES_STATICFILES_BACKEND", + ), + }, + } + + +class Development(Base): + """ + Development environment settings + + We set DEBUG to True and configure the server to respond from all hosts. + """ + + ALLOWED_HOSTS = ["*"] + CORS_ALLOW_ALL_ORIGINS = True + CSRF_TRUSTED_ORIGINS = ["http://localhost:8072"] + DEBUG = True + + SESSION_COOKIE_NAME = "people_sessionid" + + USE_SWAGGER = True + + def __init__(self): + # pylint: disable=invalid-name + self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"] + + +class Test(Base): + """Test environment settings""" + + LOGGING = values.DictValue( + { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "loggers": { + "people": { + "handlers": ["console"], + "level": "DEBUG", + }, + }, + } + ) + PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", + ] + USE_SWAGGER = True + + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True) + + def __init__(self): + # pylint: disable=invalid-name + self.INSTALLED_APPS += ["drf_spectacular_sidecar"] + + +class ContinuousIntegration(Test): + """ + Continuous Integration environment settings + + nota bene: it should inherit from the Test environment. + """ + + +class Production(Base): + """ + Production environment settings + + You must define the ALLOWED_HOSTS environment variable in Production + configuration (and derived configurations): + ALLOWED_HOSTS=["foo.com", "foo.fr"] + """ + + # Security + ALLOWED_HOSTS = values.ListValue(None) + CSRF_TRUSTED_ORIGINS = values.ListValue([]) + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + + # SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest + # object when your application is behind a reverse proxy. + # + # Keep this SECURE_PROXY_SSL_HEADER configuration only if : + # - your Django app is behind a proxy. + # - your proxy strips the X-Forwarded-Proto header from all incoming requests + # - Your proxy sets the X-Forwarded-Proto header and sends it to Django + # + # In other cases, you should comment the following line to avoid security issues. + # SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + + # Modern browsers require to have the `secure` attribute on cookies with `Samesite=none` + CSRF_COOKIE_SECURE = True + SESSION_COOKIE_SECURE = True + + # For static files in production, we want to use a backend that includes a hash in + # the filename, that is calculated from the file content, so that browsers always + # get the updated version of each file. + STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + }, + "staticfiles": { + # For static files in production, we want to use a backend that includes a hash in + # the filename, that is calculated from the file content, so that browsers always + # get the updated version of each file. + "BACKEND": values.Value( + "whitenoise.storage.CompressedManifestStaticFilesStorage", + environ_name="STORAGES_STATICFILES_BACKEND", + ) + }, + } + + # Privacy + SECURE_REFERRER_POLICY = "same-origin" + + # Media + AWS_S3_ENDPOINT_URL = values.Value() + AWS_S3_ACCESS_KEY_ID = values.Value() + AWS_S3_SECRET_ACCESS_KEY = values.Value() + AWS_STORAGE_BUCKET_NAME = values.Value("tf-default-people-media-storage") + AWS_S3_REGION_NAME = values.Value() + + +class Feature(Production): + """ + Feature environment settings + + nota bene: it should inherit from the Production environment. + """ + + +class Staging(Production): + """ + Staging environment settings + + nota bene: it should inherit from the Production environment. + """ + + +class PreProduction(Production): + """ + Pre-production environment settings + + nota bene: it should inherit from the Production environment. + """ diff --git a/src/backend/people/urls.py b/src/backend/people/urls.py new file mode 100644 index 0000000..5085335 --- /dev/null +++ b/src/backend/people/urls.py @@ -0,0 +1,50 @@ +"""People URL Configuration""" + +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.urls import include, path, re_path + +from drf_spectacular.views import ( + SpectacularJSONAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +from . import api_urls + +API_VERSION = settings.API_VERSION + +urlpatterns = [ + path("admin/", admin.site.urls), +] + api_urls.urlpatterns + +if settings.DEBUG: + urlpatterns = ( + urlpatterns + + staticfiles_urlpatterns() + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + ) + +if settings.USE_SWAGGER or settings.DEBUG: + urlpatterns += [ + path( + f"{API_VERSION}/swagger.json", + SpectacularJSONAPIView.as_view( + api_version=API_VERSION, + urlconf="people.api_urls", + ), + name="client-api-schema", + ), + path( + f"{API_VERSION}/swagger/", + SpectacularSwaggerView.as_view(url_name="client-api-schema"), + name="swagger-ui-schema", + ), + re_path( + f"{API_VERSION}/redoc/", + SpectacularRedocView.as_view(url_name="client-api-schema"), + name="redoc-schema", + ), + ] diff --git a/src/backend/people/wsgi.py b/src/backend/people/wsgi.py new file mode 100644 index 0000000..9ddb2e7 --- /dev/null +++ b/src/backend/people/wsgi.py @@ -0,0 +1,17 @@ +""" +WSGI config for the People project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +""" + +import os + +from configurations.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "people.settings") +os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + +application = get_wsgi_application() diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml new file mode 100644 index 0000000..1a6c39c --- /dev/null +++ b/src/backend/pyproject.toml @@ -0,0 +1,134 @@ +# +# People package +# +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "people" +version = "0.1.0" +authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: Django", + "Framework :: Django :: 5", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +description = "An application to handle contacts and teams." +keywords = ["Django", "Contacts", "Teams", "RBAC"] +license = { file = "LICENSE" } +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "boto3==1.33.6", + "Brotli==1.1.0", + "celery[redis]==5.3.6", + "django-configurations==2.5", + "django-cors-headers==4.3.1", + "django-countries==7.5.1", + "django-parler==2.3", + "django-storages==1.14.2", + "django-timezone-field>=5.1", + "django==5.0", + "djangorestframework-simplejwt==5.3.0", + "djangorestframework==3.14.0", + "drf_spectacular==0.26.5", + "dockerflow==2022.8.0", + "easy_thumbnails==2.8.5", + "factory_boy==3.3.0", + "gunicorn==21.2.0", + "jsonschema==4.20.0", + "nested-multipart-parser==1.5.0", + "psycopg[binary]==3.1.14", + "PyJWT==2.8.0", + "requests==2.31.0", + "sentry-sdk==1.38.0", + "url-normalize==1.4.3", + "whitenoise==6.6.0", +] + +[project.urls] +"Bug Tracker" = "https://github.com/numerique-gouv/people/issues/new" +"Changelog" = "https://github.com/numerique-gouv/people/blob/main/CHANGELOG.md" +"Homepage" = "https://github.com/numerique-gouv/people" +"Repository" = "https://github.com/numerique-gouv/people" + +[project.optional-dependencies] +dev = [ + "django-extensions==3.2.3", + "drf-spectacular-sidecar==2023.12.1", + "ipdb==0.13.13", + "ipython==8.18.1", + "pyfakefs==5.3.2", + "pytest-cov==4.1.0", + "pytest-django==4.7.0", + "pytest==7.4.3", + "pytest-icdiff==0.8", + "pytest-xdist==3.5.0", + "responses==0.24.1", + "ruff==0.1.6", + "types-requests==2.31.0.10", +] + +[tool.setuptools] +packages = { find = { where = ["."], exclude = ["tests"] } } +zip-safe = true + +[tool.distutils.bdist_wheel] +universal = true + +[tool.ruff] +exclude = [ + ".git", + ".venv", + "build", + "venv", + "__pycache__", + "*/migrations/*", +] +ignore= ["DJ001", "PLR2004"] +line-length = 88 + + +[tool.ruff.lint] +select = [ + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "DJ", # flake8-django + "I", # isort + "PLC", # pylint-convention + "PLE", # pylint-error + "PLR", # pylint-refactoring + "PLW", # pylint-warning + "RUF100", # Ruff unused-noqa + "RUF200", # Ruff check pyproject.toml + "S", # flake8-bandit + "SLF", # flake8-self + "T20", # flake8-print +] + +[tool.ruff.lint.isort] +section-order = ["future","standard-library","django","third-party","people","first-party","local-folder"] +sections = { people=["core"], django=["django"] } + +[tool.ruff.per-file-ignores] +"**/tests/*" = ["S", "SLF"] + +[tool.pytest.ini_options] +addopts = [ + "-v", + "--cov-report", + "term-missing", + # Allow test files to have the same name in different directories. + "--import-mode=importlib", +] +python_files = [ + "test_*.py", + "tests.py", +] diff --git a/src/backend/setup.py b/src/backend/setup.py new file mode 100644 index 0000000..9e3c584 --- /dev/null +++ b/src/backend/setup.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +"""Setup file for the people module. All configuration stands in the setup.cfg file.""" +# coding: utf-8 + +from setuptools import setup + +setup() diff --git a/src/mail/bin/html-to-plain-text b/src/mail/bin/html-to-plain-text new file mode 100755 index 0000000..ced0c13 --- /dev/null +++ b/src/mail/bin/html-to-plain-text @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -eo pipefail +# Run html-to-text to convert all html files to text files +DIR_MAILS="../backend/core/templates/mail/" + +if [ ! -d "${DIR_MAILS}" ]; then + mkdir -p "${DIR_MAILS}"; +fi + +if [ ! -d "${DIR_MAILS}"html/ ]; then + mkdir -p "${DIR_MAILS}"html/; + exit; +fi + +for file in "${DIR_MAILS}"html/*.html; + do html-to-text -j ./html-to-text.config.json < "$file" > "${file%.html}".txt; done; + +if [ ! -d "${DIR_MAILS}"text/ ]; then + mkdir -p "${DIR_MAILS}"text/; +fi + +mv "${DIR_MAILS}"html/*.txt "${DIR_MAILS}"text/; diff --git a/src/mail/bin/mjml-to-html b/src/mail/bin/mjml-to-html new file mode 100755 index 0000000..fb5710b --- /dev/null +++ b/src/mail/bin/mjml-to-html @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Run mjml command to convert all mjml templates to html files +DIR_MAILS="../backend/core/templates/mail/html/" + +if [ ! -d "${DIR_MAILS}" ]; then + mkdir -p "${DIR_MAILS}"; +fi +mjml mjml/*.mjml -o "${DIR_MAILS}"; diff --git a/src/mail/html-to-text.config.json b/src/mail/html-to-text.config.json new file mode 100644 index 0000000..8d2e57a --- /dev/null +++ b/src/mail/html-to-text.config.json @@ -0,0 +1,11 @@ +{ + "wordwrap": 600, + "selectors": [ + { + "selector": "h1", + "options": { + "uppercase": false + } + } + ] +} diff --git a/src/mail/mjml/hello.mjml b/src/mail/mjml/hello.mjml new file mode 100644 index 0000000..543c8f4 --- /dev/null +++ b/src/mail/mjml/hello.mjml @@ -0,0 +1,28 @@ + + + + + + + + + + + + +

+ {%if fullname%} + {% blocktranslate with name=fullname %}Hello {{ name }}{% endblocktranslate %} + {% else %} + {%trans "Hello" %} + {% endif %}
+ {%trans "Thank you very much for your visit!"%} +

+
+
+
+
+ +
+
+ diff --git a/src/mail/mjml/partial/footer.mjml b/src/mail/mjml/partial/footer.mjml new file mode 100644 index 0000000..ad343a3 --- /dev/null +++ b/src/mail/mjml/partial/footer.mjml @@ -0,0 +1,9 @@ + + + + {% blocktranslate with href=site.url name=site.name trimmed %} + This mail has been sent to {{email}} by {{name}} + {% endblocktranslate %} + + + diff --git a/src/mail/mjml/partial/header.mjml b/src/mail/mjml/partial/header.mjml new file mode 100644 index 0000000..665a3c0 --- /dev/null +++ b/src/mail/mjml/partial/header.mjml @@ -0,0 +1,48 @@ + + {{ title }} + + + {% load i18n static extra_tags %} + {{ title }} + + + + + + + + + /* Reset */ + h1, h2, h3, h4, h5, h6, p { + margin: 0; + padding: 0; + } + + a { + color: inherit; + } + + + /* Global styles */ + h1 { + color: #055FD2; + font-size: 2rem; + line-height: 1em; + font-weight: 700; + } + + .wrapper { + background: #FFFFFF; + border-radius: 0 0 6px 6px; + box-shadow: 0 0 6px rgba(2 117 180 / 0.3); + } + + diff --git a/src/mail/package.json b/src/mail/package.json new file mode 100644 index 0000000..a23699b --- /dev/null +++ b/src/mail/package.json @@ -0,0 +1,22 @@ +{ + "name": "mail_mjml", + "version": "1.1.0", + "description": "An util to generate html and text django's templates from mjml templates", + "type": "module", + "dependencies": { + "@html-to/text-cli": "0.5.4", + "mjml": "4.14.1" + }, + "private": true, + "scripts": { + "build-mjml-to-html": "./bin/mjml-to-html", + "build-html-to-plain-text": "./bin/html-to-plain-text", + "build": "yarn build-mjml-to-html; yarn build-html-to-plain-text;" + }, + "volta": { + "node": "16.15.1" + }, + "repository": "https://github.com/numerique-gouv/people", + "author": "DINUM", + "license": "MIT" +} diff --git a/src/mail/yarn.lock b/src/mail/yarn.lock new file mode 100644 index 0000000..a1f45e8 --- /dev/null +++ b/src/mail/yarn.lock @@ -0,0 +1,1126 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/runtime@^7.14.6": + version "7.21.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" + integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== + dependencies: + regenerator-runtime "^0.13.11" + +"@html-to/text-cli@0.5.4": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@html-to/text-cli/-/text-cli-0.5.4.tgz#f40804d139e0eaa43c923c3d845cbf1f8b133acf" + integrity sha512-V7WDfiYjXcibHGD6q61oW8HD68UPvBVkKit0X+9v54nTmLe8KDCc+56STleqqP7CzuEK5f/1jqa652fnr9Pmsw== + dependencies: + "@selderee/plugin-htmlparser2" "^0.11.0" + aspargvs "^0.6.0" + deepmerge "^4.3.1" + htmlparser2 "^8.0.2" + selderee "^0.11.0" + +"@selderee/plugin-htmlparser2@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz#d5b5e29a7ba6d3958a1972c7be16f4b2c188c517" + integrity sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ== + dependencies: + domhandler "^5.0.3" + selderee "^0.11.0" + +abbrev@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +ansi-colors@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +aspargvs@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/aspargvs/-/aspargvs-0.6.0.tgz#15991c35425b044cb99868b6b3cfa7e051a28424" + integrity sha512-yUrWCd1hkK5UtDOne1gM3O+FoTFGQ+BVlSd4G7FczBz8+JaFn1uzvQzROxwp9hmlhIUtwSwyRuV9mHgd/WbXxg== + dependencies: + peberminta "^0.8.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +camel-case@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" + integrity sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w== + dependencies: + no-case "^2.2.0" + upper-case "^1.1.1" + +cheerio-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" + integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== + dependencies: + boolbase "^1.0.0" + css-select "^5.1.0" + css-what "^6.1.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + +cheerio@1.0.0-rc.12, cheerio@^1.0.0-rc.12: + version "1.0.0-rc.12" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" + integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== + dependencies: + cheerio-select "^2.1.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + domutils "^3.0.1" + htmlparser2 "^8.0.1" + parse5 "^7.0.0" + parse5-htmlparser2-tree-adapter "^7.0.0" + +chokidar@^3.0.0: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +clean-css@^4.2.1: + version "4.2.4" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178" + integrity sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A== + dependencies: + source-map "~0.6.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^2.19.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +commander@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +config-chain@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-what@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +detect-node@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" + integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== + +detect-node@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" + integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== + +dom-serializer@^1.0.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" + integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.2.0" + entities "^2.0.0" + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" + integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== + dependencies: + domelementtype "^2.0.1" + +domhandler@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" + integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== + dependencies: + domelementtype "^2.2.0" + +domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^2.4.2: + version "2.8.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + +domutils@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.0.1.tgz#696b3875238338cb186b6c0612bd4901c89a4f1c" + integrity sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.1" + +editorconfig@^0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" + integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g== + dependencies: + commander "^2.19.0" + lru-cache "^4.1.5" + semver "^5.6.0" + sigmund "^1.0.1" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + +entities@^4.2.0, entities@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" + integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-goat@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-3.0.0.tgz#e8b5fb658553fe8a3c4959c316c6ebb8c842b19c" + integrity sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw== + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^7.1.1: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^8.0.3: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +html-minifier@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-4.0.0.tgz#cca9aad8bce1175e02e17a8c33e46d8988889f56" + integrity sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig== + dependencies: + camel-case "^3.0.0" + clean-css "^4.2.1" + commander "^2.19.0" + he "^1.2.0" + param-case "^2.1.1" + relateurl "^0.2.7" + uglify-js "^3.5.1" + +htmlparser2@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-5.0.1.tgz#7daa6fc3e35d6107ac95a4fc08781f091664f6e7" + integrity sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ== + dependencies: + domelementtype "^2.0.1" + domhandler "^3.3.0" + domutils "^2.4.2" + entities "^2.0.0" + +htmlparser2@^8.0.1, htmlparser2@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +js-beautify@^1.6.14: + version "1.14.7" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.14.7.tgz#9206296de33f86dc106d3e50a35b7cf8729703b2" + integrity sha512-5SOX1KXPFKx+5f6ZrPsIPEY7NwKeQz47n3jm2i+XeHx9MoRsfQenlOP13FQhWvg8JRS0+XLO6XYUQ2GX+q+T9A== + dependencies: + config-chain "^1.1.13" + editorconfig "^0.15.3" + glob "^8.0.3" + nopt "^6.0.0" + +juice@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/juice/-/juice-9.0.0.tgz#8aed857a896f27f8063e6fba7ecdcd019b4e300c" + integrity sha512-s/IwgQ4caZq3bSnQZlKfdGUqJWy9WzTzB12WSPko9G8uK74H8BJEQvX7GLmFAQ6SLFgAppqC/TUYepKZZaV+JA== + dependencies: + cheerio "^1.0.0-rc.12" + commander "^6.1.0" + mensch "^0.3.4" + slick "^1.12.2" + web-resource-inliner "^6.0.1" + +leac@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" + integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== + +lodash@^4.17.15, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +lower-case@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" + integrity sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA== + +lru-cache@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +mensch@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/mensch/-/mensch-0.3.4.tgz#770f91b46cb16ea5b204ee735768c3f0c491fecd" + integrity sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g== + +mime@^2.4.6: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +mjml-accordion@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-accordion/-/mjml-accordion-4.14.1.tgz#39977d426ed4e828614245c8b2e8212085394d14" + integrity sha512-dpNXyjnhYwhM75JSjD4wFUa9JgHm86M2pa0CoTzdv1zOQz67ilc4BoK5mc2S0gOjJpjBShM5eOJuCyVIuAPC6w== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-body@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-body/-/mjml-body-4.14.1.tgz#31c79c25a74257ff042e287c09fb363a98e1209f" + integrity sha512-YpXcK3o2o1U+fhI8f60xahrhXuHmav6BZez9vIN3ZEJOxPFSr+qgr1cT2iyFz50L5+ZsLIVj2ZY+ALQjdsg8ig== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-button@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-button/-/mjml-button-4.14.1.tgz#a1779555ca4a479c5a52cc0025e8ca0f8e74dad8" + integrity sha512-V1Tl1vQ3lXYvvqHJHvGcc8URr7V1l/ZOsv7iLV4QRrh7kjKBXaRS7uUJtz6/PzEbNsGQCiNtXrODqcijLWlgaw== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-carousel@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-carousel/-/mjml-carousel-4.14.1.tgz#dc90116af6adab22bf2074e54165c3b5b7998328" + integrity sha512-Ku3MUWPk/TwHxVgKEUtzspy/ePaWtN/3z6/qvNik0KIn0ZUIZ4zvR2JtaVL5nd30LHSmUaNj30XMPkCjYiKkFA== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-cli@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-cli/-/mjml-cli-4.14.1.tgz#4f445e30a3573c9bd57ee6d5a2a6bf8d5b1a0b20" + integrity sha512-Gy6MnSygFXs0U1qOXTHqBg2vZX2VL/fAacgQzD4MHq4OuybWaTNSzXRwxBXYCxT3IJB874n2Q0Mxp+Xka+tnZg== + dependencies: + "@babel/runtime" "^7.14.6" + chokidar "^3.0.0" + glob "^7.1.1" + html-minifier "^4.0.0" + js-beautify "^1.6.14" + lodash "^4.17.21" + mjml-core "4.14.1" + mjml-migrate "4.14.1" + mjml-parser-xml "4.14.1" + mjml-validator "4.13.0" + yargs "^16.1.0" + +mjml-column@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-column/-/mjml-column-4.14.1.tgz#828cd4e5f82dcbc6d91bd2ac83d56e7b262becbe" + integrity sha512-iixVCIX1YJtpQuwG2WbDr7FqofQrlTtGQ4+YAZXGiLThs0En3xNIJFQX9xJ8sgLEGGltyooHiNICBRlzSp9fDg== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-core@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-core/-/mjml-core-4.14.1.tgz#f748a137c280b89a8d09fab1a988014c3fc8dcd2" + integrity sha512-di88rSfX+8r4r+cEqlQCO7CRM4mYZrfe2wSCu2je38i+ujjkLpF72cgLnjBlSG5aOUCZgYvlsZ85stqIz9LQfA== + dependencies: + "@babel/runtime" "^7.14.6" + cheerio "1.0.0-rc.12" + detect-node "^2.0.4" + html-minifier "^4.0.0" + js-beautify "^1.6.14" + juice "^9.0.0" + lodash "^4.17.21" + mjml-migrate "4.14.1" + mjml-parser-xml "4.14.1" + mjml-validator "4.13.0" + +mjml-divider@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-divider/-/mjml-divider-4.14.1.tgz#c5f90bffc0cd1321b6a73342163311221dff1d26" + integrity sha512-agqWY0aW2xaMiUOhYKDvcAAfOLalpbbtjKZAl1vWmNkURaoK4L7MgDilKHSJDFUlHGm2ZOArTrq8i6K0iyThBQ== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-group@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-group/-/mjml-group-4.14.1.tgz#5c3f1a99f0f338241697c2971964a6f89a1fd2a7" + integrity sha512-dJt5batgEJ7wxlxzqOfHOI94ABX+8DZBvAlHuddYO4CsLFHYv6XRIArLAMMnAKU76r6p3X8JxYeOjKZXdv49kg== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-head-attributes@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-head-attributes/-/mjml-head-attributes-4.14.1.tgz#cd864f46e039823c4c0c1070745865dac83101d7" + integrity sha512-XdUNOp2csK28kBDSistInOyzWNwmu5HDNr4y1Z7vSQ1PfkmiuS6jWG7jHUjdoMhs27e6Leuyyc6a8gWSpqSWrg== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-head-breakpoint@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-head-breakpoint/-/mjml-head-breakpoint-4.14.1.tgz#60900466174b0e9dc1d763a4836917351a3cc074" + integrity sha512-Qw9l/W/I5Z9p7I4ShgnEpAL9if4472ejcznbBnp+4Gq+sZoPa7iYoEPsa9UCGutlaCh3N3tIi2qKhl9qD8DFxA== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-head-font@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-head-font/-/mjml-head-font-4.14.1.tgz#b642dff1f0542df701ececb80f8ca625d9efb48e" + integrity sha512-oBYm1gaOdEMjE5BoZouRRD4lCNZ1jcpz92NR/F7xDyMaKCGN6T/+r4S5dq1gOLm9zWqClRHaECdFJNEmrDpZqA== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-head-html-attributes@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-head-html-attributes/-/mjml-head-html-attributes-4.14.1.tgz#c9fddb0e8cb813a7f929c17ee8ae2dfdc397611e" + integrity sha512-vlJsJc1Sm4Ml2XvLmp01zsdmWmzm6+jNCO7X3eYi9ngEh8LjMCLIQOncnOgjqm9uGpQu2EgUhwvYFZP2luJOVg== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-head-preview@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-head-preview/-/mjml-head-preview-4.14.1.tgz#b7db35020229aadec857f292f9725cd81014c51f" + integrity sha512-89gQtt3fhl2dkYpHLF5HDQXz/RLpzecU6wmAIT7Dz6etjLGE1dgq2Ay6Bu/OeHjDcT1gbM131zvBwuXw8OydNw== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-head-style@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-head-style/-/mjml-head-style-4.14.1.tgz#6bec3b30fd0ac6ca3ee9806d8721c9e32b0968b6" + integrity sha512-XryOuf32EDuUCBT2k99C1+H87IOM919oY6IqxKFJCDkmsbywKIum7ibhweJdcxiYGONKTC6xjuibGD3fQTTYNQ== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-head-title@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-head-title/-/mjml-head-title-4.14.1.tgz#ff1af20467e8ea7f65a29bbc0c58e05d98cb45a6" + integrity sha512-aIfpmlQdf1eJZSSrFodmlC4g5GudBti2eMyG42M7/3NeLM6anEWoe+UkF/6OG4Zy0tCQ40BDJ5iBZlMsjQICzw== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-head@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-head/-/mjml-head-4.14.1.tgz#27ae83d9023b6b2126cd4dd105685b0b08a8baa3" + integrity sha512-KoCbtSeTAhx05Ugn9TB2UYt5sQinSCb7RGRer5iPQ3CrXj8hT5B5Svn6qvf/GACPkWl4auExHQh+XgLB+r3OEA== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-hero@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-hero/-/mjml-hero-4.14.1.tgz#6e969e24ae1eacff037c3170849f1eb51cda1253" + integrity sha512-TQJ3yfjrKYGkdEWjHLHhL99u/meKFYgnfJvlo9xeBvRjSM696jIjdqaPHaunfw4CP6d2OpCIMuacgOsvqQMWOA== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-image@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-image/-/mjml-image-4.14.1.tgz#825566ce9d79692b3c841f85597e533217a0a960" + integrity sha512-jfKLPHXuFq83okwlNM1Um/AEWeVDgs2JXIOsWp2TtvXosnRvGGMzA5stKLYdy1x6UfKF4c1ovpMS162aYGp+xQ== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-migrate@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-migrate/-/mjml-migrate-4.14.1.tgz#e3e1402f9310c1fed8a0a1c800fab316fbc56d12" + integrity sha512-d+9HKQOhZi3ZFAaFSDdjzJX9eDQGjMf3BArLWNm2okC4ZgfJSpOc77kgCyFV8ugvwc8fFegPnSV60Jl4xtvK2A== + dependencies: + "@babel/runtime" "^7.14.6" + js-beautify "^1.6.14" + lodash "^4.17.21" + mjml-core "4.14.1" + mjml-parser-xml "4.14.1" + yargs "^16.1.0" + +mjml-navbar@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-navbar/-/mjml-navbar-4.14.1.tgz#7979049759a7850f239fd2ee63bc47cc02c5c2e9" + integrity sha512-rNy1Kw8CR3WQ+M55PFBAUDz2VEOjz+sk06OFnsnmNjoMVCjo1EV7OFLDAkmxAwqkC8h4zQWEOFY0MBqqoAg7+A== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-parser-xml@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-parser-xml/-/mjml-parser-xml-4.14.1.tgz#bf20f06614569a2adf017698bb411e16dcd831c2" + integrity sha512-9WQVeukbXfq9DUcZ8wOsHC6BTdhaVwTAJDYMIQglXLwKwN7I4pTCguDDHy5d0kbbzK5OCVxCdZe+bfVI6XANOQ== + dependencies: + "@babel/runtime" "^7.14.6" + detect-node "2.0.4" + htmlparser2 "^8.0.1" + lodash "^4.17.15" + +mjml-preset-core@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-preset-core/-/mjml-preset-core-4.14.1.tgz#9b465f4d1227c928497973b34c5d0903f04dc649" + integrity sha512-uUCqK9Z9d39rwB/+JDV2KWSZGB46W7rPQpc9Xnw1DRP7wD7qAfJwK6AZFCwfTgWdSxw0PwquVNcrUS9yBa9uhw== + dependencies: + "@babel/runtime" "^7.14.6" + mjml-accordion "4.14.1" + mjml-body "4.14.1" + mjml-button "4.14.1" + mjml-carousel "4.14.1" + mjml-column "4.14.1" + mjml-divider "4.14.1" + mjml-group "4.14.1" + mjml-head "4.14.1" + mjml-head-attributes "4.14.1" + mjml-head-breakpoint "4.14.1" + mjml-head-font "4.14.1" + mjml-head-html-attributes "4.14.1" + mjml-head-preview "4.14.1" + mjml-head-style "4.14.1" + mjml-head-title "4.14.1" + mjml-hero "4.14.1" + mjml-image "4.14.1" + mjml-navbar "4.14.1" + mjml-raw "4.14.1" + mjml-section "4.14.1" + mjml-social "4.14.1" + mjml-spacer "4.14.1" + mjml-table "4.14.1" + mjml-text "4.14.1" + mjml-wrapper "4.14.1" + +mjml-raw@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-raw/-/mjml-raw-4.14.1.tgz#d543e40f7e1c3468593d6783e7e4ce10bfd9b2d8" + integrity sha512-9+4wzoXnCtfV6QPmjfJkZ50hxFB4Z8QZnl2Ac0D1Cn3dUF46UkmO5NLMu7UDIlm5DdFyycZrMOwvZS4wv9ksPw== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-section@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-section/-/mjml-section-4.14.1.tgz#3309aae46aca1b33f034c5f3b9dad883c52f2267" + integrity sha512-Ik5pTUhpT3DOfB3hEmAWp8rZ0ilWtIivnL8XdUJRfgYE9D+MCRn+reIO+DAoJHxiQoI6gyeKkIP4B9OrQ7cHQw== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-social@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-social/-/mjml-social-4.14.1.tgz#7fe45c7c8c328142d2514e5c61d8c3939ee97e3f" + integrity sha512-G44aOZXgZHukirjkeQWTTV36UywtE2YvSwWGNfo/8d+k5JdJJhCIrlwaahyKEAyH63G1B0Zt8b2lEWx0jigYUw== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-spacer@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-spacer/-/mjml-spacer-4.14.1.tgz#24fa57fb5e7781825ab3f03c1bec108afd0e97da" + integrity sha512-5SfQCXTd3JBgRH1pUy6NVZ0lXBiRqFJPVHBdtC3OFvUS3q1w16eaAXlIUWMKTfy8CKhQrCiE6m65kc662ZpYxA== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-table@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-table/-/mjml-table-4.14.1.tgz#f22ff9ec9b74cd4c3d677866b68e740b07fdf700" + integrity sha512-aVBdX3WpyKVGh/PZNn2KgRem+PQhWlvnD00DKxDejRBsBSKYSwZ0t3EfFvZOoJ9DzfHsN0dHuwd6Z18Ps44NFQ== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-text@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-text/-/mjml-text-4.14.1.tgz#09ba10828976fc7e2493ac3187f6a6e5e5b50442" + integrity sha512-yZuvf5z6qUxEo5CqOhCUltJlR6oySKVcQNHwoV5sneMaKdmBiaU4VDnlYFera9gMD9o3KBHIX6kUg7EHnCwBRQ== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + +mjml-validator@4.13.0: + version "4.13.0" + resolved "https://registry.yarnpkg.com/mjml-validator/-/mjml-validator-4.13.0.tgz#a05bac51535cb8073a253304105ffbaf88f85d26" + integrity sha512-uURYfyQYtHJ6Qz/1A7/+E9ezfcoISoLZhYK3olsxKRViwaA2Mm8gy/J3yggZXnsUXWUns7Qymycm5LglLEIiQg== + dependencies: + "@babel/runtime" "^7.14.6" + +mjml-wrapper@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml-wrapper/-/mjml-wrapper-4.14.1.tgz#d52478d0529584343aa7924a012e26c084673ae0" + integrity sha512-aA5Xlq6d0hZ5LY+RvSaBqmVcLkvPvdhyAv3vQf3G41Gfhel4oIPmkLnVpHselWhV14A0KwIOIAKVxHtSAxyOTQ== + dependencies: + "@babel/runtime" "^7.14.6" + lodash "^4.17.21" + mjml-core "4.14.1" + mjml-section "4.14.1" + +mjml@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/mjml/-/mjml-4.14.1.tgz#4c9ca49bb6a4df51c204d2448e3385d5e166ec00" + integrity sha512-f/wnWWIVbeb/ge3ff7c/KYYizI13QbGIp03odwwkCThsJsacw4gpZZAU7V4gXY3HxSXP2/q3jxOfaHVbkfNpOQ== + dependencies: + "@babel/runtime" "^7.14.6" + mjml-cli "4.14.1" + mjml-core "4.14.1" + mjml-migrate "4.14.1" + mjml-preset-core "4.14.1" + mjml-validator "4.13.0" + +no-case@^2.2.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" + integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ== + dependencies: + lower-case "^1.1.1" + +node-fetch@^2.6.0: + version "2.6.9" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" + integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== + dependencies: + whatwg-url "^5.0.0" + +nopt@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d" + integrity sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g== + dependencies: + abbrev "^1.0.0" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +param-case@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247" + integrity sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w== + dependencies: + no-case "^2.2.0" + +parse5-htmlparser2-tree-adapter@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" + integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g== + dependencies: + domhandler "^5.0.2" + parse5 "^7.0.0" + +parse5@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + +parseley@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.0.tgz#e0dc6b0f99201d136ce756d5f3cec9461ad09830" + integrity sha512-uLqDm6IQVb6m50a3dIxF66hI8VWr7wFDYUULtHa1ITRh9mwYIXzFpPTkPM66Cm5V0t+bMyeSHgUCGzoXTV96LQ== + dependencies: + leac "^0.6.0" + peberminta "^0.9.0" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +peberminta@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.8.0.tgz#acf7b105f3d13c8ac28cad81f2f5fe4698507590" + integrity sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw== + +peberminta@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352" + integrity sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ== + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +regenerator-runtime@^0.13.11: + version "0.13.11" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" + integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== + +relateurl@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" + integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +selderee@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.11.0.tgz#6af0c7983e073ad3e35787ffe20cefd9daf0ec8a" + integrity sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA== + dependencies: + parseley "^0.12.0" + +semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +sigmund@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" + integrity sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g== + +slick@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/slick/-/slick-1.12.2.tgz#bd048ddb74de7d1ca6915faa4a57570b3550c2d7" + integrity sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A== + +source-map@~0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +uglify-js@^3.5.1: + version "3.17.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" + integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== + +upper-case@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" + integrity sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA== + +valid-data-url@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/valid-data-url/-/valid-data-url-3.0.1.tgz#826c1744e71b5632e847dd15dbd45b9fb38aa34f" + integrity sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA== + +web-resource-inliner@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz#df0822f0a12028805fe80719ed52ab6526886e02" + integrity sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A== + dependencies: + ansi-colors "^4.1.1" + escape-goat "^3.0.0" + htmlparser2 "^5.0.0" + mime "^2.4.6" + node-fetch "^2.6.0" + valid-data-url "^3.0.0" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A== + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs@^16.1.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" diff --git a/src/tsclient/package.json b/src/tsclient/package.json new file mode 100644 index 0000000..9b9ede3 --- /dev/null +++ b/src/tsclient/package.json @@ -0,0 +1,25 @@ +{ + "name": "people-openapi-client-ts", + "version": "1.1.0", + "private": true, + "description": "Tool to generate Typescript API client for the People application.", + "scripts": { + "generate:api:client:local": "./scripts/openapi-typescript-codegen/generate_api_client_local.sh $1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/numerique-gouv/people.git" + }, + "author": { + "name": "DINUM", + "email": "dev@mail.numerique.gouv.fr" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/numerique-gouv/people/issues" + }, + "homepage": "https://github.com/numerique-gouv/people#readme", + "devDependencies": { + "openapi-typescript-codegen": "0.25.0" + } +} diff --git a/src/tsclient/scripts/openapi-typescript-codegen/generate_api_client_local.sh b/src/tsclient/scripts/openapi-typescript-codegen/generate_api_client_local.sh new file mode 100755 index 0000000..317709e --- /dev/null +++ b/src/tsclient/scripts/openapi-typescript-codegen/generate_api_client_local.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# usage: yarn generate:api:client:local [--output] + +# OPTIONS: +# --output the path folder where types will be generated + +openapi --input http://app-dev:8000/v1.0/swagger.json --output $1 --indent='2' --name ApiClientPeople --useOptions diff --git a/src/tsclient/yarn.lock b/src/tsclient/yarn.lock new file mode 100644 index 0000000..17f6fd0 --- /dev/null +++ b/src/tsclient/yarn.lock @@ -0,0 +1,133 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@apidevtools/json-schema-ref-parser@9.0.9": + version "9.0.9" + resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#d720f9256e3609621280584f2b47ae165359268b" + integrity sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w== + dependencies: + "@jsdevtools/ono" "^7.1.3" + "@types/json-schema" "^7.0.6" + call-me-maybe "^1.0.1" + js-yaml "^4.1.0" + +"@jsdevtools/ono@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" + integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== + +"@types/json-schema@^7.0.6": + version "7.0.11" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" + integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +call-me-maybe@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" + integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ== + +camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +commander@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" + integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== + +fs-extra@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" + integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== + +handlebars@^4.7.7: + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-schema-ref-parser@^9.0.9: + version "9.0.9" + resolved "https://registry.yarnpkg.com/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#66ea538e7450b12af342fa3d5b8458bc1e1e013f" + integrity sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q== + dependencies: + "@apidevtools/json-schema-ref-parser" "9.0.9" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +minimist@^1.2.5: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +neo-async@^2.6.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +openapi-typescript-codegen@0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/openapi-typescript-codegen/-/openapi-typescript-codegen-0.25.0.tgz#0cb028f54b33b0a63bd9da3756c1c41b4e1a70e2" + integrity sha512-nN/TnIcGbP58qYgwEEy5FrAAjePcYgfMaCe3tsmYyTgI3v4RR9v8os14L+LEWDvV50+CmqiyTzRkKKtJeb6Ybg== + dependencies: + camelcase "^6.3.0" + commander "^11.0.0" + fs-extra "^11.1.1" + handlebars "^4.7.7" + json-schema-ref-parser "^9.0.9" + +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +uglify-js@^3.1.4: + version "3.17.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" + integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==