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==