Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b364d386 | |||
| b7f61e73c2 | |||
| a8e3d8d20e | |||
| 43c18cb4e6 | |||
| bbe8f32b96 | |||
| 1e025f034f | |||
| 2f7449f727 | |||
| 863c85e3f0 | |||
| 28a972e19e | |||
| 90a3e26c7f | |||
| 59f3499799 | |||
| 6123e11dd4 | |||
| 6be1b63277 | |||
| ac853299d3 | |||
| 4d3097e322 | |||
| a10f65a51f | |||
| 33e05f7a2d | |||
| 1e45f1ffd1 | |||
| 9c894bdbe9 | |||
| 20a8edd3aa | |||
| 0b0b77cead | |||
| 21bf431940 | |||
| 8f30264445 | |||
| bde91d55da | |||
| a328e16e53 | |||
| edde9c8d15 | |||
| a18f06ed27 | |||
| 72abe04c72 | |||
| 79e92214ab | |||
| e5f1151f58 | |||
| ca886c19b0 | |||
| b602478406 | |||
| 8b0f942f9b | |||
| bd6fe584c6 | |||
| 821db276bc | |||
| 639490d41e | |||
| 748e24cab6 | |||
| 55c0815c31 | |||
| 989239082e | |||
| 988a091e53 | |||
| 20a19d36b5 | |||
| 7d695ab81c | |||
| a42e7a10db | |||
| ad4065e682 | |||
| ababcde0d6 | |||
| faf8dcc7e5 | |||
| 2bbed8de0c | |||
| a736a9143f | |||
| c4ea62dc1f | |||
| 1d1f5cfbb6 | |||
| 3934a0bc28 | |||
| fd3ac00ea7 | |||
| 2d46b7d504 | |||
| 54e5be81e2 | |||
| 31944e8083 | |||
| d1421dbe8c | |||
| c8800259ae | |||
| bfc2462103 |
@@ -1,4 +1,5 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -15,6 +16,44 @@ env:
|
||||
DOCKER_USER: 1001:127
|
||||
|
||||
jobs:
|
||||
trivy-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
lasuite/people-backend
|
||||
lasuite/people-frontend
|
||||
-
|
||||
name: Run trivy scan (backend)
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/people-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Run trivy scan (frontend)
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target frontend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
|
||||
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -48,9 +87,16 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
with:
|
||||
name: "version.json"
|
||||
json: '{"source":"${{github.repository}}", "version":"${{github.ref_name}}", "commit":"${{github.sha}}", "build": "NA"}'
|
||||
dir: 'src/backend'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
@@ -88,13 +134,20 @@ jobs:
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
with:
|
||||
name: "version.json"
|
||||
json: '{"source":"${{github.repository}}", "version":"${{github.ref_name}}", "commit":"${{github.sha}}", "build": "NA"}'
|
||||
dir: 'src/frontend/apps/desk'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: frontend-production
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 0
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
|
||||
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
|
||||
@@ -147,6 +147,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-mails, build-front]
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -196,15 +201,35 @@ jobs:
|
||||
run: cd src/frontend/apps/e2e && yarn install
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test
|
||||
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
- name: Save logs
|
||||
if: always()
|
||||
run: docker compose logs > src/frontend/apps/e2e/report/logs.txt
|
||||
|
||||
- name: Save Dimail logs
|
||||
if: always()
|
||||
run: docker compose logs dimail > src/frontend/apps/e2e/report/dimail-logs.txt
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
name: playwright-report-${{ matrix.shardIndex }}
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
|
||||
tests-e2e-feedback:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-e2e]
|
||||
if: always()
|
||||
steps:
|
||||
- name: All tests ok
|
||||
if: ${{ !(contains(needs.*.result, 'failure')) }}
|
||||
run: exit 0
|
||||
- name: Some tests failed
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -226,8 +251,8 @@ jobs:
|
||||
- name: Persist mails' templates
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -42,7 +42,6 @@ env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
@@ -59,11 +58,6 @@ src/frontend/tsclient
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Terraform
|
||||
.terraform
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
coverage.json
|
||||
|
||||
@@ -8,3 +8,4 @@ creation_rules:
|
||||
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
|
||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
|
||||
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
|
||||
|
||||
+40
-1
@@ -8,6 +8,42 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.0] - 2024-11-20
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(teams) remove search by trigram for team access and contact
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(domains) allow creation of "pending" mailboxes
|
||||
- ✨(teams) allow team management for team admins/owners #509
|
||||
|
||||
## [1.5.0] - 2024-11-14
|
||||
|
||||
### Removed
|
||||
|
||||
- ⬆️(dependencies) remove unneeded dependencies
|
||||
- 🔥(teams) remove pagination of teams listing
|
||||
- 🔥(teams) remove search users by trigram
|
||||
- 🗃️(teams) remove `slug` field
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(dimail) send domain creation requests to dimail #454
|
||||
- ✨(dimail) synchronize mailboxes from dimail to our db #453
|
||||
- ✨(ci) add security scan #429
|
||||
- ✨(teams) register contacts on admin views
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(mail) fix display button on outlook
|
||||
- 💚(ci) improve E2E tests #492
|
||||
- 🔧(sentry) restore default integrations
|
||||
- 🔇(backend) remove Sentry duplicated warning/errors
|
||||
- 👷(ci) add sharding e2e tests #467
|
||||
- 🐛(dimail) fix unexpected status_code for proper debug #454
|
||||
|
||||
## [1.4.1] - 2024-10-23
|
||||
|
||||
### Fixed
|
||||
@@ -35,6 +71,7 @@ and this project adheres to
|
||||
- ✨(api) add RELEASE version on config endpoint #459
|
||||
- ✨(backend) manage roles on domain admin view
|
||||
- ✨(frontend) show version number in footer #369
|
||||
- 👔(backend) add Organization model
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -124,7 +161,9 @@ and this project adheres to
|
||||
- ✨(domains) create and manage domains on admin + API
|
||||
- ✨(domains) mailbox creation + link to email provisioning API
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.4.1...main
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.6.0...main
|
||||
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
|
||||
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
|
||||
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
|
||||
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
|
||||
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
|
||||
|
||||
+16
-24
@@ -1,15 +1,14 @@
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.10-slim-bullseye as base
|
||||
FROM python:3.12.6-alpine3.20 as base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
### ---- Front-end dependencies image ----
|
||||
FROM node:20 as frontend-deps
|
||||
@@ -40,7 +39,7 @@ FROM frontend-builder-dev as frontend-builder
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
@@ -88,11 +87,9 @@ 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/*
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
@@ -116,16 +113,13 @@ 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/*
|
||||
RUN apk add \
|
||||
gettext \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -155,9 +149,7 @@ FROM core as backend-development
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall people and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
|
||||
@@ -131,11 +131,6 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
reset-dimail-container:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
@$(MAKE) setup-dimail-db
|
||||
.PHONY: reset-dimail-container
|
||||
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
@@ -283,6 +278,10 @@ i18n-generate-and-upload: \
|
||||
# -- INTEROPERABILTY
|
||||
# -- Dimail configuration
|
||||
|
||||
recreate-dimail-container:
|
||||
@$(COMPOSE) up --force-recreate -d dimail
|
||||
.PHONY: recreate-dimail-container
|
||||
|
||||
dimail-setup-db:
|
||||
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
|
||||
@$(MANAGE) setup_dimail_db
|
||||
|
||||
@@ -5,7 +5,6 @@ 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"
|
||||
|
||||
@@ -92,66 +91,3 @@ function _dc_exec() {
|
||||
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<nb_projects; i++ )); do
|
||||
prompt+="[$((i+1))] ${projects[$i]}"
|
||||
if [[ $((i+1)) -eq ${default} ]]; then
|
||||
prompt+=" (default)"
|
||||
fi
|
||||
prompt+="\\n"
|
||||
done
|
||||
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
|
||||
prompt+="Your choice: "
|
||||
read -r -p "$(echo -e "${prompt}")" choice
|
||||
|
||||
if [[ ${choice} -gt nb_projects ]]; then
|
||||
(>&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}"
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/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 "$@"
|
||||
+4
-17
@@ -35,6 +35,7 @@ services:
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- dimail
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
@@ -116,22 +117,6 @@ services:
|
||||
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
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
@@ -168,7 +153,9 @@ services:
|
||||
- kc_postgresql
|
||||
|
||||
dimail:
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:latest
|
||||
entrypoint: /opt/dimail-api/start-dev.sh
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.0.12
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
DIMAIL_JWT_SECRET: fake_jwt_secret
|
||||
|
||||
@@ -99,6 +99,216 @@
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member",
|
||||
"email": "jean.team-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator",
|
||||
"email": "jean.team-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner",
|
||||
"email": "jean.team-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-member",
|
||||
"email": "jean.mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-administrator",
|
||||
"email": "jean.mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-owner",
|
||||
"email": "jean.mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-member",
|
||||
"email": "jean.team-member-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-administrator",
|
||||
"email": "jean.team-member-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-owner",
|
||||
"email": "jean.team-member-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-member",
|
||||
"email": "jean.team-administrator-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-administrator",
|
||||
"email": "jean.team-administrator-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-owner",
|
||||
"email": "jean.team-administrator-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-member",
|
||||
"email": "jean.team-owner-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-administrator",
|
||||
"email": "jean.team-owner-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-owner",
|
||||
"email": "jean.team-owner-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
|
||||
@@ -9,19 +9,29 @@ API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.
|
||||
|
||||
## Architectural links of dimail
|
||||
|
||||
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users and accesses.
|
||||
As dimail's primary goal is to act as an interface between People and OX, its architecture is similar to that of People. A series of requests are sent from People to dimail upon creating domains, users, permissions and mailboxes.
|
||||
|
||||
### Domains
|
||||
|
||||
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered valid and mailboxes can be created on it.
|
||||
Upon creating a domain on People, the same domain is created on dimail and will undergo a series of checks. When all checks have passed, the domain is considered enabled.
|
||||
|
||||
Domains in OX have a field called "context". Context is a shared space between domains, allowing users to discover users not only on their domain but on their entire context.
|
||||
|
||||
### Mailboxes
|
||||
|
||||
Mailboxes can be created by a domain owners or administrators in People's domain tab.
|
||||
|
||||
On enabled domains, mailboxes are created at the same time on dimail (and a confirmation email is sent to the secondary email).
|
||||
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's activation.
|
||||
On disabled domains, mailboxes creation is not allowed.
|
||||
|
||||
### Users
|
||||
|
||||
The ones issuing requests. Dimail users will reflect domains owners and administrators on People, for logging purposes and to allow direct use of dimail, if desired. User reconciliation is made on user uuid provided by ProConnect.
|
||||
|
||||
### Accesses
|
||||
### Permissions
|
||||
|
||||
As for People, an access - a permissions (or "allows" in dimail) - grants an user permission to create objects on a domain.
|
||||
As for People, an access - a permissions (or an "allow" in dimail) - grants an user permission to create objects on a domain.
|
||||
|
||||
Permissions requests are sent automatically upon :
|
||||
- dimail database initialisation:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# E2E tests
|
||||
|
||||
## Run E2E tests
|
||||
|
||||
``` bash
|
||||
# you need the dockers to be up and running
|
||||
make bootstrap
|
||||
|
||||
# you will need to have few accounts in the database
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
# run the tests
|
||||
cd src/frontend/apps/e2e
|
||||
yarn test:ui --workers=1
|
||||
```
|
||||
|
||||
A new browser window will open and you will be able to run the tests.
|
||||
|
||||
## Available accounts
|
||||
|
||||
The `make demo` command creates the following accounts:
|
||||
- `jean.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only belong to a team with the specified role.
|
||||
- `jean.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only have a mailbox with the specified role access.
|
||||
- `jean.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
|
||||
previous accounts.
|
||||
|
||||
For each account, the password is `password-e2e-<username>`, for instance `password-e2e-jean.team-member`.
|
||||
|
||||
In the E2E tests you can use these accounts to benefit from there accesses,
|
||||
using the `keyCloakSignIn(page, browserName, <account_name>)`. The account name is the
|
||||
username without the prefix `jean.`.
|
||||
|
||||
``` typescript jsx
|
||||
await keyCloakSignIn(page, browserName, 'mail-owner');
|
||||
```
|
||||
|
||||
The `keyCloakSignIn` function will sign in the user on Keycloak using the proper username and password.
|
||||
|
||||
.. note::
|
||||
This only works because the OIDC setting is set to fallback on user email.
|
||||
|
||||
## Add a new account
|
||||
|
||||
In case you need to add a new account for specific tests you need:
|
||||
- to create a new user with the same format in the backend database:
|
||||
update `[create_demo.py](../src/backend/demo/management/commands/create_demo.py)`
|
||||
- to create a new account in Keycloak: update [realm.json](../docker/auth/realm.json)
|
||||
- if the keycloak was running locally, you need to destroy its database and
|
||||
restart the database and the keycloak containers.
|
||||
+7
-2
@@ -1,3 +1,5 @@
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
@@ -95,10 +97,13 @@ def prepare_release(version, kind):
|
||||
run_command(f"git push origin {branch_to_release}", shell=True)
|
||||
sys.stdout.write(f"""
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR and merge code to main than tag version
|
||||
--> Please submit PR and merge code to main
|
||||
--> Then do:
|
||||
>> git checkout main
|
||||
>> git pull
|
||||
>> git tag -a v{version}
|
||||
>> git push origin v{version}
|
||||
--> Please check docker image: https://hub.docker.com/r/lasuite/people-backend/tags
|
||||
--> Please check and wait for the docker image v{version} to be published here: https://hub.docker.com/r/lasuite/people-backend/tags
|
||||
>> git tag -d preprod
|
||||
>> git tag preprod
|
||||
>> git push -f origin preprod
|
||||
|
||||
@@ -363,7 +363,8 @@ name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
# Ignore: private stuff and `Params` class from FactoryBoy
|
||||
no-docstring-rgx=(^_|^Params$)
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Root module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core root module."""
|
||||
|
||||
@@ -18,6 +18,15 @@ class TeamAccessInline(admin.TabularInline):
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
class OrganizationAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for organization accesses."""
|
||||
|
||||
autocomplete_fields = ["user", "organization"]
|
||||
extra = 0
|
||||
model = models.OrganizationAccess
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
class TeamWebhookInline(admin.TabularInline):
|
||||
"""Inline admin class for team webhooks."""
|
||||
|
||||
@@ -31,6 +40,7 @@ class TeamWebhookInline(admin.TabularInline):
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
|
||||
autocomplete_fields = ["organization"]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -67,9 +77,10 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (TeamAccessInline, MailDomainAccessInline)
|
||||
inlines = (TeamAccessInline, MailDomainAccessInline, OrganizationAccessInline)
|
||||
list_display = (
|
||||
"get_user",
|
||||
"organization",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_active",
|
||||
@@ -104,7 +115,6 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
inlines = (TeamAccessInline, TeamWebhookInline)
|
||||
list_display = (
|
||||
"name",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
@@ -148,8 +158,8 @@ class InvitationAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Mark all fields read only, i.e. disable update."""
|
||||
if obj:
|
||||
# all fields read only = disable update
|
||||
return self.fields
|
||||
return self.readonly_fields
|
||||
|
||||
@@ -162,5 +172,44 @@ class InvitationAdmin(admin.ModelAdmin):
|
||||
return super().change_view(request, object_id, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Fill in current logged-in user as issuer."""
|
||||
obj.issuer = request.user
|
||||
obj.save()
|
||||
|
||||
|
||||
@admin.register(models.Contact)
|
||||
class ContactAdmin(admin.ModelAdmin):
|
||||
"""Contact admin interface declaration."""
|
||||
|
||||
list_display = (
|
||||
"full_name",
|
||||
"owner",
|
||||
"base",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for organizations."""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
inlines = (OrganizationAccessInline,)
|
||||
|
||||
|
||||
@admin.register(models.OrganizationAccess)
|
||||
class OrganizationAccessAdmin(admin.ModelAdmin):
|
||||
"""Organization access admin interface declaration."""
|
||||
|
||||
autocomplete_fields = ("user", "organization")
|
||||
list_display = (
|
||||
"user",
|
||||
"organization",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
@@ -12,13 +12,13 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check auth token first."""
|
||||
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.
|
||||
Allows access only to user's own data.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
|
||||
@@ -34,6 +34,8 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Pass arguments to superclass except 'fields', then drop fields not listed therein."""
|
||||
|
||||
# Don't pass the 'fields' arg up to the superclass
|
||||
fields = kwargs.pop("fields", None)
|
||||
|
||||
@@ -69,6 +71,39 @@ class UserSerializer(DynamicFieldsModelSerializer):
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
|
||||
class UserMeSerializer(UserSerializer):
|
||||
"""
|
||||
Serialize the current user.
|
||||
|
||||
Same as the `UserSerializer` but with abilities.
|
||||
"""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"email",
|
||||
"id",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
"language",
|
||||
"name",
|
||||
"timezone",
|
||||
# added fields
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
def get_abilities(self, user: models.User) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
if user != self.context["request"].user: # Should not happen
|
||||
raise RuntimeError(
|
||||
"UserMeSerializer.get_abilities: user is not the same as the request user",
|
||||
)
|
||||
return user.get_abilities()
|
||||
|
||||
|
||||
class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize team accesses."""
|
||||
|
||||
@@ -170,7 +205,6 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
slug = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
@@ -179,7 +213,6 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -187,11 +220,18 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new team with organization enforcement."""
|
||||
# Note: this is not the purpose of this API to check the user has an organization
|
||||
return super().create(
|
||||
validated_data=validated_data
|
||||
| {"organization_id": self.context["request"].user.organization_id}
|
||||
)
|
||||
|
||||
def get_abilities(self, team) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
@@ -199,10 +239,6 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
return team.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def get_slug(self, instance):
|
||||
"""Return slug from the team's name."""
|
||||
return instance.get_slug()
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""API endpoints"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import OuterRef, Q, Subquery
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
@@ -85,16 +83,23 @@ class SerializerPerActionMixin:
|
||||
|
||||
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
|
||||
Example:
|
||||
```
|
||||
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
|
||||
serializer_class = MySerializer
|
||||
list_serializer_class = MyListSerializer
|
||||
retrieve_serializer_class = MyRetrieveSerializer
|
||||
```
|
||||
"""
|
||||
|
||||
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)
|
||||
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
|
||||
return serializer_class
|
||||
return super().get_serializer_class()
|
||||
|
||||
|
||||
class Pagination(pagination.PageNumberPagination):
|
||||
@@ -150,19 +155,11 @@ class ContactViewSet(
|
||||
overriding_contacts__isnull=True,
|
||||
)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
# Search by case-insensitive and accent-insensitive 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")
|
||||
queryset = queryset.filter(
|
||||
Q(full_name__unaccent__icontains=query)
|
||||
| Q(short_name__unaccent__icontains=query)
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
@@ -176,20 +173,22 @@ class ContactViewSet(
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
|
||||
SerializerPerActionMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
):
|
||||
"""
|
||||
User viewset for all interactions with user infos and teams.
|
||||
|
||||
GET /api/users/&q=query
|
||||
Return a list of users whose email matches the query. Similarity is
|
||||
calculated using trigram similarity, allowing for partial,
|
||||
case-insensitive matches and accented queries.
|
||||
Return a list of users whose email or name matches the query.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().order_by("-created_at")
|
||||
serializer_class = serializers.UserSerializer
|
||||
get_me_serializer_class = serializers.UserMeSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
|
||||
@@ -207,22 +206,11 @@ class UserViewSet(
|
||||
if team_id := self.request.GET.get("team_id", ""):
|
||||
queryset = queryset.exclude(teams__id=team_id)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
# Search by case-insensitive and accent-insensitive
|
||||
if query := self.request.GET.get("q", ""):
|
||||
similarity = Max(
|
||||
TrigramSimilarity(
|
||||
Coalesce(Func("email", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(Func("name", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(similarity__gte=SIMILARITY_THRESHOLD)
|
||||
.order_by("-similarity")
|
||||
queryset = queryset.filter(
|
||||
Q(name__unaccent__icontains=query)
|
||||
| Q(email__unaccent__icontains=query)
|
||||
)
|
||||
|
||||
return queryset
|
||||
@@ -238,9 +226,7 @@ class UserViewSet(
|
||||
Return information on currently logged user
|
||||
"""
|
||||
user = request.user
|
||||
return response.Response(
|
||||
self.serializer_class(user, context={"request": request}).data
|
||||
)
|
||||
return response.Response(self.get_serializer(user).data)
|
||||
|
||||
|
||||
class TeamViewSet(
|
||||
@@ -259,6 +245,7 @@ class TeamViewSet(
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
@@ -340,6 +327,7 @@ class TeamAccessViewSet(
|
||||
return context
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Chooses list or detail serializer according to the action."""
|
||||
if self.action in {"list", "retrieve"}:
|
||||
return self.list_serializer_class
|
||||
return self.detail_serializer_class
|
||||
@@ -351,20 +339,9 @@ class TeamAccessViewSet(
|
||||
|
||||
if self.action in {"list", "retrieve"}:
|
||||
if query := self.request.GET.get("q", ""):
|
||||
similarity = Max(
|
||||
TrigramSimilarity(
|
||||
Coalesce(Func("user__email", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(Func("user__name", function="unaccent"), Value("")),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(similarity__gte=SIMILARITY_THRESHOLD)
|
||||
.order_by("-similarity")
|
||||
queryset = queryset.filter(
|
||||
Q(user__email__unaccent__icontains=query)
|
||||
| Q(user__name__unaccent__icontains=query)
|
||||
)
|
||||
|
||||
# Determine which role the logged-in user has in the team
|
||||
@@ -506,7 +483,7 @@ class ConfigView(views.APIView):
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = ["LANGUAGES", "FEATURES", "RELEASE"]
|
||||
array_settings = ["LANGUAGES", "FEATURES", "RELEASE", "COMMIT"]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Authentication module."""
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Authentication Backends for the People core app."""
|
||||
|
||||
import logging
|
||||
from email.headerregistry import Address
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
@@ -10,9 +14,21 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import Organization, OrganizationAccess, OrganizationRoleChoices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_domain_from_email(email: Optional[str]) -> Optional[str]:
|
||||
"""Extract domain from email."""
|
||||
try:
|
||||
return Address(addr_spec=email).domain
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
@@ -67,19 +83,24 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
full_name = self.compute_full_name(user_info)
|
||||
email = user_info.get("email")
|
||||
|
||||
claims = {
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
}
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
|
||||
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
|
||||
# if sub is absent, try matching on email
|
||||
@@ -90,7 +111,41 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
self.update_user_if_needed(user, claims)
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
|
||||
user = self.create_user(claims)
|
||||
|
||||
# Data cleaning, to be removed when user organization is null=False
|
||||
# or all users have an organization.
|
||||
# See https://github.com/numerique-gouv/people/issues/504
|
||||
if not user.organization_id:
|
||||
organization_registration_id = claims.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
domain = get_domain_from_email(email)
|
||||
try:
|
||||
organization, organization_created = (
|
||||
Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id=organization_registration_id,
|
||||
domain=domain,
|
||||
)
|
||||
)
|
||||
if organization_created:
|
||||
logger.info("Organization %s created", organization)
|
||||
# For this case, we don't create an OrganizationAccess we will
|
||||
# manage this manually later, because we don't want the first
|
||||
# user who log in after the release to be the admin of their
|
||||
# organization. We will keep organization without admin, and
|
||||
# we will have to manually clean things up (while there is
|
||||
# not that much organization in the database).
|
||||
except ValueError as exc:
|
||||
# Raised when there is no recognizable organization
|
||||
# identifier (domain or registration_id)
|
||||
logger.warning("Unable to update user organization: %s", exc)
|
||||
else:
|
||||
user.organization = organization
|
||||
user.save()
|
||||
logger.info(
|
||||
"User %s updated with organization %s", user.pk, organization
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -101,13 +156,47 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
email = claims.get("email")
|
||||
name = claims.get("name")
|
||||
|
||||
return self.UserModel.objects.create(
|
||||
# Extract or create the organization from the data
|
||||
organization_registration_id = claims.get(
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
|
||||
)
|
||||
domain = get_domain_from_email(email)
|
||||
try:
|
||||
organization, organization_created = (
|
||||
Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id=organization_registration_id,
|
||||
domain=domain,
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable organization identification")
|
||||
) from exc
|
||||
|
||||
if organization_created:
|
||||
logger.info("Organization %s created", organization)
|
||||
|
||||
logger.info("Creating user %s / %s", sub, email)
|
||||
|
||||
user = self.UserModel.objects.create(
|
||||
organization=organization,
|
||||
password="!", # noqa: S106
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
name=claims.get("name"),
|
||||
email=email,
|
||||
name=name,
|
||||
)
|
||||
if organization_created:
|
||||
# Warning: we may remove this behavior in the near future when we
|
||||
# add a feature to claim the organization ownership.
|
||||
OrganizationAccess.objects.create(
|
||||
organization=organization,
|
||||
user=user,
|
||||
role=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
return user
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
@@ -132,8 +221,12 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
has_changed = any(
|
||||
value and value != getattr(user, key) for key, value in claims.items()
|
||||
value and value != getattr(user, key)
|
||||
for key, value in claims.items()
|
||||
if key != "sub"
|
||||
)
|
||||
if has_changed:
|
||||
updated_claims = {key: value for key, value in claims.items() if value}
|
||||
updated_claims = {
|
||||
key: value for key, value in claims.items() if value and key != "sub"
|
||||
}
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -119,6 +119,25 @@ class ContactFactory(BaseContactFactory):
|
||||
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
|
||||
|
||||
|
||||
class OrganizationFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory to create organizations for testing purposes."""
|
||||
|
||||
name = factory.Faker("company")
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
|
||||
class Params: # pylint: disable=missing-class-docstring
|
||||
with_registration_id = factory.Trait(
|
||||
registration_id_list=factory.List(
|
||||
[factory.Sequence(lambda n: f"{n:014d}")]
|
||||
),
|
||||
)
|
||||
with_domain = factory.Trait(
|
||||
domain_list=factory.List([factory.Faker("domain_name")]),
|
||||
)
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create random users for testing purposes."""
|
||||
|
||||
@@ -126,6 +145,13 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
model = models.User
|
||||
django_get_or_create = ("sub",)
|
||||
|
||||
class Params:
|
||||
with_organization = factory.Trait(
|
||||
organization=factory.SubFactory(
|
||||
OrganizationFactory, with_registration_id=True
|
||||
),
|
||||
)
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
name = factory.Faker("name")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-22 10:07
|
||||
|
||||
import core.models
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Organization',
|
||||
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 at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=100, verbose_name='name')),
|
||||
('registration_id_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=128), blank=True, default=list, size=None, validators=[core.models.validate_unique_registration_id], verbose_name='registration ID list')),
|
||||
('domain_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=256), blank=True, default=list, size=None, validators=[core.models.validate_unique_domain], verbose_name='domain list')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'organization',
|
||||
'verbose_name_plural': 'organizations',
|
||||
'db_table': 'people_organization',
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('registration_id_list__len__gt', 0), ('domain_list__len__gt', 0), _connector='OR'), name='registration_id_or_domain', violation_error_message='An organization must have at least a registration ID or a domain.')],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='teams', to='core.organization'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='core.organization'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizationAccess',
|
||||
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 at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('role', models.CharField(choices=[('administrator', 'Administrator')], default='administrator', max_length=20)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to='core.organization')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organization/user relation',
|
||||
'verbose_name_plural': 'Organization/user relations',
|
||||
'db_table': 'people_organization_access',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'organization'), name='unique_organization_user', violation_error_message='This user is already in this organization.')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-04 14:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_add_organization_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100, null=True),
|
||||
),
|
||||
]
|
||||
+259
-11
@@ -6,19 +6,22 @@ import json
|
||||
import os
|
||||
import smtplib
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
from typing import Tuple
|
||||
|
||||
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.sites.models import Site
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
@@ -27,6 +30,7 @@ from timezone_field import TimeZoneField
|
||||
|
||||
from core.enums import WebhookStatusChoices
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -44,6 +48,17 @@ class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
class OrganizationRoleChoices(models.TextChoices):
|
||||
"""
|
||||
Defines the possible roles a user can have in an organization.
|
||||
For now, we only have one role, but we might add more in the future.
|
||||
|
||||
administrator: The user can manage the organization: change name, add/remove users.
|
||||
"""
|
||||
|
||||
ADMIN = "administrator", _("Administrator")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -158,6 +173,140 @@ class Contact(BaseModel):
|
||||
raise exceptions.ValidationError({"data": [error_message]}) from e
|
||||
|
||||
|
||||
class OrganizationManager(models.Manager):
|
||||
"""
|
||||
Custom manager for the Organization model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def get_or_create_from_user_claims(
|
||||
self, registration_id: str = None, domain: str = None, **kwargs
|
||||
) -> Tuple["Organization", bool]:
|
||||
"""
|
||||
Get or create an organization using the most fitting information from the user's claims.
|
||||
|
||||
We expect to have only one organization per registration_id, but
|
||||
the registration_id might not be provided.
|
||||
When the registration_id is not provided, we use the domain to identify the organization.
|
||||
|
||||
If both are provided, we use the registration_id first to create missing organization.
|
||||
|
||||
Dev note: When a registration_id is provided by the Identity Provider, we don't want
|
||||
to use the domain to create the organization, because it is less reliable: for example,
|
||||
a professional user, may have a personal email address, and the domain would be gmail.com
|
||||
which is not a good identifier for an organization. The domain email is just a fallback
|
||||
when the registration_id is not provided by the Identity Provider. We can use the domain
|
||||
to create the organization manually when we are sure about the "safety" of it.
|
||||
"""
|
||||
if not any([registration_id, domain]):
|
||||
raise ValueError("You must provide either a registration_id or a domain.")
|
||||
|
||||
filters = models.Q()
|
||||
if registration_id:
|
||||
filters |= models.Q(registration_id_list__icontains=registration_id)
|
||||
if domain:
|
||||
filters |= models.Q(domain_list__icontains=domain)
|
||||
|
||||
with suppress(self.model.DoesNotExist):
|
||||
# If there are several organizations, we must raise an error and fix the data
|
||||
# If there is an organization, we return it
|
||||
return self.get(filters, **kwargs), False
|
||||
|
||||
# Manage the case where the organization does not exist: we create one
|
||||
if registration_id:
|
||||
return self.create(
|
||||
name=registration_id, registration_id_list=[registration_id], **kwargs
|
||||
), True
|
||||
|
||||
if domain:
|
||||
return self.create(name=domain, domain_list=[domain], **kwargs), True
|
||||
|
||||
raise ValueError("Should never reach this point.")
|
||||
|
||||
|
||||
def validate_unique_registration_id(value):
|
||||
"""
|
||||
Validate that the registration ID values in an array field are unique across all instances.
|
||||
"""
|
||||
if Organization.objects.filter(registration_id_list__overlap=value).exists():
|
||||
raise ValidationError(
|
||||
"registration_id_list value must be unique across all instances."
|
||||
)
|
||||
|
||||
|
||||
def validate_unique_domain(value):
|
||||
"""
|
||||
Validate that the domain values in an array field are unique across all instances.
|
||||
"""
|
||||
if Organization.objects.filter(domain_list__overlap=value).exists():
|
||||
raise ValidationError("domain_list value must be unique across all instances.")
|
||||
|
||||
|
||||
class Organization(BaseModel):
|
||||
"""
|
||||
Organization model used to regroup Teams.
|
||||
|
||||
Each User have an Organization, which corresponds actually to a default organization
|
||||
because a user can belong to a Team from another organization.
|
||||
Each Team have an Organization, which is the Organization from the User who created
|
||||
the Team.
|
||||
|
||||
Organization is managed automatically, the User should never choose their Organization.
|
||||
When creating a User, you must use the `get_or_create` method from the
|
||||
OrganizationManager to find the proper Organization.
|
||||
|
||||
An Organization can have several registration IDs and domains but during automatic
|
||||
creation process, only one will be used. We may want to allow (manual) organization merge
|
||||
later, to regroup several registration IDs or domain in the same Organization.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=100)
|
||||
registration_id_list = ArrayField(
|
||||
models.CharField(
|
||||
max_length=128,
|
||||
validators=get_field_validators_from_setting(
|
||||
"ORGANIZATION_REGISTRATION_ID_VALIDATORS"
|
||||
),
|
||||
),
|
||||
verbose_name=_("registration ID list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
validators=[
|
||||
validate_unique_registration_id,
|
||||
],
|
||||
)
|
||||
domain_list = ArrayField(
|
||||
models.CharField(max_length=256),
|
||||
verbose_name=_("domain list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
validators=[validate_unique_domain],
|
||||
)
|
||||
|
||||
objects = OrganizationManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "people_organization"
|
||||
verbose_name = _("organization")
|
||||
verbose_name_plural = _("organizations")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
name="registration_id_or_domain",
|
||||
condition=models.Q(registration_id_list__len__gt=0)
|
||||
| models.Q(domain_list__len__gt=0),
|
||||
violation_error_message=_(
|
||||
"An organization must have at least a registration ID or a domain."
|
||||
),
|
||||
),
|
||||
# Check a registration ID str can only be present in one
|
||||
# organization registration ID list
|
||||
# Check a domain str can only be present in one organization domain list
|
||||
# Those checks cannot be done with Django constraints
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} (# {self.pk})"
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
@@ -218,6 +367,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="users",
|
||||
null=True, # Need to be set to False when everything is migrated
|
||||
blank=True, # Need to be set to False when everything is migrated
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
@@ -284,6 +440,101 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
raise ValueError("You must first set an email for the user.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
|
||||
def get_abilities(self):
|
||||
"""
|
||||
Return the user permissions at the application level (ie feature availability).
|
||||
|
||||
Note: this is for display purposes only for now.
|
||||
|
||||
- Contacts view and creation is globally available (or not) for now.
|
||||
- Teams view is available if the user is owner or admin of at least
|
||||
one Team for now. It allows to restrict users knowing the existence of
|
||||
this feature.
|
||||
- Teams creation is globally available (or not) for now.
|
||||
- Mailboxes view is available if the user has access to at least one
|
||||
MailDomain for now. It allows to restrict users knowing the existence of
|
||||
this feature.
|
||||
- Mailboxes creation is globally available (or not) for now.
|
||||
"""
|
||||
user_info = (
|
||||
self._meta.model.objects.filter(pk=self.pk)
|
||||
.annotate(
|
||||
teams_can_view=models.Exists(
|
||||
self.accesses.model.objects.filter( # pylint: disable=no-member
|
||||
user=models.OuterRef("pk"),
|
||||
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
|
||||
)
|
||||
),
|
||||
mailboxes_can_view=models.Exists(
|
||||
self.mail_domain_accesses.model.objects.filter( # pylint: disable=no-member
|
||||
user=models.OuterRef("pk")
|
||||
)
|
||||
),
|
||||
)
|
||||
.only("id")
|
||||
.get()
|
||||
)
|
||||
|
||||
teams_can_view = user_info.teams_can_view
|
||||
mailboxes_can_view = user_info.mailboxes_can_view
|
||||
|
||||
return {
|
||||
"contacts": {
|
||||
"can_view": settings.FEATURES["CONTACTS_DISPLAY"],
|
||||
"can_create": (
|
||||
settings.FEATURES["CONTACTS_DISPLAY"]
|
||||
and settings.FEATURES["CONTACTS_CREATE"]
|
||||
),
|
||||
},
|
||||
"teams": {
|
||||
"can_view": teams_can_view and settings.FEATURES["TEAMS_DISPLAY"],
|
||||
"can_create": teams_can_view and settings.FEATURES["TEAMS_CREATE"],
|
||||
},
|
||||
"mailboxes": {
|
||||
"can_view": mailboxes_can_view,
|
||||
"can_create": mailboxes_can_view
|
||||
and settings.FEATURES["MAILBOXES_CREATE"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class OrganizationAccess(BaseModel):
|
||||
"""
|
||||
Link table between organization and users,
|
||||
only for user with specific rights on Organization.
|
||||
"""
|
||||
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="organization_accesses",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="organization_accesses",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=OrganizationRoleChoices.choices,
|
||||
default=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_organization_access"
|
||||
verbose_name = _("Organization/user relation")
|
||||
verbose_name_plural = _("Organization/user relations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "organization"],
|
||||
name="unique_organization_user",
|
||||
violation_error_message=_("This user is already in this organization."),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
"""
|
||||
@@ -291,7 +542,6 @@ class Team(BaseModel):
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(max_length=100, unique=True, null=False, editable=False)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
@@ -299,6 +549,13 @@ class Team(BaseModel):
|
||||
through_fields=("team", "user"),
|
||||
related_name="teams",
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="teams",
|
||||
null=True, # Need to be set to False when everything is migrated
|
||||
blank=True, # Need to be set to False when everything is migrated
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_team"
|
||||
@@ -309,15 +566,6 @@ class Team(BaseModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save function to compute the slug."""
|
||||
self.slug = self.get_slug()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_slug(self):
|
||||
"""Compute slug value from name."""
|
||||
return slugify(self.name)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the team.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend resource server module."""
|
||||
|
||||
@@ -19,6 +19,7 @@ class ResourceServerAuthentication(OIDCAuthentication):
|
||||
"""Authenticate clients using the token received from the authorization server."""
|
||||
|
||||
def __init__(self):
|
||||
"""Require authentication to be configured in order to instantiate."""
|
||||
super().__init__()
|
||||
|
||||
try:
|
||||
@@ -40,7 +41,7 @@ class ResourceServerAuthentication(OIDCAuthentication):
|
||||
def get_access_token(self, request):
|
||||
"""Retrieve and decode the access token from the request.
|
||||
|
||||
This method overcharges the 'get_access_token' method from the parent class,
|
||||
This method overrides the 'get_access_token' method from the parent class,
|
||||
to support service providers that would base64 encode the bearer token.
|
||||
"""
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class ResourceServerBackend:
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, authorization_server_client):
|
||||
"""Require client_id, client_secret set and authorization_server_client provided."""
|
||||
# pylint: disable=invalid-name
|
||||
self.UserModel = auth.get_user_model()
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class AuthorizationServerClient:
|
||||
- Setting appropriate headers for secure communication as recommended by RFC drafts.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913 PLR017
|
||||
# ruff: noqa: PLR0913 PLR0917
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
@@ -31,6 +31,8 @@ class AuthorizationServerClient:
|
||||
timeout,
|
||||
proxy,
|
||||
):
|
||||
"""Require at a minimum url, url_jwks and url_introspection."""
|
||||
|
||||
if not url or not url_jwks or not url_introspection:
|
||||
raise ImproperlyConfigured(
|
||||
"Could not instantiate AuthorizationServerClient, some parameters are missing."
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core template tags module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core tests."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core authentication tests."""
|
||||
|
||||
@@ -42,7 +42,8 @@ def test_authentication_getter_existing_user_with_email(
|
||||
When the user's info contains an email and targets an existing user,
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = factories.UserFactory(name="John Doe")
|
||||
|
||||
user = factories.UserFactory(name="John Doe", with_organization=True)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
@@ -79,7 +80,9 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
It should update the email or name fields on the user when they change.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = factories.UserFactory(name="John Doe", email="john.doe@example.com")
|
||||
user = factories.UserFactory(
|
||||
name="John Doe", email="john.doe@example.com", with_organization=True
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
@@ -112,7 +115,7 @@ def test_authentication_getter_existing_user_via_email(
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory()
|
||||
db_user = factories.UserFactory(with_organization=True)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
@@ -158,25 +161,23 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's info doesn't contain an email/name, created user's email/name should be empty.
|
||||
If no user matches the user's info sub, a user should not be created without email
|
||||
nor organization registration ID.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123"}
|
||||
return {"sub": "123"} # No email, no organization registration ID
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.name is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
with (
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Claims contained no recognizable organization identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
@@ -284,3 +285,99 @@ def test_authentication_getter_existing_disabled_user_via_email(
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_with_email_new_organization(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
If the corresponding organization doesn't exist, it should be created.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.organization is not None
|
||||
assert user.organization.domain_list == ["example.com"]
|
||||
assert user.organization.registration_id_list == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"registration_id_setting,expected_registration_id_list,expected_domain_list",
|
||||
[
|
||||
(None, [], ["example.com"]),
|
||||
("missing-claim", [], ["example.com"]),
|
||||
("registration_number", ["12345678901234"], []),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_new_user_with_registration_id_new_organization(
|
||||
monkeypatch,
|
||||
settings,
|
||||
registration_id_setting,
|
||||
expected_registration_id_list,
|
||||
expected_domain_list,
|
||||
):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
If the corresponding organization doesn't exist, it should be created.
|
||||
"""
|
||||
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = registration_id_setting
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": "123",
|
||||
"email": email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"registration_number": "12345678901234",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.organization is not None
|
||||
assert user.organization.domain_list == expected_domain_list
|
||||
assert user.organization.registration_id_list == expected_registration_id_list
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email_update_organization(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user already exists without organization, the organization must be updated.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = factories.UserFactory(name="John Doe", email="toto@my-domain.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": db_user.sub,
|
||||
"email": db_user.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(9):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
assert user.organization is not None
|
||||
assert user.organization.domain_list == ["my-domain.com"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core resource server tests."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core Swagger tests."""
|
||||
|
||||
@@ -5,12 +5,11 @@ Tests for Teams API endpoint in People's core app: create
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_201_CREATED,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import TeamFactory, UserFactory
|
||||
from core.factories import OrganizationFactory, UserFactory
|
||||
from core.models import Team
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -34,7 +33,8 @@ 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.
|
||||
"""
|
||||
user = UserFactory()
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -50,76 +50,34 @@ def test_api_teams_create_authenticated():
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_slugify_name():
|
||||
def test_api_teams_create_cannot_override_organization():
|
||||
"""
|
||||
Creating teams should automatically generate a slug.
|
||||
Authenticated users should be able to create teams and not
|
||||
be able to set the organization manually (for now).
|
||||
"""
|
||||
user = UserFactory()
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{"name": "my team"},
|
||||
{
|
||||
"name": "my team",
|
||||
"organization": OrganizationFactory(
|
||||
with_registration_id=True
|
||||
).pk, # ignored
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.slug == "my-team"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"param",
|
||||
[
|
||||
("my team", "my-team"),
|
||||
("my team", "my-team"),
|
||||
("MY TEAM TOO", "my-team-too"),
|
||||
("mon équipe", "mon-equipe"),
|
||||
("front devs & UX", "front-devs-ux"),
|
||||
],
|
||||
)
|
||||
def test_api_teams_create_authenticated_expected_slug(param):
|
||||
"""
|
||||
Creating teams should automatically create unaccented, no unicode, lower-case slug.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": param[0],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == param[0]
|
||||
assert team.slug == param[1]
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_unique_slugs():
|
||||
"""
|
||||
Creating teams should raise an error if already existing slug.
|
||||
"""
|
||||
TeamFactory(name="existing team")
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "èxisting team",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["slug"] == ["Team with this Slug already exists."]
|
||||
assert team.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -47,60 +47,13 @@ def test_api_teams_list_authenticated():
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
results = response.json()["results"]
|
||||
results = response.json()
|
||||
|
||||
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."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(access.team.id)
|
||||
for access in factories.TeamAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
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 = client.get(
|
||||
"/api/v1.0/teams/?page=2",
|
||||
)
|
||||
|
||||
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."""
|
||||
user = factories.UserFactory()
|
||||
@@ -118,8 +71,8 @@ def test_api_teams_list_authenticated_distinct():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(team.id)
|
||||
assert len(content) == 1
|
||||
assert content[0]["id"] == str(team.id)
|
||||
|
||||
|
||||
def test_api_teams_order():
|
||||
@@ -142,7 +95,7 @@ def test_api_teams_order():
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
response_team_ids = [team["id"] for team in response_data]
|
||||
|
||||
team_ids.reverse()
|
||||
assert (
|
||||
@@ -171,7 +124,7 @@ def test_api_teams_order_param():
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
response_team_ids = [team["id"] for team in response_data]
|
||||
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
|
||||
@@ -69,7 +69,6 @@ def test_api_teams_retrieve_authenticated_related():
|
||||
assert response.json() == {
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"slug": team.slug,
|
||||
"abilities": team.get_abilities(user),
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
|
||||
@@ -7,7 +7,6 @@ import random
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
@@ -125,7 +124,7 @@ def test_api_teams_update_authenticated_administrators():
|
||||
elif key == "updated_at":
|
||||
assert value > initial_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
# name and abilities successfully modified
|
||||
assert value == new_values[key]
|
||||
|
||||
|
||||
@@ -158,7 +157,7 @@ def test_api_teams_update_authenticated_owners():
|
||||
elif key == "updated_at":
|
||||
assert value > old_team_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
# name and abilities successfully modified
|
||||
assert value == new_team_values[key]
|
||||
|
||||
|
||||
@@ -189,31 +188,3 @@ def test_api_teams_update_administrator_or_owner_of_another():
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_existing_slug_should_return_error():
|
||||
"""
|
||||
Updating a team's name to an existing slug should return a bad request,
|
||||
instead of creating a duplicate.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.TeamFactory(name="Existing team", users=[(user, "administrator")])
|
||||
my_team = factories.TeamFactory(name="New team", users=[(user, "administrator")])
|
||||
|
||||
updated_values = serializers.TeamSerializer(instance=my_team).data
|
||||
# Update my team's name for existing team. Creates a duplicate slug
|
||||
updated_values["name"] = "existing team"
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{my_team.id!s}/",
|
||||
updated_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["slug"] == ["Team with this Slug already exists."]
|
||||
# Both teams names and slugs should be unchanged
|
||||
assert my_team.name == "New team"
|
||||
assert my_team.slug == "new-team"
|
||||
|
||||
@@ -20,7 +20,14 @@ def test_api_config_anonymous():
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
|
||||
"FEATURES": {"TEAMS": True},
|
||||
"COMMIT": "NA",
|
||||
"FEATURES": {
|
||||
"CONTACTS_DISPLAY": True,
|
||||
"CONTACTS_CREATE": True,
|
||||
"MAILBOXES_CREATE": True,
|
||||
"TEAMS_DISPLAY": True,
|
||||
"TEAMS_CREATE": True,
|
||||
},
|
||||
"RELEASE": "NA",
|
||||
}
|
||||
|
||||
@@ -36,6 +43,13 @@ def test_api_config_authenticated():
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"]],
|
||||
"FEATURES": {"TEAMS": True},
|
||||
"COMMIT": "NA",
|
||||
"FEATURES": {
|
||||
"CONTACTS_DISPLAY": True,
|
||||
"CONTACTS_CREATE": True,
|
||||
"MAILBOXES_CREATE": True,
|
||||
"TEAMS_DISPLAY": True,
|
||||
"TEAMS_CREATE": True,
|
||||
},
|
||||
"RELEASE": "NA",
|
||||
}
|
||||
|
||||
@@ -131,19 +131,17 @@ def test_api_contacts_list_authenticated_by_full_name():
|
||||
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 = client.get("/api/v1.0/contacts/?q=ole")
|
||||
|
||||
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)]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ool")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(nicole.id), str(frank.id)]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_uppercase_content():
|
||||
|
||||
@@ -16,6 +16,9 @@ from rest_framework.test import APIClient
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.api.viewsets import Pagination
|
||||
from core.factories import TeamAccessFactory
|
||||
|
||||
from mailbox_manager.factories import MailDomainAccessFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -78,28 +81,16 @@ def test_api_users_authenticated_list_by_email():
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.id)
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected emails
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"email": frank.email,
|
||||
@@ -109,6 +100,15 @@ def test_api_users_authenticated_list_by_email():
|
||||
"language": frank.language,
|
||||
"timezone": str(frank.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -118,9 +118,9 @@ def test_api_users_authenticated_list_by_name():
|
||||
partial query on the name.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(name="dave bowman", email=None)
|
||||
dave = factories.UserFactory(name="Dave bowman", email=None)
|
||||
nicole = factories.UserFactory(name="nicole foole", email=None)
|
||||
frank = factories.UserFactory(name="frank poole", email=None)
|
||||
frank = factories.UserFactory(name="frank poolé", email=None)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
|
||||
client = APIClient()
|
||||
@@ -128,7 +128,7 @@ def test_api_users_authenticated_list_by_name():
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
"/api/v1.0/users/?q=dave",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
@@ -142,28 +142,16 @@ def test_api_users_authenticated_list_by_name():
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.id)
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected user
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
response = client.get("/api/v1.0/users/?q=oole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"email": frank.email,
|
||||
@@ -173,6 +161,15 @@ def test_api_users_authenticated_list_by_name():
|
||||
"language": frank.language,
|
||||
"timezone": str(frank.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(nicole.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.is_device,
|
||||
"is_staff": nicole.is_staff,
|
||||
"language": nicole.language,
|
||||
"timezone": str(nicole.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -184,22 +181,18 @@ def test_api_users_authenticated_list_by_name_and_email():
|
||||
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com", name="nicole foole")
|
||||
frank = factories.UserFactory(email="oleg_poole@work.com", name=None)
|
||||
oleg = factories.UserFactory(email="oleg_poole@work.com", name=None)
|
||||
david = factories.UserFactory(email=None, name="david role")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Result that matches a trigram in name and email ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
|
||||
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
|
||||
# "Oleg poole" matches twice on "ole" in her email
|
||||
# "David role" matches once on "ole" in his name
|
||||
assert user_ids == [str(nicole.id), str(frank.id), str(david.id)]
|
||||
assert user_ids == [str(david.id), str(oleg.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_exclude_users_already_in_team(
|
||||
@@ -468,6 +461,77 @@ def test_api_users_retrieve_me_authenticated():
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"abilities": {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": False, "can_view": False},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated_abilities():
|
||||
"""
|
||||
Authenticated users should be able to retrieve their own user via the "/users/me" path
|
||||
with the proper abilities.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Define profile contact
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
# Test the mailboxes abilities
|
||||
mail_domain_access = MailDomainAccessFactory(user=user)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
}
|
||||
|
||||
# Test the teams abilities - user is not an admin/owner
|
||||
team_access = TeamAccessFactory(user=user, role=models.RoleChoices.MEMBER)
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
}
|
||||
|
||||
# Test the teams abilities - user is an admin/owner
|
||||
team_access.role = models.RoleChoices.ADMIN
|
||||
team_access.save()
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
}
|
||||
|
||||
# Test the mailboxes abilities - user has no mail domain access anymore
|
||||
mail_domain_access.delete()
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": False, "can_view": False},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Unit tests for the Organization model
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_organization_str():
|
||||
"""The str representation should be the organization's name."""
|
||||
organization = factories.OrganizationFactory(
|
||||
name="HAL 9000", registration_id_list=["12345678901234"]
|
||||
)
|
||||
assert str(organization) == f"HAL 9000 (# {organization.pk})"
|
||||
|
||||
|
||||
def test_models_organization_constraints():
|
||||
"""It should not be possible to create an organization."""
|
||||
organization = factories.OrganizationFactory(
|
||||
registration_id_list=["12345678901234"], domain_list=["hal9000.com"]
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.Organization.objects.create(name="HAL 9000")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.Organization.objects.create(
|
||||
name="HAL 9000",
|
||||
registration_id_list=[
|
||||
organization.registration_id_list[0],
|
||||
"12345678901235",
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.Organization.objects.create(
|
||||
name="HAL 9000", domain_list=[organization.domain_list[0], "hal9001.com"]
|
||||
)
|
||||
|
||||
|
||||
def test_models_organization_get_or_create_from_user_claims_no_kwargs():
|
||||
"""It should fail."""
|
||||
with pytest.raises(ValueError):
|
||||
models.Organization.objects.get_or_create_from_user_claims()
|
||||
|
||||
|
||||
def test_models_organization_get_or_create_from_user_claims_with_registration_id():
|
||||
"""It should create an organization with a registration ID number."""
|
||||
organization, created = models.Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id="12345678901234"
|
||||
)
|
||||
assert created is True
|
||||
assert organization.registration_id_list == ["12345678901234"]
|
||||
assert organization.domain_list == []
|
||||
|
||||
same_organization, created = (
|
||||
models.Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id="12345678901234"
|
||||
)
|
||||
)
|
||||
assert created is False
|
||||
assert organization == same_organization
|
||||
assert same_organization.registration_id_list == ["12345678901234"]
|
||||
assert same_organization.domain_list == []
|
||||
|
||||
|
||||
def test_models_organization_get_or_create_from_user_claims_with_domain():
|
||||
"""It should create an organization with a domain."""
|
||||
organization, created = models.Organization.objects.get_or_create_from_user_claims(
|
||||
domain="hal9000.com"
|
||||
)
|
||||
assert created is True
|
||||
assert organization.registration_id_list == []
|
||||
assert organization.domain_list == ["hal9000.com"]
|
||||
|
||||
same_organization, created = (
|
||||
models.Organization.objects.get_or_create_from_user_claims(domain="hal9000.com")
|
||||
)
|
||||
assert created is False
|
||||
assert organization == same_organization
|
||||
assert same_organization.registration_id_list == []
|
||||
assert same_organization.domain_list == ["hal9000.com"]
|
||||
|
||||
|
||||
def test_models_organization_get_or_create_from_user_claims_with_registration_id_and_domain():
|
||||
"""It should create an organization with a registration ID number."""
|
||||
organization, created = models.Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id="12345678901234", domain="hal9000.com"
|
||||
)
|
||||
assert created is True
|
||||
assert organization.registration_id_list == ["12345678901234"]
|
||||
assert organization.domain_list == []
|
||||
|
||||
same_organization, created = (
|
||||
models.Organization.objects.get_or_create_from_user_claims(
|
||||
registration_id="12345678901234", domain="hal9000.com"
|
||||
)
|
||||
)
|
||||
assert created is False
|
||||
assert organization == same_organization
|
||||
assert same_organization.registration_id_list == ["12345678901234"]
|
||||
assert same_organization.domain_list == []
|
||||
|
||||
|
||||
def test_models_organization_registration_id_validators():
|
||||
"""
|
||||
Test the registration ID validators.
|
||||
|
||||
This cannot be tested dynamically because the validators are set at model loading
|
||||
and this is not possible to reload the models on the fly. We therefore enforce the
|
||||
setting in Test environment.
|
||||
"""
|
||||
models.Organization.objects.create(
|
||||
name="hu",
|
||||
registration_id_list=["12345678901234"],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
models.Organization.objects.create(
|
||||
name="hi",
|
||||
registration_id_list=["a12345678912345"],
|
||||
)
|
||||
@@ -47,12 +47,6 @@ def test_models_teams_name_max_length():
|
||||
factories.TeamFactory(name="a " * 51)
|
||||
|
||||
|
||||
def test_models_teams_slug_empty():
|
||||
"""Slug field should not be empty."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank."):
|
||||
models.Team.objects.create(slug="")
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Test cases for core.validators module.
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.validators import EmailValidator, RegexValidator
|
||||
|
||||
import pytest
|
||||
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
|
||||
def test_get_field_validators_from_setting_without_option(settings):
|
||||
"""Test get_field_validators_from_setting without options."""
|
||||
settings.VALIDATOR_NO_OPTION = [
|
||||
{
|
||||
"NAME": "django.core.validators.EmailValidator",
|
||||
},
|
||||
]
|
||||
|
||||
validators = get_field_validators_from_setting("VALIDATOR_NO_OPTION")
|
||||
assert len(validators) == 1
|
||||
assert isinstance(validators[0], EmailValidator)
|
||||
|
||||
|
||||
def test_get_field_validators_from_setting_with_option(settings):
|
||||
"""Test get_field_validators_from_setting with options."""
|
||||
settings.REGEX_WITH_OPTIONS = [
|
||||
{
|
||||
"NAME": "django.core.validators.RegexValidator",
|
||||
"OPTIONS": {
|
||||
"regex": "[a-z][0-9]{14}",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
validators = get_field_validators_from_setting("REGEX_WITH_OPTIONS")
|
||||
assert len(validators) == 1
|
||||
assert isinstance(validators[0], RegexValidator)
|
||||
assert validators[0].regex.pattern == "[a-z][0-9]{14}"
|
||||
|
||||
|
||||
def test_get_field_validators_from_setting_invalid_class_name(settings):
|
||||
"""Test get_field_validators_from_setting with an invalid class name."""
|
||||
settings.INVALID_VALIDATORS = [
|
||||
{
|
||||
"NAME": "non.existent.Validator",
|
||||
},
|
||||
]
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
get_field_validators_from_setting("INVALID_VALIDATORS")
|
||||
|
||||
|
||||
def test_get_field_validators_from_setting_empty_setting(settings):
|
||||
"""Test get_field_validators_from_setting with an empty setting."""
|
||||
settings.EMPTY_VALIDATORS = []
|
||||
validators = get_field_validators_from_setting("EMPTY_VALIDATORS")
|
||||
assert not validators
|
||||
@@ -0,0 +1 @@
|
||||
"""Core utils module."""
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Declare validators that can be used in our Django models.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
|
||||
def get_field_validators_from_setting(setting_name: str) -> list:
|
||||
"""
|
||||
Get field validators from a setting.
|
||||
|
||||
Highly inspired by Django's `get_password_validators` function.
|
||||
|
||||
The setting should be a list of dictionaries, where each dictionary
|
||||
should have a NAME key that points to the validator class and an
|
||||
optional OPTIONS key that points to the validator options.
|
||||
|
||||
Example:
|
||||
```
|
||||
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.core.validators.RegexValidator",
|
||||
"OPTIONS": {
|
||||
"regex": "[a-z][0-9]{14}",
|
||||
},
|
||||
},
|
||||
]
|
||||
```
|
||||
"""
|
||||
validators = []
|
||||
for validator in getattr(settings, setting_name):
|
||||
try:
|
||||
klass = import_string(validator["NAME"])
|
||||
except ImportError as exc:
|
||||
msg = "The module in NAME could not be imported: %s. Check your %s setting."
|
||||
raise ImproperlyConfigured(msg % (validator["NAME"], setting_name)) from exc
|
||||
validators.append(klass(**validator.get("OPTIONS", {})))
|
||||
|
||||
return validators
|
||||
@@ -33,6 +33,7 @@ class DebugViewNewMailboxHtml(DebugBaseView):
|
||||
template_name = "mail/html/new_mailbox.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Hardcode user credentials for debug setting."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["mailbox_data"] = {
|
||||
"email": "john.doe@example.com",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Demo module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Demo management module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"Demo management commands module."
|
||||
|
||||
@@ -106,7 +106,7 @@ class Timeit:
|
||||
return elapsed_time
|
||||
|
||||
|
||||
def create_demo(stdout):
|
||||
def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
"""
|
||||
Create a database with demo data for developers to work in a realistic environment.
|
||||
The code is engineered to create a huge number of objects fast.
|
||||
@@ -128,6 +128,45 @@ def create_demo(stdout):
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
# this is a quick fix to fix e2e tests
|
||||
# tests needs some no random data
|
||||
queue.push(
|
||||
models.User(
|
||||
sub=uuid4(),
|
||||
email="monique.test@example.com",
|
||||
name="Monique Test",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
queue.push(
|
||||
models.User(
|
||||
sub=uuid4(),
|
||||
email="jeanne.test@example.com",
|
||||
name="Jean Test",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
queue.push(
|
||||
models.User(
|
||||
sub=uuid4(),
|
||||
email="jean.somethingelse@example.com",
|
||||
name="Jean Something",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating teams"):
|
||||
@@ -135,8 +174,6 @@ def create_demo(stdout):
|
||||
queue.push(
|
||||
models.Team(
|
||||
name=f"Team {i:d}",
|
||||
# slug should be automatic but bulk_create doesn't use save
|
||||
slug=f"team-{i:d}",
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
@@ -153,11 +190,15 @@ def create_demo(stdout):
|
||||
queue.push(
|
||||
models.TeamAccess(team_id=team_id, user_id=user_id, role=role[0])
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating domains"):
|
||||
created = set()
|
||||
for _i in range(defaults.NB_OBJECTS["domains"]):
|
||||
name = fake.domain_name()
|
||||
if name in created:
|
||||
continue
|
||||
created.add(name)
|
||||
|
||||
slug = slugify(name)
|
||||
|
||||
queue.push(
|
||||
@@ -182,6 +223,75 @@ def create_demo(stdout):
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
)
|
||||
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating specific users"):
|
||||
# ⚠️ Warning: this users also need to be created in the keycloak
|
||||
# realm.json AND the OIDC setting to fallback on user email
|
||||
# should be set to True, because we don't pilot the sub.
|
||||
for role in models.RoleChoices.values:
|
||||
team_user = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.team-{role}@example.com",
|
||||
name=f"Jean Group {role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
queue.push(team_user)
|
||||
queue.push(
|
||||
models.TeamAccess(team_id=teams_ids[0], user_id=team_user.pk, role=role)
|
||||
)
|
||||
|
||||
for role in models.RoleChoices.values:
|
||||
user_with_mail = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.mail-{role}@example.com",
|
||||
name=f"Jean Mail {role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
queue.push(user_with_mail)
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domains_ids[0],
|
||||
user_id=user_with_mail.pk,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
|
||||
for team_role in models.RoleChoices.values:
|
||||
for domain_role in models.RoleChoices.values:
|
||||
team_mail_user = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.team-{team_role}-mail-{domain_role}@example.com",
|
||||
name=f"Jean Group {team_role.capitalize()} Mail {domain_role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
queue.push(team_mail_user)
|
||||
queue.push(
|
||||
models.TeamAccess(
|
||||
team_id=teams_ids[0], user_id=team_mail_user.pk, role=team_role
|
||||
)
|
||||
)
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domains_ids[0],
|
||||
user_id=team_mail_user.pk,
|
||||
role=domain_role,
|
||||
)
|
||||
)
|
||||
|
||||
queue.flush()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Demo tests."""
|
||||
|
||||
@@ -28,11 +28,22 @@ def test_commands_create_demo():
|
||||
"""The create_demo management command should create objects as expected."""
|
||||
call_command("create_demo")
|
||||
|
||||
assert models.User.objects.count() == TEST_NB_OBJECTS["users"]
|
||||
# Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
|
||||
# 3 user with team rights
|
||||
# 3 user with domain rights
|
||||
# 3 x 3 user with both rights
|
||||
assert models.User.objects.count() == TEST_NB_OBJECTS["users"] + 3 + 3 + 3 + 9
|
||||
|
||||
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
|
||||
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
|
||||
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
|
||||
assert mailbox_models.MailDomainAccess.objects.count() == TEST_NB_OBJECTS["domains"]
|
||||
|
||||
# 3 domain access for each user with domain rights
|
||||
# 3 x 3 domain access for each user with both rights
|
||||
assert (
|
||||
mailbox_models.MailDomainAccess.objects.count()
|
||||
== TEST_NB_OBJECTS["domains"] + 3 + 9
|
||||
)
|
||||
|
||||
|
||||
def test_commands_createsuperuser():
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-10-15 10:52+0000\n"
|
||||
"POT-Creation-Date: 2024-11-18 23:02+0000\n"
|
||||
"PO-Revision-Date: 2024-01-03 23:15\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
@@ -17,225 +17,261 @@ msgstr ""
|
||||
"X-Crowdin-File: backend.pot\n"
|
||||
"X-Crowdin-File-ID: 2\n"
|
||||
|
||||
#: core/admin.py:45
|
||||
#: core/admin.py:55
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:47
|
||||
#: core/admin.py:57
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:59
|
||||
#: core/admin.py:69
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:97
|
||||
#: core/admin.py:108
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:82
|
||||
#: core/authentication/backends.py:89
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:90
|
||||
#: core/authentication/backends.py:111
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
#: core/authentication/backends.py:157
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:176
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/enums.py:23
|
||||
msgid "Failure"
|
||||
msgstr ""
|
||||
|
||||
#: core/enums.py:24 mailbox_manager/enums.py:20
|
||||
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:30
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/enums.py:25
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:42
|
||||
#: core/models.py:46
|
||||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:43 mailbox_manager/enums.py:13
|
||||
#: core/models.py:47 core/models.py:59 mailbox_manager/enums.py:14
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:44 mailbox_manager/enums.py:14
|
||||
#: core/models.py:48 mailbox_manager/enums.py:15
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:71
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:72
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:63
|
||||
#: core/models.py:78
|
||||
msgid "created at"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:64
|
||||
#: core/models.py:79
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:69
|
||||
#: core/models.py:84
|
||||
msgid "updated at"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:70
|
||||
#: core/models.py:85
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:101
|
||||
#: core/models.py:116
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
#: core/models.py:117
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:107
|
||||
#: core/models.py:122
|
||||
msgid "contact information"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:123
|
||||
msgid "A JSON object containing the contact information"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:122
|
||||
#: core/models.py:137
|
||||
msgid "contact"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:138
|
||||
msgid "contacts"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:262 core/models.py:331 mailbox_manager/models.py:24
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:270
|
||||
msgid "registration ID list"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:279
|
||||
msgid "domain list"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:289
|
||||
msgid "organization"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:290
|
||||
msgid "organizations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:297
|
||||
msgid "An organization must have at least a registration ID or a domain."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:316
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:322
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:324
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:181 core/models.py:489
|
||||
#: core/models.py:330 core/models.py:737
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182 mailbox_manager/models.py:20
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:194
|
||||
#: core/models.py:343
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:344
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:201
|
||||
#: core/models.py:350
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:204
|
||||
#: core/models.py:353
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:206
|
||||
#: core/models.py:355
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:358
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:211
|
||||
#: core/models.py:360
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:214
|
||||
#: core/models.py:363
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:217
|
||||
#: core/models.py:366
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:229
|
||||
#: core/models.py:385
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:230
|
||||
#: core/models.py:386
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:306
|
||||
#: core/models.py:525
|
||||
msgid "Organization/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:526
|
||||
msgid "Organization/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:531
|
||||
msgid "This user is already in this organization."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:563
|
||||
msgid "Team"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:307
|
||||
#: core/models.py:564
|
||||
msgid "Teams"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:367
|
||||
#: core/models.py:615
|
||||
msgid "Team/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:368
|
||||
#: core/models.py:616
|
||||
msgid "Team/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:373
|
||||
#: core/models.py:621
|
||||
msgid "This user is already in this team."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:462
|
||||
#: core/models.py:710
|
||||
msgid "url"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:463
|
||||
#: core/models.py:711
|
||||
msgid "secret"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:472
|
||||
#: core/models.py:720
|
||||
msgid "Team webhook"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:473
|
||||
#: core/models.py:721
|
||||
msgid "Team webhooks"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:506
|
||||
#: core/models.py:754
|
||||
msgid "Team invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:507
|
||||
#: core/models.py:755
|
||||
msgid "Team invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:532
|
||||
#: core/models.py:780
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:574 core/models.py:580
|
||||
#: core/models.py:822 core/models.py:828
|
||||
msgid "Invitation to join Desk!"
|
||||
msgstr ""
|
||||
|
||||
@@ -420,65 +456,80 @@ msgstr "L'équipe de La Suite"
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/enums.py:12
|
||||
#: mailbox_manager/admin.py:12
|
||||
msgid "Synchronise from dimail"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/admin.py:23
|
||||
#, python-brace-format
|
||||
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/admin.py:29
|
||||
#, python-brace-format
|
||||
msgid "Synchronisation succeed for {domain.name}. "
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/enums.py:13
|
||||
msgid "Viewer"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/enums.py:21
|
||||
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:31
|
||||
msgid "Enabled"
|
||||
msgstr ""
|
||||
msgstr "Actif"
|
||||
|
||||
#: mailbox_manager/enums.py:22
|
||||
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:32
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
msgstr "En échec"
|
||||
|
||||
#: mailbox_manager/enums.py:23
|
||||
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:33
|
||||
msgid "Disabled"
|
||||
msgstr ""
|
||||
msgstr "Désactivé"
|
||||
|
||||
#: mailbox_manager/models.py:31
|
||||
#: mailbox_manager/models.py:35
|
||||
msgid "Mail domain"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:32
|
||||
#: mailbox_manager/models.py:36
|
||||
msgid "Mail domains"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:98
|
||||
#: mailbox_manager/models.py:102
|
||||
msgid "User/mail domain relation"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:99
|
||||
#: mailbox_manager/models.py:103
|
||||
msgid "User/mail domain relations"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:171
|
||||
#: mailbox_manager/models.py:175
|
||||
msgid "local_part"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:185
|
||||
#: mailbox_manager/models.py:189
|
||||
msgid "secondary email address"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:190
|
||||
#: mailbox_manager/models.py:199
|
||||
msgid "Mailbox"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:191
|
||||
#: mailbox_manager/models.py:200
|
||||
msgid "Mailboxes"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/utils/dimail.py:137
|
||||
#: mailbox_manager/models.py:224
|
||||
msgid "You can't create a mailbox for a disabled domain."
|
||||
msgstr "Vous ne pouvez pas créer de boîte mail pour un domain désactivé."
|
||||
|
||||
#: mailbox_manager/utils/dimail.py:183
|
||||
msgid "Your new mailbox information"
|
||||
msgstr "Informations concernant votre nouvelle boîte mail"
|
||||
|
||||
#: people/settings.py:134
|
||||
#: people/settings.py:135
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: people/settings.py:135
|
||||
#: people/settings.py:136
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#~ msgid "Regards,"
|
||||
#~ msgstr "Cordialement,"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager module."""
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
"""Admin classes and registrations for People's mailbox manager app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib import admin, messages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from requests import exceptions
|
||||
|
||||
from mailbox_manager import models
|
||||
from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
|
||||
|
||||
@admin.action(description=_("Synchronise from dimail"))
|
||||
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Admin action to synchronize existing mailboxes from dimail to our database."""
|
||||
client = DimailAPIClient()
|
||||
|
||||
for domain in queryset:
|
||||
try:
|
||||
imported_mailboxes = client.synchronize_mailboxes_from_dimail(domain)
|
||||
except exceptions.HTTPError as err:
|
||||
messages.error(
|
||||
request,
|
||||
_(f"Synchronisation failed for {domain.name} with message: [{err}]"),
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
_(
|
||||
f"Synchronisation succeed for {domain.name}. "
|
||||
f"Imported mailboxes: {', '.join(imported_mailboxes)}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserMailDomainAccessInline(admin.TabularInline):
|
||||
@@ -28,6 +54,14 @@ class MailDomainAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ["created_at", "slug"]
|
||||
inlines = (UserMailDomainAccessInline,)
|
||||
actions = (sync_mailboxes_from_dimail,)
|
||||
|
||||
|
||||
@admin.register(models.Mailbox)
|
||||
class MailboxAdmin(admin.ModelAdmin):
|
||||
"""Admin for mailbox model."""
|
||||
|
||||
list_display = ("__str__", "first_name", "last_name")
|
||||
|
||||
|
||||
@admin.register(models.MailDomainAccess)
|
||||
@@ -50,10 +84,3 @@ class MailDomainAccessInline(admin.TabularInline):
|
||||
autocomplete_fields = ["user", "domain"]
|
||||
model = models.MailDomainAccess
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(models.Mailbox)
|
||||
class MailboxAdmin(admin.ModelAdmin):
|
||||
"""Admin for mailbox model."""
|
||||
|
||||
list_display = ("__str__", "first_name", "last_name")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager API module."""
|
||||
|
||||
@@ -18,6 +18,7 @@ class MailBoxPermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class to manage mailboxes for a mail domain"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission based on domain."""
|
||||
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
|
||||
abilities = domain.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
@@ -16,32 +16,45 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Mailbox
|
||||
fields = ["id", "first_name", "last_name", "local_part", "secondary_email"]
|
||||
fields = [
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"local_part",
|
||||
"secondary_email",
|
||||
"status",
|
||||
]
|
||||
# everything is actually read-only as we do not allow update for now
|
||||
read_only_fields = ["id"]
|
||||
read_only_fields = ["id", "status"]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Override create function to fire a request on mailbox creation.
|
||||
"""
|
||||
# send new mailbox request to dimail
|
||||
client = DimailAPIClient()
|
||||
response = client.send_mailbox_request(
|
||||
validated_data, self.context["request"].user.sub
|
||||
)
|
||||
mailbox_status = enums.MailDomainStatusChoices.PENDING
|
||||
|
||||
# fix format to have actual json, and remove uuid
|
||||
mailbox_data = json.loads(response.content.decode("utf-8").replace("'", '"'))
|
||||
del mailbox_data["uuid"]
|
||||
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
|
||||
client = DimailAPIClient()
|
||||
# send new mailbox request to dimail
|
||||
response = client.send_mailbox_request(
|
||||
validated_data, self.context["request"].user.sub
|
||||
)
|
||||
|
||||
# fix format to have actual json, and remove uuid
|
||||
mailbox_data = json.loads(
|
||||
response.content.decode("utf-8").replace("'", '"')
|
||||
)
|
||||
del mailbox_data["uuid"]
|
||||
|
||||
mailbox_status = enums.MailDomainStatusChoices.ENABLED
|
||||
|
||||
# send confirmation email
|
||||
client.send_new_mailbox_notification(
|
||||
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
|
||||
)
|
||||
|
||||
# actually save mailbox on our database
|
||||
instance = models.Mailbox.objects.create(**validated_data)
|
||||
|
||||
# send confirmation email
|
||||
client.send_new_mailbox_notification(
|
||||
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
|
||||
)
|
||||
return instance
|
||||
return models.Mailbox.objects.create(**validated_data, status=mailbox_status)
|
||||
|
||||
|
||||
class MailDomainSerializer(serializers.ModelSerializer):
|
||||
@@ -77,6 +90,19 @@ class MailDomainSerializer(serializers.ModelSerializer):
|
||||
return domain.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Override create function to fire a request to dimail upon domain creation.
|
||||
"""
|
||||
# send new domain request to dimail
|
||||
client = DimailAPIClient()
|
||||
client.send_domain_creation_request(
|
||||
validated_data["name"], self.context["request"].user.sub
|
||||
)
|
||||
|
||||
# no exception raised ? Then actually save domain on our database
|
||||
return models.MailDomain.objects.create(**validated_data)
|
||||
|
||||
|
||||
class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize mail domain access."""
|
||||
|
||||
@@ -40,6 +40,7 @@ class MailDomainViewSet(
|
||||
queryset = models.MailDomain.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict results to the current user's team."""
|
||||
return self.queryset.filter(accesses__user=self.request.user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -100,6 +101,7 @@ class MailDomainAccessViewSet(
|
||||
detail_serializer_class = serializers.MailDomainAccessSerializer
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Chooses list or detail serializer according to the action."""
|
||||
if self.action in {"list", "retrieve"}:
|
||||
return self.list_serializer_class
|
||||
return self.detail_serializer_class
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-ancestors
|
||||
"""
|
||||
Application enums declaration
|
||||
"""
|
||||
@@ -21,3 +22,12 @@ class MailDomainStatusChoices(models.TextChoices):
|
||||
ENABLED = "enabled", _("Enabled")
|
||||
FAILED = "failed", _("Failed")
|
||||
DISABLED = "disabled", _("Disabled")
|
||||
|
||||
|
||||
class MailboxStatusChoices(models.TextChoices):
|
||||
"""Lists the possible statuses in which a mailbox can be."""
|
||||
|
||||
PENDING = "pending", _("Pending")
|
||||
ENABLED = "enabled", _("Enabled")
|
||||
FAILED = "failed", _("Failed")
|
||||
DISABLED = "disabled", _("Disabled")
|
||||
|
||||
@@ -75,3 +75,9 @@ class MailboxFactory(factory.django.DjangoModelFactory):
|
||||
)
|
||||
domain = factory.SubFactory(MailDomainEnabledFactory)
|
||||
secondary_email = factory.Faker("email")
|
||||
|
||||
|
||||
class MailboxEnabledFactory(MailboxFactory):
|
||||
"""A factory to create mailbox enabled."""
|
||||
|
||||
status = enums.MailboxStatusChoices.ENABLED
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"Mailbox manager management module."
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager management commands module."""
|
||||
|
||||
@@ -7,10 +7,13 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
import requests
|
||||
from rest_framework import status
|
||||
|
||||
from mailbox_manager.enums import MailDomainStatusChoices
|
||||
from mailbox_manager.models import MailDomain
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
DIMAIL_URL = "http://host.docker.internal:8001"
|
||||
DIMAIL_URL = "http://dimail:8000"
|
||||
admin = {"username": "admin", "password": "admin"}
|
||||
regie = {"username": "la_regie", "password": "password"}
|
||||
|
||||
@@ -48,16 +51,21 @@ class Command(BaseCommand):
|
||||
perms=["new_domain", "create_users", "manage_users"],
|
||||
)
|
||||
|
||||
# we create a domain and add John Doe to it
|
||||
domain_name = "test.domain.com"
|
||||
if not MailDomain.objects.filter(name=domain_name).exists():
|
||||
MailDomain.objects.create(
|
||||
name=domain_name, status=MailDomainStatusChoices.ENABLED
|
||||
)
|
||||
self.create_domain(domain_name)
|
||||
|
||||
# we create a dimail user for keycloak+regie user John Doe
|
||||
# This way, la Régie will be able to make request in the name of
|
||||
# this user
|
||||
people_base_user = User.objects.get(name="John Doe")
|
||||
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
|
||||
|
||||
# we create a domain and add John Doe to it
|
||||
domain_name = "test.domain.com"
|
||||
self.create_domain(domain_name)
|
||||
self.create_allows(people_base_user.sub, domain_name)
|
||||
people_base_user = User.objects.filter(name="John Doe")
|
||||
if people_base_user.exists():
|
||||
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
|
||||
self.create_allows(people_base_user.sub, domain_name)
|
||||
|
||||
self.stdout.write("DONE", ending="\n")
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-18 14:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mailbox_manager', '0013_remove_maildomain_secret'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mailbox',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', 'Pending'), ('enabled', 'Enabled'), ('failed', 'Failed'), ('disabled', 'Disabled')], default='pending', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -10,7 +10,11 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import BaseModel
|
||||
|
||||
from mailbox_manager.enums import MailDomainRoleChoices, MailDomainStatusChoices
|
||||
from mailbox_manager.enums import (
|
||||
MailboxStatusChoices,
|
||||
MailDomainRoleChoices,
|
||||
MailDomainStatusChoices,
|
||||
)
|
||||
|
||||
|
||||
class MailDomain(BaseModel):
|
||||
@@ -184,6 +188,11 @@ class Mailbox(BaseModel):
|
||||
secondary_email = models.EmailField(
|
||||
_("secondary email address"), null=False, blank=False
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=MailboxStatusChoices.choices,
|
||||
default=MailboxStatusChoices.PENDING,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_mail_box"
|
||||
@@ -196,14 +205,8 @@ class Mailbox(BaseModel):
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Mailboxes can only be created on enabled domains.
|
||||
Also, mail-provisioning API credentials must be set for dimail to allow auth.
|
||||
Mail-provisioning API credentials must be set for dimail to allow auth.
|
||||
"""
|
||||
if self.domain.status != MailDomainStatusChoices.ENABLED:
|
||||
raise exceptions.ValidationError(
|
||||
"You can create mailbox only for a domain enabled"
|
||||
)
|
||||
|
||||
# Won't be able to query user token if MAIL_PROVISIONING_API_CREDENTIALS are not set
|
||||
if not settings.MAIL_PROVISIONING_API_CREDENTIALS:
|
||||
raise exceptions.ValidationError(
|
||||
@@ -216,6 +219,11 @@ class Mailbox(BaseModel):
|
||||
"""
|
||||
self.full_clean()
|
||||
|
||||
if self.domain.status == MailDomainStatusChoices.DISABLED:
|
||||
raise exceptions.ValidationError(
|
||||
_("You can't create a mailbox for a disabled domain.")
|
||||
)
|
||||
|
||||
if self._state.adding:
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager tests."""
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
Tests for MailDomains API endpoint in People's app mailbox_manager. Focus on "create" action.
|
||||
"""
|
||||
|
||||
import re
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -56,13 +62,26 @@ def test_api_mail_domains__create_authenticated():
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": "mydomain.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
domain_name = "test.domain.fr"
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/domains/"),
|
||||
body=str(
|
||||
{
|
||||
"name": domain_name,
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{"name": domain_name, "context": "null", "features": ["webmail"]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
domain = models.MailDomain.objects.get()
|
||||
|
||||
@@ -79,5 +98,93 @@ def test_api_mail_domains__create_authenticated():
|
||||
|
||||
# a new domain with status "pending" is created and authenticated user is the owner
|
||||
assert domain.status == enums.MailDomainStatusChoices.PENDING
|
||||
assert domain.name == "mydomain.com"
|
||||
assert domain.name == domain_name
|
||||
assert domain.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
## SYNC TO DIMAIL
|
||||
@mock.patch.object(Logger, "info")
|
||||
def test_api_mail_domains__create_dimail_domain(mock_info):
|
||||
"""
|
||||
Creating a domain should trigger a call to create a domain on dimail too.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
domain_name = "test.fr"
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsp = rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/domains/"),
|
||||
body=str(
|
||||
{
|
||||
"name": domain_name,
|
||||
}
|
||||
),
|
||||
status=status.HTTP_201_CREATED,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": domain_name,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert rsp.call_count == 1 # endpoint was called
|
||||
|
||||
# Logger
|
||||
assert (
|
||||
mock_info.call_count == 2
|
||||
) # should be 1. A new empty info has been added. To be investigated
|
||||
assert mock_info.call_args_list[0][0] == (
|
||||
"Domain %s successfully created on dimail by user %s",
|
||||
domain_name,
|
||||
user.sub,
|
||||
)
|
||||
|
||||
|
||||
def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
|
||||
"""No domain should be created when dimail returns a 409 conflict."""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
domain_name = "test.fr"
|
||||
dimail_error = {
|
||||
"status_code": status.HTTP_409_CONFLICT,
|
||||
"detail": "Domain already exists",
|
||||
}
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsp = rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(r".*/domains/"),
|
||||
body=str({"detail": dimail_error["detail"]}),
|
||||
status=dimail_error["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
with pytest.raises(HTTPError):
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": domain_name,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert rsp.call_count == 1 # endpoint was called
|
||||
assert response.status_code == dimail_error["status_code"]
|
||||
assert response.json() == {"detail": dimail_error["detail"]}
|
||||
|
||||
# check logs
|
||||
record = caplog.records[0]
|
||||
assert record.levelname == "ERROR"
|
||||
assert (
|
||||
record.message
|
||||
== "[DIMAIL] unexpected error : 409 {'detail': 'Domain already exists'}"
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -136,6 +137,7 @@ def test_api_mailboxes__create_roles_success(role):
|
||||
"last_name": str(mailbox.last_name),
|
||||
"local_part": str(mailbox.local_part),
|
||||
"secondary_email": str(mailbox.secondary_email),
|
||||
"status": enums.MailboxStatusChoices.ENABLED,
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +195,7 @@ def test_api_mailboxes__create_with_accent_success(role):
|
||||
"last_name": str(mailbox.last_name),
|
||||
"local_part": str(mailbox.local_part),
|
||||
"secondary_email": str(mailbox.secondary_email),
|
||||
"status": enums.MailboxStatusChoices.ENABLED,
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +238,73 @@ def test_api_mailboxes__create_administrator_missing_fields():
|
||||
assert response.json() == {"secondary_email": ["This field is required."]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[
|
||||
enums.MailDomainRoleChoices.OWNER,
|
||||
enums.MailDomainRoleChoices.ADMIN,
|
||||
],
|
||||
)
|
||||
def test_api_mailboxes__cannot_create_on_disabled_domain(role):
|
||||
"""Admin and owner users should not be able to create mailboxes for a disabled domain"""
|
||||
mail_domain = factories.MailDomainFactory(
|
||||
status=enums.MailDomainStatusChoices.DISABLED
|
||||
)
|
||||
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not models.Mailbox.objects.exists()
|
||||
assert response.json() == ["You can't create a mailbox for a disabled domain."]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain_status",
|
||||
[
|
||||
enums.MailDomainStatusChoices.PENDING,
|
||||
enums.MailDomainStatusChoices.FAILED,
|
||||
],
|
||||
)
|
||||
def test_api_mailboxes__create_pending_mailboxes(domain_status):
|
||||
"""
|
||||
Admin and owner users should be able to create mailboxes, including on pending and failed
|
||||
domains.
|
||||
Mailboxes created on pending and failed domains should have the "pending" status
|
||||
"""
|
||||
mail_domain = factories.MailDomainFactory(status=domain_status)
|
||||
access = factories.MailDomainAccessFactory(
|
||||
role=enums.MailDomainRoleChoices.ADMIN, domain=mail_domain
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
|
||||
mailbox_values = serializers.MailboxSerializer(
|
||||
factories.MailboxFactory.build()
|
||||
).data
|
||||
with responses.RequestsMock():
|
||||
# We add no response in RequestsMock
|
||||
# because we expect no outside calls to be made
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
|
||||
mailbox_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
mailbox = models.Mailbox.objects.get()
|
||||
assert mailbox.status == "pending"
|
||||
|
||||
|
||||
### REACTING TO DIMAIL-API
|
||||
### We mock dimail's responses to avoid testing dimail's container too
|
||||
|
||||
@@ -410,6 +480,7 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
|
||||
"last_name": str(mailbox_data["last_name"]),
|
||||
"local_part": str(mailbox_data["local_part"]),
|
||||
"secondary_email": str(mailbox_data["secondary_email"]),
|
||||
"status": enums.MailboxStatusChoices.ENABLED,
|
||||
}
|
||||
assert mailbox.first_name == mailbox_data["first_name"]
|
||||
assert mailbox.last_name == mailbox_data["last_name"]
|
||||
@@ -500,8 +571,7 @@ def test_api_mailboxes__user_unrelated_to_domain():
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "error")
|
||||
def test_api_mailboxes__handling_dimail_unexpected_error(mock_error):
|
||||
def test_api_mailboxes__handling_dimail_unexpected_error(caplog):
|
||||
"""
|
||||
API should raise a clear error when dimail returns an unexpected response.
|
||||
"""
|
||||
@@ -526,12 +596,12 @@ def test_api_mailboxes__handling_dimail_unexpected_error(mock_error):
|
||||
rsps.add(
|
||||
rsps.POST,
|
||||
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
|
||||
body='{"details": "Internal server error"}',
|
||||
body='{"detail": "Internal server error"}',
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
with pytest.raises(SystemError):
|
||||
with pytest.raises(HTTPError):
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
|
||||
mailbox_data,
|
||||
@@ -544,9 +614,10 @@ def test_api_mailboxes__handling_dimail_unexpected_error(mock_error):
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
# Check error logger was called
|
||||
assert mock_error.called
|
||||
assert mock_error.call_args_list[1][0] == (
|
||||
'Unexpected response from dimail: 500 {"details": "Internal server error"}',
|
||||
assert caplog.records[0].levelname == "ERROR"
|
||||
assert (
|
||||
caplog.records[0].message
|
||||
== "[DIMAIL] unexpected error : 500 {'detail': 'Internal server error'}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ def test_api_mailboxes__list_authenticated():
|
||||
def test_api_mailboxes__list_roles(role):
|
||||
"""Owner, admin and viewer users should be able to list mailboxes"""
|
||||
mail_domain = factories.MailDomainEnabledFactory()
|
||||
mailbox1 = factories.MailboxFactory(domain=mail_domain)
|
||||
mailbox2 = factories.MailboxFactory(domain=mail_domain)
|
||||
mailbox1 = factories.MailboxEnabledFactory(domain=mail_domain)
|
||||
mailbox2 = factories.MailboxEnabledFactory(domain=mail_domain)
|
||||
|
||||
access = factories.MailDomainAccessFactory(role=role, domain=mail_domain)
|
||||
client = APIClient()
|
||||
@@ -69,6 +69,7 @@ def test_api_mailboxes__list_roles(role):
|
||||
"last_name": str(mailbox2.last_name),
|
||||
"local_part": str(mailbox2.local_part),
|
||||
"secondary_email": str(mailbox2.secondary_email),
|
||||
"status": enums.MailboxStatusChoices.ENABLED,
|
||||
},
|
||||
{
|
||||
"id": str(mailbox1.id),
|
||||
@@ -76,6 +77,7 @@ def test_api_mailboxes__list_roles(role):
|
||||
"last_name": str(mailbox1.last_name),
|
||||
"local_part": str(mailbox1.local_part),
|
||||
"secondary_email": str(mailbox1.secondary_email),
|
||||
"status": enums.MailboxStatusChoices.ENABLED,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Test the `setup_dimail_db` management command"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from mailbox_manager.management.commands.setup_dimail_db import DIMAIL_URL, admin
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
admin_auth = (admin["username"], admin["password"])
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
settings.DEBUG is not True,
|
||||
reason="Run only in local (dimail container not running in other envs)",
|
||||
)
|
||||
def test_commands_setup_dimail_db():
|
||||
"""The create_demo management command should create objects as expected."""
|
||||
call_command("setup_dimail_db")
|
||||
|
||||
# check created users
|
||||
response = requests.get(url=f"{DIMAIL_URL}/users/", auth=admin_auth, timeout=10)
|
||||
users = response.json()
|
||||
|
||||
# if John Doe exists, we created a dimail user for them
|
||||
local_user = User.objects.filter(name="John Doe").exists()
|
||||
|
||||
assert len(users) == 3 if local_user else 2
|
||||
# remove uuid because we cannot devine them
|
||||
[user.pop("uuid") for user in users] # pylint: disable=W0106
|
||||
|
||||
if local_user:
|
||||
assert users.pop() == {
|
||||
"is_admin": False,
|
||||
"name": User.objects.get(name="John Doe").uuid,
|
||||
"perms": [],
|
||||
}
|
||||
assert users == [
|
||||
{
|
||||
"is_admin": True,
|
||||
"name": "admin",
|
||||
"perms": [],
|
||||
},
|
||||
{
|
||||
"is_admin": False,
|
||||
"name": "la_regie",
|
||||
"perms": [
|
||||
"new_domain",
|
||||
"create_users",
|
||||
"manage_users",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# check created domains
|
||||
response = requests.get(url=f"{DIMAIL_URL}/domains/", auth=admin_auth, timeout=10)
|
||||
domains = response.json()
|
||||
assert len(domains) == 1
|
||||
assert domains[0]["name"] == "test.domain.com"
|
||||
|
||||
# check created allows
|
||||
response = requests.get(url=f"{DIMAIL_URL}/allows/", auth=admin_auth, timeout=10)
|
||||
allows = response.json()
|
||||
assert len(allows) == 2 if local_user else 1
|
||||
@@ -74,7 +74,7 @@ def test_models_mailboxes__domain_must_be_a_maildomain_instance():
|
||||
|
||||
def test_models_mailboxes__domain_cannot_be_null():
|
||||
"""The "domain" field should not be null."""
|
||||
with pytest.raises(models.MailDomain.DoesNotExist, match="Mailbox has no domain."):
|
||||
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
|
||||
factories.MailboxFactory(domain=None)
|
||||
|
||||
|
||||
@@ -93,44 +93,37 @@ def test_models_mailboxes__secondary_email_cannot_be_null():
|
||||
factories.MailboxFactory(secondary_email=None)
|
||||
|
||||
|
||||
def test_models_mailboxes__cannot_be_created_for_disabled_maildomain():
|
||||
"""Mailbox creation is allowed only for a domain enabled.
|
||||
@pytest.mark.parametrize(
|
||||
"domain_status",
|
||||
[
|
||||
enums.MailDomainStatusChoices.PENDING,
|
||||
enums.MailDomainStatusChoices.FAILED,
|
||||
],
|
||||
)
|
||||
def test_models_mailboxes__can_create_pending_mailboxes_on_non_enabled_domain(
|
||||
domain_status,
|
||||
):
|
||||
"""Mailbox creation is allowed for a domain pending and failed.
|
||||
A pending mailbox is created."""
|
||||
mailbox = factories.MailboxFactory(
|
||||
domain=factories.MailDomainFactory(status=domain_status)
|
||||
)
|
||||
assert mailbox.status == enums.MailboxStatusChoices.PENDING
|
||||
|
||||
|
||||
def test_models_mailboxes__cannot_create_mailboxes_on_disabled_domain():
|
||||
"""Mailbox creation is not allowed for a domain disabled.
|
||||
A disabled status for the mail domain raises an error."""
|
||||
with pytest.raises(
|
||||
exceptions.ValidationError,
|
||||
match="You can create mailbox only for a domain enabled",
|
||||
match="You can't create a mailbox for a disabled domain.",
|
||||
):
|
||||
factories.MailboxFactory(
|
||||
domain=factories.MailDomainFactory(
|
||||
status=enums.MailDomainStatusChoices.DISABLED
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_models_mailboxes__cannot_be_created_for_failed_maildomain():
|
||||
"""Mailbox creation is allowed only for a domain enabled.
|
||||
A failed status for the mail domain raises an error."""
|
||||
with pytest.raises(
|
||||
exceptions.ValidationError,
|
||||
match="You can create mailbox only for a domain enabled",
|
||||
):
|
||||
factories.MailboxFactory(
|
||||
domain=factories.MailDomainFactory(
|
||||
status=enums.MailDomainStatusChoices.FAILED
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_models_mailboxes__cannot_be_created_for_pending_maildomain():
|
||||
"""Mailbox creation is allowed only for a domain enabled.
|
||||
A pending status for the mail domain raises an error."""
|
||||
with pytest.raises(
|
||||
exceptions.ValidationError,
|
||||
match="You can create mailbox only for a domain enabled",
|
||||
):
|
||||
# MailDomainFactory initializes a mail domain with default values,
|
||||
# so mail domain status is pending!
|
||||
factories.MailboxFactory(domain=factories.MailDomainFactory())
|
||||
assert not models.Mailbox.objects.exist()
|
||||
|
||||
|
||||
### REACTING TO DIMAIL-API
|
||||
|
||||
@@ -37,6 +37,21 @@ def test_models_mail_domain__slug_inferred_from_name():
|
||||
assert domain.slug == slugify(name)
|
||||
|
||||
|
||||
# "STATUS" FIELD
|
||||
|
||||
|
||||
def test_models_mail_domain__status_should_not_be_empty():
|
||||
"""Status field should not be empty."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank"):
|
||||
factories.MailDomainFactory(status="")
|
||||
|
||||
|
||||
def test_models_mail_domain__status_should_not_be_null():
|
||||
"""Status field should not be null."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null."):
|
||||
factories.MailDomainFactory(status=None)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Unit tests for dimail client
|
||||
"""
|
||||
|
||||
import re
|
||||
from email.errors import HeaderParseError, NonASCIILocalPartDefect
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
|
||||
from mailbox_manager import factories, models
|
||||
from mailbox_manager.utils.dimail import DimailAPIClient
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_dimail_synchronization__already_sync():
|
||||
"""
|
||||
No mailbox should be created when everything is already synced.
|
||||
"""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
factories.MailboxFactory.create_batch(3, domain=domain)
|
||||
|
||||
pre_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
|
||||
assert pre_sync_mailboxes.count() == 3
|
||||
|
||||
dimail_client = DimailAPIClient()
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "dimail_people_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
[
|
||||
{
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"{mailbox.local_part}@{domain.name}",
|
||||
"givenName": mailbox.first_name,
|
||||
"surName": mailbox.last_name,
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
}
|
||||
for mailbox in pre_sync_mailboxes
|
||||
]
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
imported_mailboxes = dimail_client.synchronize_mailboxes_from_dimail(domain)
|
||||
|
||||
post_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
|
||||
assert post_sync_mailboxes.count() == 3
|
||||
assert imported_mailboxes == []
|
||||
assert set(models.Mailbox.objects.filter(domain=domain)) == set(pre_sync_mailboxes)
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "warning")
|
||||
def test_dimail_synchronization__synchronize_mailboxes(mock_warning):
|
||||
"""A mailbox existing solely on dimail should be synchronized
|
||||
upon calling sync function on its domain"""
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
dimail_client = DimailAPIClient()
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "dimail_people_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
mailbox_valid = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"oxadmin@{domain.name}",
|
||||
"givenName": "Admin",
|
||||
"surName": "Context",
|
||||
"displayName": "Context Admin",
|
||||
}
|
||||
mailbox_with_wrong_domain = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": "johndoe@wrongdomain.com",
|
||||
"givenName": "John",
|
||||
"surName": "Doe",
|
||||
"displayName": "John Doe",
|
||||
}
|
||||
mailbox_with_invalid_domain = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"naw@ake@{domain.name}",
|
||||
"givenName": "Joe",
|
||||
"surName": "Doe",
|
||||
"displayName": "Joe Doe",
|
||||
}
|
||||
mailbox_with_invalid_local_part = {
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"obalmaské@{domain.name}",
|
||||
"givenName": "Jean",
|
||||
"surName": "Vang",
|
||||
"displayName": "Jean Vang",
|
||||
}
|
||||
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
[
|
||||
mailbox_valid,
|
||||
mailbox_with_wrong_domain,
|
||||
mailbox_with_invalid_domain,
|
||||
mailbox_with_invalid_local_part,
|
||||
]
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
imported_mailboxes = dimail_client.synchronize_mailboxes_from_dimail(domain)
|
||||
|
||||
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
|
||||
assert mock_warning.call_count == 3
|
||||
|
||||
# first we try to import email with a wrong domain
|
||||
assert mock_warning.call_args_list[0][0] == (
|
||||
"Import of email %s failed because of a wrong domain",
|
||||
mailbox_with_wrong_domain["email"],
|
||||
)
|
||||
|
||||
# then we try to import email with invalid domain
|
||||
invalid_mailbox_log = mock_warning.call_args_list[1][0]
|
||||
assert invalid_mailbox_log[1] == mailbox_with_invalid_domain["email"]
|
||||
assert isinstance(invalid_mailbox_log[2], HeaderParseError)
|
||||
|
||||
# finally we try to import email with non ascii local part
|
||||
non_ascii_mailbox_log = mock_warning.call_args_list[2][0]
|
||||
assert non_ascii_mailbox_log[1] == mailbox_with_invalid_local_part["email"]
|
||||
assert isinstance(non_ascii_mailbox_log[2], NonASCIILocalPartDefect)
|
||||
|
||||
mailbox = models.Mailbox.objects.get()
|
||||
assert mailbox.local_part == "oxadmin"
|
||||
assert imported_mailboxes == [mailbox_valid["email"]]
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager utils."""
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""A minimalist client to synchronize with mailbox provisioning API."""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import smtplib
|
||||
from email.errors import HeaderParseError, NonASCIILocalPartDefect
|
||||
from email.headerregistry import Address
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
@@ -13,6 +17,8 @@ import requests
|
||||
from rest_framework import status
|
||||
from urllib3.util import Retry
|
||||
|
||||
from mailbox_manager import models
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
@@ -71,6 +77,41 @@ class DimailAPIClient:
|
||||
|
||||
return self.pass_dimail_unexpected_response(response)
|
||||
|
||||
def send_domain_creation_request(self, domain_name, request_user):
|
||||
"""Send a domain creation request to dimail API."""
|
||||
|
||||
payload = {
|
||||
"name": domain_name,
|
||||
"context_name": domain_name, # for now, we put each domain on its own context
|
||||
"features": ["webmail", "mailbox"],
|
||||
"delivery": "virtual",
|
||||
}
|
||||
try:
|
||||
response = session.post(
|
||||
f"{self.API_URL}/domains/",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
|
||||
verify=True,
|
||||
timeout=10,
|
||||
)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
logger.error(
|
||||
"Connection error while trying to reach %s.",
|
||||
self.API_URL,
|
||||
exc_info=error,
|
||||
)
|
||||
raise error
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
logger.info(
|
||||
"Domain %s successfully created on dimail by user %s",
|
||||
domain_name,
|
||||
request_user,
|
||||
)
|
||||
return response
|
||||
|
||||
return self.pass_dimail_unexpected_response(response)
|
||||
|
||||
def send_mailbox_request(self, mailbox, user_sub=None):
|
||||
"""Send a CREATE mailbox request to mail provisioning API."""
|
||||
|
||||
@@ -118,12 +159,17 @@ class DimailAPIClient:
|
||||
|
||||
def pass_dimail_unexpected_response(self, response):
|
||||
"""Raise error when encountering an unexpected error in dimail."""
|
||||
error_content = response.content.decode("utf-8")
|
||||
try:
|
||||
error_content = json.loads(
|
||||
response.content.decode(response.encoding).replace("'", '"')
|
||||
)
|
||||
except json.decoder.JSONDecodeError:
|
||||
error_content = response.content.decode(response.encoding)
|
||||
|
||||
logger.error(
|
||||
"[DIMAIL] unexpected error : %s %s", response.status_code, error_content
|
||||
)
|
||||
raise SystemError(
|
||||
raise requests.exceptions.HTTPError(
|
||||
f"Unexpected response from dimail: {response.status_code} {error_content}"
|
||||
)
|
||||
|
||||
@@ -163,3 +209,66 @@ class DimailAPIClient:
|
||||
recipient,
|
||||
exception,
|
||||
)
|
||||
|
||||
def synchronize_mailboxes_from_dimail(self, domain):
|
||||
"""Synchronize mailboxes from dimail - open xchange to our database.
|
||||
This is useful in case of acquisition of a pre-existing mail domain.
|
||||
Mailboxes created here are not new mailboxes and will not trigger mail notification."""
|
||||
|
||||
try:
|
||||
response = session.get(
|
||||
f"{self.API_URL}/domains/{domain.name}/mailboxes/",
|
||||
headers=self.get_headers(),
|
||||
verify=True,
|
||||
timeout=10,
|
||||
)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
logger.error(
|
||||
"Connection error while trying to reach %s.",
|
||||
self.API_URL,
|
||||
exc_info=error,
|
||||
)
|
||||
raise error
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return self.pass_dimail_unexpected_response(response)
|
||||
|
||||
dimail_mailboxes = ast.literal_eval(
|
||||
response.content.decode("utf-8")
|
||||
) # format output str to proper list
|
||||
|
||||
people_mailboxes = models.Mailbox.objects.filter(domain=domain)
|
||||
imported_mailboxes = []
|
||||
for dimail_mailbox in dimail_mailboxes:
|
||||
if not dimail_mailbox["email"] in [
|
||||
str(people_mailbox) for people_mailbox in people_mailboxes
|
||||
]:
|
||||
try:
|
||||
# sometimes dimail api returns email from another domain,
|
||||
# so we decide to exclude this kind of email
|
||||
address = Address(addr_spec=dimail_mailbox["email"])
|
||||
if address.domain == domain.name:
|
||||
# creates a mailbox on our end
|
||||
mailbox = models.Mailbox.objects.create(
|
||||
first_name=dimail_mailbox["givenName"],
|
||||
last_name=dimail_mailbox["surName"],
|
||||
local_part=address.username,
|
||||
domain=domain,
|
||||
secondary_email=dimail_mailbox[
|
||||
"email"
|
||||
], # secondary email is mandatory. Unfortunately, dimail doesn't
|
||||
# store any. We temporarily give current email as secondary email.
|
||||
)
|
||||
imported_mailboxes.append(str(mailbox))
|
||||
else:
|
||||
logger.warning(
|
||||
"Import of email %s failed because of a wrong domain",
|
||||
dimail_mailbox["email"],
|
||||
)
|
||||
except (HeaderParseError, NonASCIILocalPartDefect) as err:
|
||||
logger.warning(
|
||||
"Import of email %s failed with error %s",
|
||||
dimail_mailbox["email"],
|
||||
err,
|
||||
)
|
||||
return imported_mailboxes
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People module."""
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -43,6 +44,17 @@ def get_release():
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
def get_commit():
|
||||
"""
|
||||
Get the current commit of the application
|
||||
"""
|
||||
try:
|
||||
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
|
||||
return json.load(version)["commit"]
|
||||
except FileNotFoundError:
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
class Base(Configuration):
|
||||
"""
|
||||
This is the base configuration every configuration (aka environment) should inherit from. It
|
||||
@@ -387,6 +399,11 @@ class Base(Configuration):
|
||||
environ_name="USER_OIDC_FIELDS_TO_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = values.Value(
|
||||
default=None,
|
||||
environ_name="OIDC_ORGANIZATION_REGISTRATION_ID_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_OP_TOKEN_INTROSPECTION_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_TOKEN_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||
@@ -426,7 +443,7 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
MAIL_PROVISIONING_API_URL = values.Value(
|
||||
default="http://host.docker.internal:8001",
|
||||
default="http://dimail:8000",
|
||||
environ_name="MAIL_PROVISIONING_API_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -436,11 +453,14 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
FEATURES = {
|
||||
"TEAMS": values.BooleanValue(
|
||||
default=True, environ_name="FEATURE_TEAMS", environ_prefix=None
|
||||
),
|
||||
}
|
||||
# Organizations
|
||||
ORGANIZATION_REGISTRATION_ID_VALIDATORS = json.loads(
|
||||
values.Value(
|
||||
default="[]",
|
||||
environ_name="ORGANIZATION_REGISTRATION_ID_VALIDATORS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
@@ -448,6 +468,27 @@ class Base(Configuration):
|
||||
"""Environment in which the application is launched."""
|
||||
return self.__class__.__name__.lower()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def FEATURES(self):
|
||||
"""Feature flags for the application."""
|
||||
FEATURE_FLAGS: set = {
|
||||
"CONTACTS_CREATE", # Used in the users/me/ endpoint
|
||||
"CONTACTS_DISPLAY", # Used in the users/me/ endpoint
|
||||
"MAILBOXES_CREATE", # Used in the users/me/ endpoint
|
||||
"TEAMS_DISPLAY",
|
||||
"TEAMS_CREATE", # Used in the users/me/ endpoint
|
||||
}
|
||||
|
||||
return {
|
||||
feature: values.BooleanValue(
|
||||
default=True,
|
||||
environ_name=f"FEATURE_{feature}",
|
||||
environ_prefix=None,
|
||||
)
|
||||
for feature in FEATURE_FLAGS
|
||||
}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def RELEASE(self):
|
||||
@@ -458,6 +499,14 @@ class Base(Configuration):
|
||||
"""
|
||||
return get_release()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def COMMIT(self):
|
||||
"""
|
||||
Return the commit information.
|
||||
"""
|
||||
return get_commit()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def PARLER_LANGUAGES(self):
|
||||
@@ -488,10 +537,14 @@ class Base(Configuration):
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
default_integrations=False,
|
||||
)
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
|
||||
# Add the application name to the Sentry scope
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
|
||||
# Ignore the logs added by the DockerflowMiddleware
|
||||
ignore_logger("request.summary")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
@@ -549,7 +602,11 @@ class Development(Base):
|
||||
}
|
||||
)
|
||||
|
||||
# this is a dev credentials for mail provisioning API
|
||||
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
|
||||
|
||||
def __init__(self):
|
||||
"""In dev, force installs needed for Swagger API."""
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]
|
||||
|
||||
@@ -593,6 +650,15 @@ class Test(Base):
|
||||
# this is a dev credentials for mail provisioning API
|
||||
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
|
||||
|
||||
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.core.validators.RegexValidator",
|
||||
"OPTIONS": {
|
||||
"regex": "^[0-9]{14}$",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ContinuousIntegration(Test):
|
||||
"""
|
||||
|
||||
+14
-14
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "people"
|
||||
version = "1.4.1"
|
||||
version = "1.6.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,18 +25,18 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.44",
|
||||
"boto3==1.35.63",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.5.0",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.1.1",
|
||||
"redis==5.2.0",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.2",
|
||||
"django==5.1.3",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -46,12 +46,11 @@ dependencies = [
|
||||
"jsonschema==4.23.0",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.9.0",
|
||||
"PyJWT==2.10.0",
|
||||
"joserfc==1.0.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk[django]==2.17.0",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.7.0",
|
||||
"sentry-sdk[django]==2.18.0",
|
||||
"whitenoise==6.8.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
]
|
||||
|
||||
@@ -64,19 +63,19 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"drf-spectacular-sidecar==2024.11.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.28.0",
|
||||
"ipython==8.29.0",
|
||||
"pyfakefs==5.7.1",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.1",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.3",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.7.0",
|
||||
"ruff==0.7.4",
|
||||
"types-requests==2.32.0.20241016",
|
||||
"freezegun==1.5.1",
|
||||
]
|
||||
@@ -116,8 +115,9 @@ select = [
|
||||
"S", # flake8-bandit
|
||||
"SLF", # flake8-self
|
||||
"T20", # flake8-print
|
||||
"D1", # pydocstyle
|
||||
]
|
||||
ignore= ["DJ001", "PLR2004"]
|
||||
ignore= ["DJ001", "PLR2004", "D105", "D106"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
section-order = ["future","standard-library","django","third-party","people","first-party","local-folder"]
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
next-env.d.ts
|
||||
version.json
|
||||
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-desk",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -16,37 +16,36 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hookform/resolvers": "3.9.0",
|
||||
"@hookform/resolvers": "3.9.1",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@tanstack/react-query": "5.56.2",
|
||||
"i18next": "23.15.1",
|
||||
"@tanstack/react-query": "5.60.6",
|
||||
"i18next": "23.16.5",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "14.2.13",
|
||||
"next": "15.0.3",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.3.3",
|
||||
"react-dom": "*",
|
||||
"react-hook-form": "7.53.0",
|
||||
"react-i18next": "15.0.2",
|
||||
"react-select": "5.8.1",
|
||||
"sass": "1.80.3",
|
||||
"react-hook-form": "7.53.2",
|
||||
"react-i18next": "15.1.1",
|
||||
"react-select": "5.8.3",
|
||||
"sass": "1.81.0",
|
||||
"styled-components": "6.1.13",
|
||||
"zod": "3.23.8",
|
||||
"zustand": "4.5.5"
|
||||
"zustand": "5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/devtools": "4.3.1",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.58.0",
|
||||
"@tanstack/react-query-devtools": "5.60.6",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.5.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/lodash": "4.17.9",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.10",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "*",
|
||||
"dotenv": "16.4.5",
|
||||
"eslint-config-people": "*",
|
||||
@@ -55,7 +54,7 @@
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "16.9.0",
|
||||
"stylelint": "16.10.0",
|
||||
"stylelint-config-standard": "36.0.1",
|
||||
"stylelint-prettier": "5.0.2",
|
||||
"typescript": "*"
|
||||
|
||||
@@ -21,7 +21,12 @@ describe('Page', () => {
|
||||
|
||||
it('checks Page rendering with team feature', () => {
|
||||
useConfigStore.setState({
|
||||
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: true }, LANGUAGES: [] },
|
||||
config: {
|
||||
RELEASE: '1.0.0',
|
||||
COMMIT: 'NA',
|
||||
FEATURES: { TEAMS_DISPLAY: true },
|
||||
LANGUAGES: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
@@ -31,7 +36,12 @@ describe('Page', () => {
|
||||
|
||||
it('checks Page rendering without team feature', () => {
|
||||
useConfigStore.setState({
|
||||
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: false }, LANGUAGES: [] },
|
||||
config: {
|
||||
RELEASE: '1.0.0',
|
||||
COMMIT: 'NA',
|
||||
FEATURES: { TEAMS_DISPLAY: false },
|
||||
LANGUAGES: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -15,7 +15,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
<Box $height="100vh">
|
||||
<Header />
|
||||
<Box $css="flex: 1;" $direction="row">
|
||||
{config?.FEATURES.TEAMS && <Menu />}
|
||||
{config?.FEATURES.TEAMS_DISPLAY && <Menu />}
|
||||
<Box
|
||||
as="main"
|
||||
$height={`calc(100vh - ${HEADER_HEIGHT})`}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { MainLayout } from '../MainLayout';
|
||||
import { useAuthStore } from '../auth';
|
||||
import { useConfigStore } from '../config';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
@@ -17,7 +18,25 @@ jest.mock('next/navigation', () => ({
|
||||
describe('MainLayout', () => {
|
||||
it('checks menu rendering with team feature', () => {
|
||||
useConfigStore.setState({
|
||||
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: true }, LANGUAGES: [] },
|
||||
config: {
|
||||
RELEASE: '1.0.0',
|
||||
COMMIT: 'NA',
|
||||
FEATURES: { TEAMS_DISPLAY: true },
|
||||
LANGUAGES: [],
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
authenticated: true,
|
||||
userData: {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
abilities: {
|
||||
contacts: { can_view: true },
|
||||
teams: { can_view: true },
|
||||
mailboxes: { can_view: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<MainLayout />, { wrapper: AppWrapper });
|
||||
@@ -35,9 +54,66 @@ describe('MainLayout', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks menu rendering with no abilities', () => {
|
||||
useConfigStore.setState({
|
||||
config: {
|
||||
RELEASE: '1.0.0',
|
||||
COMMIT: 'NA',
|
||||
FEATURES: { TEAMS_DISPLAY: true },
|
||||
LANGUAGES: [],
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
authenticated: true,
|
||||
userData: {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
abilities: {
|
||||
contacts: { can_view: false },
|
||||
teams: { can_view: false },
|
||||
mailboxes: { can_view: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<MainLayout />, { wrapper: AppWrapper });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
// Changé de getByRole à queryByRole
|
||||
name: /Teams button/i,
|
||||
}),
|
||||
).not.toBeInTheDocument(); //
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: /Mail Domains button/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks menu rendering without team feature', () => {
|
||||
useConfigStore.setState({
|
||||
config: { RELEASE: '1.0.0', FEATURES: { TEAMS: false }, LANGUAGES: [] },
|
||||
config: {
|
||||
RELEASE: '1.0.0',
|
||||
COMMIT: 'NA',
|
||||
FEATURES: { TEAMS_DISPLAY: false },
|
||||
LANGUAGES: [],
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
authenticated: true,
|
||||
userData: {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
abilities: {
|
||||
contacts: { can_view: true },
|
||||
teams: { can_view: true },
|
||||
mailboxes: { can_view: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<MainLayout />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -9,4 +9,14 @@ export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
abilities?: {
|
||||
mailboxes: UserAbilities;
|
||||
contacts: UserAbilities;
|
||||
teams: UserAbilities;
|
||||
};
|
||||
}
|
||||
|
||||
export type UserAbilities = {
|
||||
can_view?: boolean;
|
||||
can_create?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export interface Config {
|
||||
LANGUAGES: [string, string][];
|
||||
RELEASE: string;
|
||||
COMMIT: string;
|
||||
FEATURES: {
|
||||
TEAMS: boolean;
|
||||
TEAMS_DISPLAY: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import styled from 'styled-components';
|
||||
import { Box, LogoGouv, StyledLink, Text } from '@/components';
|
||||
import { useConfigStore } from '@/core';
|
||||
|
||||
import frontVersion from '../../../version.json';
|
||||
|
||||
import IconLink from './assets/external-link.svg';
|
||||
|
||||
const BlueStripe = styled.div`
|
||||
@@ -141,6 +143,11 @@ export const Footer = () => {
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Text as="p" aria-hidden="true" $css="display: none">
|
||||
You have found the hidden app version information ! Well done ! back
|
||||
commit is: {config?.COMMIT}; front commit is: {frontVersion?.commit}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
$size="m"
|
||||
|
||||
+5
-1
@@ -35,7 +35,11 @@ describe('getMailDomainAccesses', () => {
|
||||
{
|
||||
id: '2',
|
||||
role: Role.VIEWER,
|
||||
user: { id: '12', name: 'username2', email: 'user2@test.com' },
|
||||
user: {
|
||||
id: '12',
|
||||
name: 'username2',
|
||||
email: 'user2@test.com',
|
||||
},
|
||||
can_set_role_to: [Role.VIEWER],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -22,7 +22,7 @@ export const Panel = () => {
|
||||
$minWidth: '0',
|
||||
};
|
||||
|
||||
const styleNoTeam = !config?.FEATURES.TEAMS && {
|
||||
const styleNoTeam = !config?.FEATURES.TEAMS_DISPLAY && {
|
||||
$display: 'none',
|
||||
tabIndex: -1,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import IconGroup from '@/assets/icons/icon-group.svg';
|
||||
import { Box } from '@/components/';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
|
||||
|
||||
import MenuItem from './MenuItems';
|
||||
@@ -10,6 +10,9 @@ import IconMailDomains from './assets/icon-mails.svg';
|
||||
|
||||
export const Menu = () => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { userData } = useAuthStore();
|
||||
|
||||
console.log(userData);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -22,17 +25,21 @@ export const Menu = () => {
|
||||
$margin="none"
|
||||
>
|
||||
<Box $padding={{ top: 'large' }} $direction="column" $gap="0.8rem">
|
||||
<MenuItem
|
||||
Icon={IconGroup}
|
||||
label={t('Teams')}
|
||||
href="/teams"
|
||||
alias={['/teams']}
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={IconMailDomains}
|
||||
label={t('Mail Domains')}
|
||||
href="/mail-domains"
|
||||
/>
|
||||
{userData?.abilities?.teams.can_view && (
|
||||
<MenuItem
|
||||
Icon={IconGroup}
|
||||
label={t('Teams')}
|
||||
href="/teams"
|
||||
alias={['/teams']}
|
||||
/>
|
||||
)}
|
||||
{userData?.abilities?.mailboxes.can_view && (
|
||||
<MenuItem
|
||||
Icon={IconMailDomains}
|
||||
label={t('Mail Domains')}
|
||||
href="/mail-domains"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Team } from '../types';
|
||||
|
||||
@@ -17,18 +12,14 @@ export enum TeamsOrdering {
|
||||
export type TeamsParams = {
|
||||
ordering: TeamsOrdering;
|
||||
};
|
||||
type TeamsAPIParams = TeamsParams & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
type TeamsResponse = APIList<Team>;
|
||||
type TeamsResponse = Team[];
|
||||
|
||||
export const getTeams = async ({
|
||||
ordering,
|
||||
page,
|
||||
}: TeamsAPIParams): Promise<TeamsResponse> => {
|
||||
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
|
||||
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
|
||||
}: TeamsParams): Promise<TeamsResponse> => {
|
||||
const orderingQuery = ordering ? `?ordering=${ordering}` : '';
|
||||
const response = await fetchAPI(`teams/${orderingQuery}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the teams', await errorCauses(response));
|
||||
@@ -39,33 +30,9 @@ export const getTeams = async ({
|
||||
|
||||
export const KEY_LIST_TEAM = 'teams';
|
||||
|
||||
export function useTeams(
|
||||
param: TeamsParams,
|
||||
queryConfig?: DefinedInitialDataInfiniteOptions<
|
||||
TeamsResponse,
|
||||
APIError,
|
||||
InfiniteData<TeamsResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
) {
|
||||
return useInfiniteQuery<
|
||||
TeamsResponse,
|
||||
APIError,
|
||||
InfiniteData<TeamsResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>({
|
||||
initialPageParam: 1,
|
||||
queryKey: [KEY_LIST_TEAM, param],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getTeams({
|
||||
...param,
|
||||
page: pageParam,
|
||||
}),
|
||||
getNextPageParam(lastPage, allPages) {
|
||||
return lastPage.next ? allPages.length + 1 : undefined;
|
||||
},
|
||||
...queryConfig,
|
||||
export function useTeams(params: TeamsParams) {
|
||||
return useQuery({
|
||||
queryKey: [KEY_LIST_TEAM, params],
|
||||
queryFn: () => getTeams(params),
|
||||
});
|
||||
}
|
||||
|
||||
+39
-57
@@ -23,10 +23,7 @@ describe('PanelTeams', () => {
|
||||
});
|
||||
|
||||
it('renders with no team to display', async () => {
|
||||
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
|
||||
count: 0,
|
||||
results: [],
|
||||
});
|
||||
fetchMock.mock(`end:/teams/?ordering=-created_at`, []);
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -40,16 +37,13 @@ describe('PanelTeams', () => {
|
||||
});
|
||||
|
||||
it('renders an empty team', async () => {
|
||||
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchMock.mock(`end:/teams/?ordering=-created_at`, [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -59,21 +53,18 @@ describe('PanelTeams', () => {
|
||||
});
|
||||
|
||||
it('renders a team with only 1 member', async () => {
|
||||
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [
|
||||
{
|
||||
id: '1',
|
||||
role: 'owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchMock.mock(`end:/teams/?ordering=-created_at`, [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [
|
||||
{
|
||||
id: '1',
|
||||
role: 'owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -83,25 +74,22 @@ describe('PanelTeams', () => {
|
||||
});
|
||||
|
||||
it('renders a non-empty team', () => {
|
||||
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [
|
||||
{
|
||||
id: '1',
|
||||
role: 'admin',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'member',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchMock.mock(`end:/teams/?ordering=-created_at`, [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Team 1',
|
||||
accesses: [
|
||||
{
|
||||
id: '1',
|
||||
role: 'admin',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'member',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<TeamList />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -109,7 +97,7 @@ describe('PanelTeams', () => {
|
||||
});
|
||||
|
||||
it('renders the error', async () => {
|
||||
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
|
||||
fetchMock.mock(`end:/teams/?ordering=-created_at`, {
|
||||
status: 500,
|
||||
});
|
||||
|
||||
@@ -125,10 +113,7 @@ describe('PanelTeams', () => {
|
||||
});
|
||||
|
||||
it('renders with team panel open', async () => {
|
||||
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
|
||||
count: 1,
|
||||
results: [],
|
||||
});
|
||||
fetchMock.mock(`end:/teams/?ordering=-created_at`, []);
|
||||
|
||||
render(<Panel />, { wrapper: AppWrapper });
|
||||
|
||||
@@ -140,10 +125,7 @@ describe('PanelTeams', () => {
|
||||
});
|
||||
|
||||
it('closes and opens the team panel', async () => {
|
||||
fetchMock.get(`end:/teams/?page=1&ordering=-created_at`, {
|
||||
count: 1,
|
||||
results: [],
|
||||
});
|
||||
fetchMock.get(`end:/teams/?ordering=-created_at`, []);
|
||||
|
||||
render(<Panel />, { wrapper: AppWrapper });
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { InfiniteScroll } from '@/components/InfiniteScroll';
|
||||
import { Team, useTeams } from '@/features/teams/team-management';
|
||||
|
||||
import { useTeamStore } from '../store';
|
||||
@@ -57,39 +56,14 @@ const TeamListState = ({ isLoading, isError, teams }: PanelTeamsStateProps) => {
|
||||
|
||||
export const TeamList = () => {
|
||||
const ordering = useTeamStore((state) => state.ordering);
|
||||
const {
|
||||
data,
|
||||
isError,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTeams({
|
||||
const { data, isError, isLoading } = useTeams({
|
||||
ordering,
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const teams = useMemo(() => {
|
||||
return data?.pages.reduce((acc, page) => {
|
||||
return acc.concat(page.results);
|
||||
}, [] as Team[]);
|
||||
}, [data?.pages]);
|
||||
|
||||
return (
|
||||
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}>
|
||||
<InfiniteScroll
|
||||
hasMore={hasNextPage}
|
||||
isLoading={isFetchingNextPage}
|
||||
next={() => {
|
||||
void fetchNextPage();
|
||||
}}
|
||||
scrollContainer={containerRef.current}
|
||||
as="ul"
|
||||
$margin={{ top: 'none' }}
|
||||
$padding="none"
|
||||
role="listbox"
|
||||
>
|
||||
<TeamListState isLoading={isLoading} isError={isError} teams={teams} />
|
||||
</InfiniteScroll>
|
||||
<TeamListState isLoading={isLoading} isError={isError} teams={data} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,8 +9,8 @@ const Page: NextPageWithLayout = () => {
|
||||
const router = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
router.push(config?.FEATURES.TEAMS ? '/teams/' : '/mail-domains/');
|
||||
}, [config?.FEATURES.TEAMS, router]);
|
||||
router.push(config?.FEATURES.TEAMS_DISPLAY ? '/teams/' : '/mail-domains/');
|
||||
}, [config?.FEATURES.TEAMS_DISPLAY, router]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user