Compare commits

..

2 Commits

Author SHA1 Message Date
Eléonore Voisin a073e491b1 🐛(front) fix missing pagination mail domains
fix position div ref
2025-07-02 14:16:31 +02:00
Eléonore Voisin efd137e328 🐛(front) fix missing pagination mail domains
fix missing pagination mail domains + mailboxes list
2025-07-02 00:03:06 +02:00
269 changed files with 11080 additions and 27272 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '22.x'
node_version: '18.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
@@ -48,7 +48,7 @@ jobs:
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+12 -12
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '22.x'
node_version: '18.x'
with-front-dependencies-installation: true
with-build_mails: true
@@ -20,19 +20,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
# Backend i18n
- name: Set up Python
uses: actions/setup-python@v6
- name: Install Python
uses: actions/setup-python@v3
with:
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
python-version: "3.11"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -45,10 +45,10 @@ jobs:
- name: generate pot files
working-directory: src/backend
run: |
DJANGO_CONFIGURATION=Build uv run python manage.py makemessages -a --keep-pot
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+9 -9
View File
@@ -5,7 +5,7 @@ on:
inputs:
node_version:
required: false
default: '22.x'
default: '18.x'
type: string
with-front-dependencies-installation:
type: boolean
@@ -20,16 +20,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
@@ -37,7 +37,7 @@ jobs:
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -50,10 +50,10 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -61,7 +61,7 @@ jobs:
- name: Setup Node.js
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
@@ -79,7 +79,7 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
+5 -11
View File
@@ -21,7 +21,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -48,7 +48,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -58,10 +58,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
@@ -85,7 +82,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -102,10 +99,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
+34 -47
View File
@@ -13,7 +13,7 @@ jobs:
dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '22.x'
node_version: '18.x'
with-front-dependencies-installation: true
with-build_mails: true
@@ -22,24 +22,20 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep -E "(\bprint\(|\bpprint\()"
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
@@ -49,17 +45,17 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
run: git log --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -73,10 +69,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -86,7 +82,7 @@ jobs:
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
@@ -96,10 +92,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -113,10 +109,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -136,7 +132,7 @@ jobs:
shardTotal: [4]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set services env variables
run: |
@@ -144,7 +140,7 @@ jobs:
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -152,14 +148,14 @@ jobs:
fail-on-cache-miss: true
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v5
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/desk/out/
@@ -220,22 +216,19 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
python-version: '3.11'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: uv run ruff format . --diff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: uv run ruff check .
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: uv run pylint .
run: ~/.local/bin/pylint .
test-back:
runs-on: ubuntu-latest
@@ -269,35 +262,29 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Set up Python
uses: actions/setup-python@v6
- name: Install Python
uses: actions/setup-python@v5
with:
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the dependencies
run: uv sync --locked --all-extras
python-version: '3.11'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages
run: python manage.py compilemessages
- name: Run tests
run: uv run pytest -n 2
run: ~/.local/bin/pytest -n 2
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
+11
View File
@@ -0,0 +1,11 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
+1 -134
View File
@@ -8,122 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.24.0] - 2026-03-24
### Fixed
- ✨(mailboxes) enforce lowercase on mailboxes
- 🐛(i18n) fix missing translations for status tag labels
- 🚚(route) prioritize mail domains as default landing page
### Changed
- 💄(config) update ui configuration domain modale
- 🍱(static) update logo in invitation email template #1085
- ✨(uiV2) use Lasuite UI kit, new layout
## [1.23.1] - 2026-02-16
- ✨(invitations) refresh expired invitations
## [1.23.0] - 2026-02-12
### Added
- ✨(demo) add aliases to demo #1050
- ✨(front) add icon to button to configure a domain
- ✨(datagrid) add sort to mailboxes list + mail domain list
- ✨(invitations) allow delete invitations mails domains access by an admin
- ✨(front) delete invitations mails domains access
- ✨(front) add show invitations mails domains access #1040
- ✨(invitations) can delete domain invitations
### Fixed
- 🐛(domains) fix attemps to send invitations to existing users #953
### Changed
- 🚸(email) we should ignore case when looking for existing emails #1056
- 🏗️(core) migrate from pip to uv
- ✨(front) add show invitations mails domains access #1040
## [1.22.2] - 2026-01-26
### Fixed
- 🐛(aliases) authorize special domain devnull in alias destinations #1029
## [1.22.1] - 2026-01-21
- 🔒️(organization) the first user is not admin #776
- 🐛(admin) fix broken alias import #1021
## [1.22.0] - 2026-01-19
### Added
- ✨(front) create, manage & delete aliases
- ✨(domains) alias sorting and admin
- ✨(aliases) delete all aliases in one call #1002
### Fixed
- 🔒️(security) upgrade python version to fix vulnerability #1010
- 🐛(dimail) ignore oxadmin when importing mailboxes from dimail #986
- ✨(aliases) fix deleting single aliases #1002
### Changed
- 🐛(dimail) allow mailboxes and aliases to have the same local part #986
### Removed
- 🔥(plugins) remove CommuneCreation plugin
## [1.21.0] - 2025-12-05
- ✨(aliases) import existing aliases from dimail
- 🛂(permissions) return 404 to users with no access to domain #985
- ✨(aliases) can create, list and delete aliases #974
## [1.20.0] - 2025-10-22
- ✨(models) impose uniqueness on display name, to match ox's constraint
- 🐛(dimail) catch duplicate displayname error
- ✨(mailbox) synchronize password of newly created mailbox with Dimail's
## [1.19.1] - 2025-09-19
- 🐛(fix) add enabled update your mailbox
## [1.19.0] - 2025-09-03
- ✨(front) add modal update mailboxes #954
### Added
- ✨(api) update mailboxes #934
- ✨(api) give update rights to domain viewer on own mailbox #934
### Fixed
- 🐛(dimail) grab duplicate displayname error #961
### Changed
- 💥(sentry) remove `DJANGO_` before Sentry DSN env variable #957
## [1.18.2] - 2025-07-03
### Fixed
- 🐛(front) fix missing pagination mail domains #950
## [1.18.1] - 2025-07-02
### Fixed
- 🐛(front) fix missing pagination on mail domains #946
## [1.18.0] - 2025-06-30
### Added
- 🐛(front) fix missing pagination mail domains
@@ -136,10 +20,6 @@ and this project adheres to
- 🧑‍💻(docker) split frontend to another file #924
### Fixed
- 🐛(webhook) handle user on different home server than room server
## [1.17.0] - 2025-06-11
### Added
@@ -526,20 +406,7 @@ 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/suitenumerique/people/compare/v1.24.0...main
[1.24.0]: https://github.com/suitenumerique/people/releases/v1.24.0
[1.23.1]: https://github.com/suitenumerique/people/releases/v1.23.1
[1.23.0]: https://github.com/suitenumerique/people/releases/v1.23.0
[1.22.2]: https://github.com/suitenumerique/people/releases/v1.22.2
[1.22.1]: https://github.com/suitenumerique/people/releases/v1.22.1
[1.22.0]: https://github.com/suitenumerique/people/releases/v1.22.0
[1.21.0]: https://github.com/suitenumerique/people/releases/v1.21.0
[1.20.0]: https://github.com/suitenumerique/people/releases/v1.20.0
[1.19.1]: https://github.com/suitenumerique/people/releases/v1.19.1
[1.19.0]: https://github.com/suitenumerique/people/releases/v1.19.0
[1.18.2]: https://github.com/suitenumerique/people/releases/v1.18.2
[1.18.1]: https://github.com/suitenumerique/people/releases/v1.18.1
[1.18.0]: https://github.com/suitenumerique/people/releases/v1.18.0
[unreleased]: https://github.com/suitenumerique/people/compare/v1.17.0...main
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
[1.15.0]: https://github.com/suitenumerique/people/releases/v1.15.0
+25 -37
View File
@@ -1,7 +1,10 @@
# Django People
# ---- base image to inherit from ----
FROM python:3.14.2-alpine 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 setuptools
# Upgrade system packages to install security updates
RUN apk update && \
@@ -10,30 +13,17 @@ RUN apk update && \
# ---- Back-end builder image ----
FROM base AS back-builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
WORKDIR /builder
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
# Copy required python dependencies
COPY ./src/backend /builder
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
RUN mkdir /install && \
pip install --prefix=/install .
# ---- mails ----
FROM node:22 AS mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -52,13 +42,14 @@ RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy people application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
@@ -89,18 +80,14 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy the application from the builder
COPY --from=back-builder /app /app
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy people application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Ensure the uv venv is used at runtime
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
@@ -115,9 +102,10 @@ USER root:root
# Install psql
RUN apk add postgresql-client
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.10,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Uninstall people and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y people
RUN pip install -e .[dev]
# Restore the un-privileged user running the application
ARG DOCKER_USER
+8 -9
View File
@@ -107,8 +107,7 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev frontend-dev
@$(COMPOSE) build dimail
@$(COMPOSE) build app-dev
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@@ -124,7 +123,7 @@ run: ## start the wsgi (production) and servers with production Docker images
.PHONY: run
run-dev: ## start the servers in development mode (watch) Docker images
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
@$(COMPOSE) up --force-recreate --detach app-dev frontend-dev celery-dev celery-beat-dev nginx maildev
.PHONY: run-dev
status: ## an alias for "docker compose ps"
@@ -215,7 +214,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore=".venv/**/*"
@$(MANAGE) compilemessages --ignore="venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@@ -241,21 +240,21 @@ resetdb: ## flush database and create a superuser "admin"
.PHONY: resetdb
env.d/development/common:
cp --update=none env.d/development/common.dist env.d/development/common
cp -n env.d/development/common.dist env.d/development/common
env.d/development/france:
cp --update=none env.d/development/france.dist env.d/development/france
cp -n env.d/development/france.dist env.d/development/france
env.d/development/postgresql:
cp --update=none env.d/development/postgresql.dist env.d/development/postgresql
cp -n env.d/development/postgresql.dist env.d/development/postgresql
env.d/development/kc_postgresql:
cp --update=none env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
# -- Internationalization
env.d/development/crowdin:
cp --update=none env.d/development/crowdin.dist env.d/development/crowdin
cp -n env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from Crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
+1 -1
View File
@@ -1,6 +1,6 @@
# People
People is an application to handle users and teams, and distribute permissions across [La Suite](https://lasuite.numerique.gouv.fr/).
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
It is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).
+4 -4
View File
@@ -42,11 +42,12 @@ services:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_healthy
restart: true
dimail:
condition: service_started
maildev:
condition: service_started
redis:
@@ -118,7 +119,6 @@ services:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_healthy
@@ -199,7 +199,7 @@ services:
working_dir: /app
node:
image: node:22
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -259,7 +259,7 @@ services:
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.5.2
image: registry.mim-libre.fr/dimail/dimail-api:v0.2.11
pull_policy: always
environment:
DIMAIL_MODE: FAKE
+23 -27
View File
@@ -17,52 +17,48 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
This script will ask you for the version you want to release and the kind of release (patch, minor, major). It will then:
- Create a new branch named: `release/4.18.1`.
1. Create a new branch named: `release/4.18.1`.
- Bump the release number for backend and frontend project.
2. Bump the release number for backend and frontend project.
- Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
- Commit your changes with the following format: 🔖 release emoji, type of release (patch/minor/patch) and release version:
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) release version 4.18.1
```
- Triggers Crowdin translation action and open related PR
3. Open a pull request ask you to wait for an approval from your peers and merge it.
- Open release PR. Wait for an approval from your peers and merge it.
> [!NOTE]
> It also open the PR for pre-prod deployment, see following section.
3. Following release script instructions,
- merge translation and release pulls requests
- tag and push your commit:
4. Ask you to tag and push your commit:
```bash
git tag v4.18.1 && git push origin tag v4.18.1
```
This triggers the CI building new Docker images. You can ensure images were successfully built on Docker Hub [here for back-end](https://hub.docker.com/r/lasuite/people-backend/tags) and [here for front-end](https://hub.docker.com/r/lasuite/people-frontend/tags).
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
5. Ensure the new [backend](https://hub.docker.com/r/lasuite/people-backend/tags) and [frontend](https://hub.docker.com/r/lasuite/people-frontend/tags) image tags are on Docker Hub.
# Deploying
6. Create a PR on the [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement) repository to bump the preprod version.
## Staging
7. The release is now done!
The `staging` platform is deployed automatically with every update of the `main` branch.
# Deploying
## Pre-prod and production
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
If you used the release script and had permission to push on [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement), a deployment branch has been created. You can skip step 1.
Making a new release doesn't publish it automatically in production.
Otherwise, for manual preprod and for production deployments :
1. Bump tag version for both front-end and back-end images in the `.gotmpl` file located in `manifests/<your-product>/env.d/<your-environment>/`,
2. Add optional new secrets and variables, if applicable
3. Create a pull request
4. Submit to approval and merge PR
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
The release is now done! 🎉
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
+1
View File
@@ -1 +1,2 @@
INSTALLED_PLUGINS=plugins.la_suite
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
+1
View File
@@ -0,0 +1 @@
"""People custom admin site."""
+9
View File
@@ -0,0 +1,9 @@
"""Custom Django admin site application configuration."""
from django.contrib.admin.apps import AdminConfig
class PeopleAdminConfig(AdminConfig):
"""Declare our custom Django admin site."""
default_site = "admin.sites.PeopleAdminSite"
+15
View File
@@ -0,0 +1,15 @@
"""Custom Django admin site for the People app."""
from django.conf import settings
from django.contrib import admin
class PeopleAdminSite(admin.AdminSite):
"""People custom admin site."""
def each_context(self, request):
"""Add custom context to the admin site."""
return super().each_context(request) | {
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
}
@@ -0,0 +1,13 @@
{% extends "admin/base_site.html" %}
{% block extrastyle %}{{ block.super }}
<style>
html[data-theme="light"], :root {
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
}
ul.messagelist li.info {
background-color: #EEEEEE;
}
</style>
{% endblock %}
-1
View File
@@ -621,7 +621,6 @@ class StatView(views.APIView):
status=enums.MailDomainStatusChoices.ENABLED
).count(),
"mailboxes": domains_models.Mailbox.objects.count(),
"aliases": domains_models.Alias.objects.count(),
}
return response.Response(context)
@@ -17,6 +17,8 @@ from core.models import (
AccountService,
Contact,
Organization,
OrganizationAccess,
OrganizationRoleChoices,
)
logger = logging.getLogger(__name__)
@@ -129,6 +131,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
user = super().create_user(claims | {"organization": organization})
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,
)
# Initiate the user's profile
Contact.objects.create(
owner=user,
-18
View File
@@ -1,18 +0,0 @@
"""
Custom exceptions for mailbox manager app
"""
from django.utils.translation import gettext_lazy as _
from rest_framework import status
from rest_framework.exceptions import APIException
class EmailAlreadyKnownException(APIException):
"""Exception raised when trying to create a user with an already existing email address."""
status_code = status.HTTP_201_CREATED
default_detail = _(
"Email already known. Invitation not sent but access created instead."
)
default_code = "email already known"
+1
View File
@@ -1,3 +1,4 @@
# ruff: noqa: S311
"""
Core application factories
"""
@@ -11,7 +11,7 @@ def update_team_paths(apps, schema_editor):
steplen = 5
# Initialize NumConv with the specified custom alphabet
converter = NumConv(alphabet)
converter = NumConv(len(alphabet), alphabet)
nodes = Team.objects.all().order_by("created_at")
for i, node in enumerate(nodes, 1):
+9 -13
View File
@@ -32,7 +32,6 @@ from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
from core.exceptions import EmailAlreadyKnownException
from core.plugins.registry import registry as plugin_hooks_registry
from core.utils.webhooks import webhooks_synchronizer
from core.validators import get_field_validators_from_setting
@@ -814,7 +813,7 @@ class Team(MP_Node, BaseModel):
except AttributeError:
try:
role = self.accesses.filter(user=user).values("role")[0]["role"]
except TeamAccess.DoesNotExist, IndexError:
except (TeamAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
@@ -901,7 +900,7 @@ class TeamAccess(BaseModel):
role = self._meta.model.objects.filter(
team=self.team_id, user=user
).values("role")[0]["role"]
except self._meta.model.DoesNotExist, IndexError:
except (self._meta.model.DoesNotExist, IndexError):
role = None
is_team_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
@@ -997,22 +996,19 @@ class BaseInvitation(BaseModel):
super().clean()
# Check if a user already exists for the provided email
if User.objects.filter(email__iexact=self.email).exists():
raise EmailAlreadyKnownException
def refresh(self):
"""A simple way to refresh invitation and move expiration date."""
self.clean()
self.updated_at = timezone.now()
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
if not self.updated_at:
if not self.created_at:
return None
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.updated_at + validity_duration)
return timezone.now() > (self.created_at + validity_duration)
def _get_mail_subject(self):
"""Get the subject of the invitation."""
@@ -1100,7 +1096,7 @@ class Invitation(BaseInvitation):
role = self.team.accesses.filter(user=user).values("role")[0][
"role"
]
except self._meta.model.DoesNotExist, IndexError:
except (self._meta.model.DoesNotExist, IndexError):
role = None
can_delete = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
+1 -2
View File
@@ -18,7 +18,6 @@ class BasePluginAppConfigMixIn:
Initialize the hooks registry when the application is ready.
This is called by Django when the application is loaded.
"""
# pylint: disable=import-outside-toplevel
from .registry import registry # noqa: PLC0415
from .registry import registry # pylint: disable=import-outside-toplevel
registry.register_app(self.name) # pylint: disable=no-member
+1 -1
View File
@@ -9,7 +9,7 @@ plugins_urlpatterns = []
for app in settings.INSTALLED_PLUGINS:
try:
plugins_urlpatterns.append(path("", include(f"{app}.urls")))
except ImportError, AttributeError:
except (ImportError, AttributeError):
# Skip if app doesn't have urls.py
continue
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -100,7 +100,7 @@ def test_authentication_getter_existing_user_change_fields(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(4):
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -160,8 +160,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(5):
# 5 = user by email + user by sub + update sub + 2 from django-lasuite
with django_assert_num_queries(3): # user by email + user by sub + update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -391,8 +390,6 @@ def test_authentication_getter_new_user_with_registration_id_new_organization(
assert user.organization.domain_list == expected_domain_list
assert user.organization.registration_id_list == expected_registration_id_list
assert models.OrganizationAccess.objects.filter(user=user).exists() is False
def test_authentication_getter_existing_user_via_email_update_organization(
django_assert_num_queries, monkeypatch
+2 -50
View File
@@ -3,54 +3,6 @@
from rest_framework import status
# SEARCH
def mock_search_empty():
"""Mock response when no Matrix user has been found through search."""
return {
"message": {"limited": "false", "results": []},
"status_code": status.HTTP_200_OK,
}
def mock_search_successful(user):
"""Mock response when exactly one user has been found through search."""
return {
"message": {
"limited": "false",
"results": [
{
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"display_name": f"@{user.name} [Fake]",
"avatar_url": "null",
},
],
},
"status_code": status.HTTP_200_OK,
}
def mock_search_successful_multiple(user):
"""Mock response when more than one user has been found through search."""
return {
"message": {
"limited": "false",
"results": [
{
"user_id": f"@{user.email.replace('@', '-')}:user_server1.com",
"display_name": f"@{user.name} [Fake]",
"avatar_url": "null",
},
{
"user_id": f"@{user.email.replace('@', '-')}:user_server2.com",
"display_name": f"@{user.name} [Other Fake]",
"avatar_url": "null",
},
],
},
"status_code": status.HTTP_200_OK,
}
# JOIN ROOMS
def mock_join_room_successful(room_id):
"""Mock response when succesfully joining room. Same response if already in room."""
@@ -87,7 +39,7 @@ def mock_invite_user_already_in_room(user):
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"{user.email.replace('@', '-')}:home_server.fr is already in the room.",
"error": f"{user.email.replace('@', ':')} is already in the room.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
@@ -104,7 +56,7 @@ def mock_kick_user_forbidden(user):
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"You cannot kick user @{user.email.replace('@', '-')}.",
"error": f"You cannot kick user @{user.email.replace('@', ':')}.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
@@ -9,7 +9,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.jwk import RSAKey
from joserfc.rfc7518.rsa_key import RSAKey
from jwt.utils import to_base64url_uint
from lasuite.oidc_resource_server.authentication import (
ResourceServerAuthentication,
@@ -236,73 +236,6 @@ def test_api_team_accesses_create__with_scim_webhook():
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__with_matrix_webhook():
"""
If a team has a Matrix webhook, creating a team access should fire a call
with the expected payload.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(
team=team,
url="https://server.fr/#/room/room_id:room_server.fr",
secret="some-secret-you-should-not-store-on-a-postit",
protocol=enums.WebhookProtocolChoices.MATRIX,
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
# Mock successful responses by matrix server
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
content_type="application/json",
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(other_user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert len(responses.calls) == 3
assert (
responses.calls[0].request.url
== "https://room_server.fr/_matrix/client/v3/rooms/room_id:room_server.fr/join"
)
# Payload sent to matrix server
assert webhook.secret in responses.calls[0].request.headers["Authorization"]
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{other_user.email.replace('@', '-')}:user_server.com",
"reason": f"User added to team {webhook.team} on People",
}
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__multiple_webhooks_success(caplog):
"""
When the team has multiple webhooks, creating a team access should fire all the expected calls.
@@ -318,7 +251,7 @@ def test_api_team_accesses_create__multiple_webhooks_success(caplog):
)
webhook_matrix = factories.TeamWebhookFactory(
team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server.fr/",
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
protocol=enums.WebhookProtocolChoices.MATRIX,
secret="yo",
)
@@ -328,40 +261,39 @@ def test_api_team_accesses_create__multiple_webhooks_success(caplog):
client = APIClient()
client.force_login(user)
# Ensure successful response by scim provider using "responses":
responses.patch(
re.compile(r".*/Groups/.*"),
body="{}",
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
# Logger
log_messages = [msg.message for msg in caplog.records]
+8 -10
View File
@@ -11,6 +11,7 @@ from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories as domains_factories
from mailbox_manager import models as domains_models
pytestmark = pytest.mark.django_db
@@ -20,20 +21,18 @@ def test_api_stats__anonymous(django_assert_num_queries):
domains_factories.MailDomainEnabledFactory.create_batch(5)
core_factories.TeamFactory.create_batch(3)
domains_factories.AliasFactory.create_batch(2)
# clear cache to allow stats count
cache.clear()
with django_assert_num_queries(6):
with django_assert_num_queries(5):
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_users": 0,
"mau": 0,
"active_domains": 7,
"active_domains": 5,
"mailboxes": 0,
"teams": 3,
"aliases": 2,
}
# no new request made due to caching
with django_assert_num_queries(0):
@@ -42,10 +41,9 @@ def test_api_stats__anonymous(django_assert_num_queries):
assert response.json() == {
"total_users": 0,
"mau": 0,
"active_domains": 7,
"active_domains": 5,
"mailboxes": 0,
"teams": 3,
"aliases": 2,
}
@@ -60,9 +58,10 @@ def test_api_stats__expected_count():
core_factories.TeamFactory.create_batch(3)
domains_factories.MailDomainFactory.create_batch(1)
enabled_domain, _ = domains_factories.MailDomainEnabledFactory.create_batch(2)
domains_factories.MailboxFactory.create_batch(10, domain=enabled_domain)
domains_factories.AliasFactory.create_batch(2, domain=enabled_domain)
domains_factories.MailDomainEnabledFactory.create_batch(2)
domains_factories.MailboxFactory.create_batch(
10, domain=domains_models.MailDomain.objects.all()[1]
)
# clear cache to allow stats count
cache.clear()
@@ -74,5 +73,4 @@ def test_api_stats__expected_count():
"active_domains": 2,
"mailboxes": 10,
"teams": 3,
"aliases": 2,
}
@@ -171,11 +171,10 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
def test_api_team_invitations__list__anonymous_user():
@@ -16,7 +16,6 @@ from faker import Faker
from freezegun import freeze_time
from core import factories, models
from core.exceptions import EmailAlreadyKnownException
pytestmark = pytest.mark.django_db
@@ -49,17 +48,6 @@ def test_models_invitations_team_required():
factories.InvitationFactory(team=None)
def test_models_invitations_email_case_insensitive_duplicate_check():
"""The email validation should be case-insensitive when checking for existing users."""
# Create a user with a lowercase email
factories.UserFactory(email="john.doe@example.com")
# Try to create an invitation with different case
# This should raise the same exception as if the email was exactly the same
with pytest.raises(EmailAlreadyKnownException):
factories.InvitationFactory(email="John.Doe@Example.COM")
def test_models_invitations_team_should_be_team_instance():
"""The "team" field should be a team instance."""
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
@@ -13,63 +13,11 @@ from rest_framework import status
from core import factories
from core.enums import WebhookProtocolChoices
from core.tests.fixtures import matrix
from core.utils.matrix import MatrixAPIClient
from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db
## SEARCH
@responses.activate
def test_matrix_webhook__search_user_unknown(caplog):
"""When searching for a user in the Matrix federation but cannot find any,
we invite a (future ?) user using user's email and room's server."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server.au",
secret="secret-access-token",
)
client = MatrixAPIClient()
# Mock successful responses
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_empty()["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.get_user_id(user=user, webhook=webhook)
assert response == f"@{user.email.replace('@', '-')}:room_server.au"
@responses.activate
def test_matrix_webhook__search_multiple_ids(caplog):
"""When searching for a user in Matrix federation,
if user directory returns multiple ids, invite the first one."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server.au",
secret="secret-access-token",
)
client = MatrixAPIClient()
# Mock successful responses
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful_multiple(user)["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.get_user_id(user=user, webhook=webhook)
assert response == f"@{user.email.replace('@', '-')}:user_server1.com"
## INVITE
@responses.activate
def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
@@ -90,11 +38,6 @@ def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(error["message"]),
@@ -121,11 +64,6 @@ def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_user_already_in_room(user)["message"]),
@@ -163,11 +101,6 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
@@ -180,8 +113,8 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
assert webhook.secret in headers["Authorization"]
# Check payloads sent to Matrix API
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
assert json.loads(responses.calls[1].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}",
"reason": f"User added to team {webhook.team} on People",
}
@@ -198,7 +131,7 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
@responses.activate
@override_settings(MATRIX_BOT_ACCESS_TOKEN="TCHAP_TOKEN")
@override_settings(TCHAP_ACCESS_TOKEN="TCHAP_TOKEN")
def test_matrix_webhook__override_secret_for_tchap():
"""The user passed to the function should get invited."""
user = factories.UserFactory()
@@ -214,11 +147,6 @@ def test_matrix_webhook__override_secret_for_tchap():
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
@@ -230,9 +158,6 @@ def test_matrix_webhook__override_secret_for_tchap():
headers = responses.calls[0].request.headers
assert "TCHAP_TOKEN" in headers["Authorization"]
# Check correctly inferred base url from room_id
assert "matrix.home_server" in responses.calls[0].request.url
## KICK
@responses.activate
@@ -253,11 +178,6 @@ def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_user_not_in_room()["message"]),
@@ -285,7 +205,7 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server",
url="https://www.matrix.org/#/room/room_id:home_server",
secret="secret-access-token",
)
@@ -294,11 +214,6 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_successful),
@@ -307,8 +222,8 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Check payloads sent to Matrix API
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
assert json.loads(responses.calls[1].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}",
"reason": f"User removed from team {webhook.team} on People",
}
@@ -343,11 +258,6 @@ def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(error["message"]),
+11 -27
View File
@@ -29,11 +29,13 @@ session.mount("https://", adapter)
class MatrixAPIClient:
"""A client to interact with Matrix API"""
secret = settings.TCHAP_ACCESS_TOKEN
def get_headers(self, webhook):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
if "tchap.gouv.fr" in webhook.url:
token = settings.MATRIX_BOT_ACCESS_TOKEN
token = settings.TCHAP_ACCESS_TOKEN
elif webhook.secret:
token = webhook.secret
else:
@@ -45,31 +47,16 @@ class MatrixAPIClient:
"""Returns room id from webhook url."""
room_id = webhook_url.split("/room/")[1]
base_url = room_id.split(":")[1]
if "tchap.gouv.fr" in webhook_url and "i.tchap.gouv.fr" not in webhook_url:
if "tchap.gouv.fr" in webhook_url:
base_url = f"matrix.{base_url}"
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
def get_user_id(self, user, webhook):
def get_user_id(self, user):
"""Returns user id from email."""
if user.email is None:
raise ValueError("You must first set an email for the user.")
if settings.MATRIX_BASE_HOME_SERVER:
home_server = settings.MATRIX_BASE_HOME_SERVER
search = session.post(
f"{home_server}/_matrix/client/v3/user_directory/search",
json={"search_term": f"@{user.email.replace('@', '-')}"},
headers=self.get_headers(webhook),
verify=True,
timeout=3,
)
results = search.json()["results"]
if len(results) > 0:
return results[0]["user_id"]
# try and invite unknown user using room home server
room_home_server = webhook.url.split(":")[2]
return f"@{user.email.replace('@', '-')}:{room_home_server}"
return f"@{user.email.replace('@', ':')}"
def join_room(self, webhook):
"""Accept invitation to the room. As of today, it is a mandatory step
@@ -78,7 +65,7 @@ class MatrixAPIClient:
f"{self._get_room_url(webhook.url)}/join",
json={},
headers=self.get_headers(webhook),
verify=True,
verify=False,
timeout=3,
)
@@ -91,11 +78,8 @@ class MatrixAPIClient:
webhook.url,
)
return join_response, False
logger.info(
"Succesfully joined room",
)
user_id = self.get_user_id(user, webhook)
user_id = self.get_user_id(user)
response = session.post(
f"{self._get_room_url(webhook.url)}/invite",
json={
@@ -103,7 +87,7 @@ class MatrixAPIClient:
"reason": f"User added to team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=True,
verify=False,
timeout=3,
)
@@ -128,7 +112,7 @@ class MatrixAPIClient:
)
return join_response, False
user_id = self.get_user_id(user, webhook)
user_id = self.get_user_id(user)
response = session.post(
f"{self._get_room_url(webhook.url)}/kick",
json={
@@ -136,7 +120,7 @@ class MatrixAPIClient:
"reason": f"User removed from team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=True,
verify=False,
timeout=3,
)
+1
View File
@@ -32,6 +32,7 @@ class WebhookClient:
response, webhook_succeeded = self._get_response_and_status(
name, webhook, user
)
if response is not None:
extra = {"response": response.content}
# pylint: disable=no-member
+1 -3
View File
@@ -4,7 +4,5 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 10,
"mailboxes_per_domain": 2,
"aliases_per_domain": 2,
"domains": 20,
}
@@ -181,7 +181,7 @@ def create_oidc_people_idp_client_user():
)
def create_demo(stdout): # pylint: disable=too-many-branches too-many-statements too-many-locals # noqa: PLR0912, PLR0915
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.
@@ -288,12 +288,14 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
)
queue.flush()
domains = mailbox_models.MailDomain.objects.all()
with Timeit(stdout, "Creating accesses to domains"):
for domain in domains:
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
queue.push(
mailbox_models.MailDomainAccess(
domain=domain,
domain_id=domain_id,
user_id=random.choice(users_ids),
role=models.RoleChoices.OWNER,
)
@@ -301,40 +303,6 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
queue.flush()
with Timeit(stdout, "Creating mailboxes"):
for domain in domains:
for i in range(defaults.NB_OBJECTS["mailboxes_per_domain"]):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}{i}"
queue.push(
mailbox_models.Mailbox(
first_name=first_name,
last_name=last_name,
local_part=local_part,
domain=domain,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
dn_email=local_part,
)
)
queue.flush()
with Timeit(stdout, "Creating aliases"):
for domain in domains:
for _i in range(defaults.NB_OBJECTS["aliases_per_domain"]):
queue.push(
mailbox_models.Alias(
local_part=fake.word(),
destination=fake.email(),
domain=domain,
)
)
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
@@ -369,7 +337,7 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
queue.push(user_with_mail)
queue.push(
mailbox_models.MailDomainAccess(
domain=domains[0],
domain_id=domains_ids[0],
user_id=user_with_mail.pk,
role=role,
)
@@ -395,7 +363,7 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
)
queue.push(
mailbox_models.MailDomainAccess(
domain=domains[0],
domain_id=domains_ids[0],
user_id=team_mail_user.pk,
role=domain_role,
)
@@ -403,62 +371,17 @@ def create_demo(stdout): # pylint: disable=too-many-branches too-many-statement
queue.flush()
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=enabled_domain,
email="people@people.world",
role=MailDomainRoleChoices.ADMIN,
)
# Many objects domain
many_objects_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="many-objects-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@mbd.com",
)
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=many_objects_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=many_objects_domain,
email="people@people.world",
role=MailDomainRoleChoices.OWNER,
)
for _i in range(30):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}"
mailbox_models.Mailbox.objects.create(
domain=many_objects_domain,
first_name=first_name,
last_name=last_name,
local_part=local_part,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
password=make_password(None), # unusable password
dn_email=f"{local_part}@{many_objects_domain}",
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
)
mailbox_models.Alias.objects.create(
local_part=fake.word(),
destination=fake.email(),
domain=many_objects_domain,
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
# OIDC configuration
@@ -19,9 +19,7 @@ TEST_NB_OBJECTS = {
"users": 100,
"teams": 100,
"max_users_per_team": 5,
"domains": 10,
"mailboxes_per_domain": 2,
"aliases_per_domain": 2,
"domains": 100,
}
@@ -35,38 +33,25 @@ def test_commands_create_demo(settings):
call_command("create_demo")
# Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
# 3 users with team rights
# 3 users with domain rights
# 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"]
# nb_domains + example.com + enabled + many-boxed-domain
assert (
mailbox_models.MailDomain.objects.count()
== TEST_NB_OBJECTS["domains"] + 1 + 1 + 1
mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1 + 1
)
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights
# 2 domains for E2E mail owner user
# 1 domain for E2E mail owner user
assert (
mailbox_models.MailDomainAccess.objects.count()
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 2
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 1
)
# TEST_NB_OBJECTS["domains"]*TEST_NB_OBJECTS["mailboxes_per_domain"] = 20
# + 30 in the many-object-domain
# + 1 mailbox for user-e2e@example.com
# = 51
assert mailbox_models.Mailbox.objects.count() == 51
# TEST_NB_OBJECTS["domains"]*TEST_NB_OBJECTS["mailboxes_per_alias"] = 20
# + 30 in the many-object-domain
assert mailbox_models.Alias.objects.count() == 50
def test_commands_createsuperuser():
"""
+317 -327
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-13 15:21+0000\n"
"PO-Revision-Date: 2026-03-24 08:54\n"
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
"PO-Revision-Date: 2025-06-11 09:29\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,336 +17,559 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: core/admin.py:62
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Personal info"
msgstr ""
#: core/admin.py:73
#: build/lib/core/admin.py:73 core/admin.py:73
msgid "Permissions"
msgstr ""
#: core/admin.py:85
#: build/lib/core/admin.py:85 core/admin.py:85
msgid "Important dates"
msgstr ""
#: core/admin.py:124
#: build/lib/core/admin.py:124 core/admin.py:124
msgid "User"
msgstr ""
#: core/admin.py:226
#: build/lib/core/admin.py:226 core/admin.py:226
msgid "Run post creation plugins"
msgstr ""
#: core/admin.py:234
#: build/lib/core/admin.py:234 core/admin.py:234
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
#: core/apps.py:65
#: build/lib/core/apps.py:65 core/apps.py:65
msgid "People core application"
msgstr ""
#: core/authentication/backends.py:102
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:122
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: core/authentication/backends.py:170 core/authentication/backends.py:172
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
msgid "Invalid authorization header."
msgstr ""
#: core/authentication/backends.py:177
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
msgid "Invalid api key."
msgstr ""
#: core/enums.py:24
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Failure"
msgstr ""
#: core/enums.py:25 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
#: build/lib/core/enums.py:25 build/lib/mailbox_manager/enums.py:21
#: build/lib/mailbox_manager/enums.py:31 core/enums.py:25
#: mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr ""
#: core/enums.py:26
#: build/lib/core/enums.py:26 core/enums.py:26
msgid "Success"
msgstr ""
#: core/exceptions.py:16
msgid "Email already known. Invitation not sent but access created instead."
msgstr ""
#: core/models.py:77
#: build/lib/core/models.py:76 core/models.py:76
msgid "Member"
msgstr ""
#: core/models.py:78 core/models.py:90 mailbox_manager/enums.py:14
#: build/lib/core/models.py:77 build/lib/core/models.py:89
#: build/lib/mailbox_manager/enums.py:14 core/models.py:77 core/models.py:89
#: mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
#: core/models.py:79 mailbox_manager/enums.py:15
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
#: core/models.py:78 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: core/models.py:102
#: build/lib/core/models.py:101 core/models.py:101
msgid "id"
msgstr ""
#: core/models.py:103
#: build/lib/core/models.py:102 core/models.py:102
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:109
#: build/lib/core/models.py:108 core/models.py:108
msgid "created at"
msgstr ""
#: core/models.py:110
#: build/lib/core/models.py:109 core/models.py:109
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:115
#: build/lib/core/models.py:114 core/models.py:114
msgid "updated at"
msgstr ""
#: core/models.py:116
#: build/lib/core/models.py:115 core/models.py:115
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:155
#: build/lib/core/models.py:154 core/models.py:154
msgid "full name"
msgstr ""
#: core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "short name"
msgstr ""
#: core/models.py:159
#: build/lib/core/models.py:158 core/models.py:158
msgid "notes"
msgstr ""
#: core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "contact information"
msgstr ""
#: core/models.py:162
#: build/lib/core/models.py:161 core/models.py:161
msgid "A JSON object containing the contact information"
msgstr ""
#: core/models.py:176
#: build/lib/core/models.py:175 core/models.py:175
msgid "contact"
msgstr ""
#: core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "contacts"
msgstr ""
#: core/models.py:251 core/models.py:365 core/models.py:511 core/models.py:1125
#: mailbox_manager/models.py:56
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1106
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
msgid "name"
msgstr ""
#: core/models.py:253
#: build/lib/core/models.py:252 core/models.py:252
msgid "audience id"
msgstr ""
#: core/models.py:258
#: build/lib/core/models.py:257 core/models.py:257
msgid "service provider"
msgstr ""
#: core/models.py:259
#: build/lib/core/models.py:258 core/models.py:258
msgid "service providers"
msgstr ""
#: core/models.py:373
#: build/lib/core/models.py:372 core/models.py:372
msgid "registration ID list"
msgstr ""
#: core/models.py:380
#: build/lib/core/models.py:379 core/models.py:379
msgid "domain list"
msgstr ""
#: core/models.py:387
#: build/lib/core/models.py:386 core/models.py:386
msgid "metadata"
msgstr ""
#: core/models.py:388
#: build/lib/core/models.py:387 core/models.py:387
msgid "A JSON object containing the organization metadata"
msgstr ""
#: core/models.py:398 core/models.py:536
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
#: core/models.py:535
msgid "active"
msgstr ""
#: core/models.py:404
#: build/lib/core/models.py:403 core/models.py:403
msgid "organization"
msgstr ""
#: core/models.py:405
#: build/lib/core/models.py:404 core/models.py:404
msgid "organizations"
msgstr ""
#: core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
#: core/models.py:496
#: build/lib/core/models.py:495 core/models.py:495
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:502
#: build/lib/core/models.py:501 core/models.py:501
msgid "sub"
msgstr ""
#: core/models.py:504
#: build/lib/core/models.py:503 core/models.py:503
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:510 core/models.py:974
#: build/lib/core/models.py:509 build/lib/core/models.py:958 core/models.py:509
#: core/models.py:958
msgid "email address"
msgstr ""
#: core/models.py:516
#: build/lib/core/models.py:515 core/models.py:515
msgid "language"
msgstr ""
#: core/models.py:517
#: build/lib/core/models.py:516 core/models.py:516
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:523
#: build/lib/core/models.py:522 core/models.py:522
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:526
#: build/lib/core/models.py:525 core/models.py:525
msgid "device"
msgstr ""
#: core/models.py:528
#: build/lib/core/models.py:527 core/models.py:527
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:531
#: build/lib/core/models.py:530 core/models.py:530
msgid "staff status"
msgstr ""
#: core/models.py:533
#: build/lib/core/models.py:532 core/models.py:532
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:539
#: build/lib/core/models.py:538 core/models.py:538
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:558
#: build/lib/core/models.py:557 core/models.py:557
msgid "user"
msgstr ""
#: core/models.py:559
#: build/lib/core/models.py:558 core/models.py:558
msgid "users"
msgstr ""
#: core/models.py:697
#: build/lib/core/models.py:696 core/models.py:696
msgid "Organization/user relation"
msgstr ""
#: core/models.py:698
#: build/lib/core/models.py:697 core/models.py:697
msgid "Organization/user relations"
msgstr ""
#: core/models.py:703
#: build/lib/core/models.py:702 core/models.py:702
msgid "This user is already in this organization."
msgstr ""
#: core/models.py:762
msgid "external_id"
msgstr ""
#: core/models.py:763
msgid "Team external UUID for synchronization with external systems"
msgstr ""
#: core/models.py:788
#: build/lib/core/models.py:774 core/models.py:774
msgid "is visible for all SP"
msgstr ""
#: core/models.py:790
#: build/lib/core/models.py:776 core/models.py:776
msgid "Whether this team is visible to all service providers."
msgstr ""
#: core/models.py:798
#: build/lib/core/models.py:784 core/models.py:784
msgid "Team"
msgstr ""
#: core/models.py:799
#: build/lib/core/models.py:785 core/models.py:785
msgid "Teams"
msgstr ""
#: core/models.py:850
#: build/lib/core/models.py:836 core/models.py:836
msgid "Team/user relation"
msgstr ""
#: core/models.py:851
#: build/lib/core/models.py:837 core/models.py:837
msgid "Team/user relations"
msgstr ""
#: core/models.py:856
#: build/lib/core/models.py:842 core/models.py:842
msgid "This user is already in this team."
msgstr ""
#: core/models.py:942
#: build/lib/core/models.py:931 core/models.py:931
msgid "url"
msgstr ""
#: core/models.py:943
#: build/lib/core/models.py:932 core/models.py:932
msgid "secret"
msgstr ""
#: core/models.py:957
#: build/lib/core/models.py:941 core/models.py:941
msgid "Team webhook"
msgstr ""
#: core/models.py:958
#: build/lib/core/models.py:942 core/models.py:942
msgid "Team webhooks"
msgstr ""
#: core/models.py:1019
#: build/lib/core/models.py:986 core/models.py:986
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Invitation to join La Régie!"
msgstr ""
#: core/models.py:1065
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Team invitation"
msgstr ""
#: core/models.py:1066
#: build/lib/core/models.py:1047 core/models.py:1047
msgid "Team invitations"
msgstr ""
#: core/models.py:1079
#: build/lib/core/models.py:1060 core/models.py:1060
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr ""
#: core/models.py:1127
#: build/lib/core/models.py:1108 core/models.py:1108
msgid "api key"
msgstr ""
#: core/models.py:1132
#: build/lib/core/models.py:1113 core/models.py:1113
msgid "allowed scopes"
msgstr ""
#: core/models.py:1133
#: build/lib/core/models.py:1114 core/models.py:1114
msgid "Allowed scopes for this service"
msgstr ""
#: core/models.py:1138
#: build/lib/core/models.py:1119 core/models.py:1119
msgid "Account service"
msgstr ""
#: core/models.py:1139
#: build/lib/core/models.py:1120 core/models.py:1120
msgid "Account services"
msgstr ""
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
msgid "Import emails from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:42 mailbox_manager/admin.py:42
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:49 mailbox_manager/admin.py:49
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:54 mailbox_manager/admin.py:54
msgid "Check and update status from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:71 mailbox_manager/admin.py:71
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
msgid "Check domains done with success."
msgstr ""
#: build/lib/mailbox_manager/admin.py:85 mailbox_manager/admin.py:85
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
msgid "No domain updated."
msgstr ""
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
msgid "Check domain failed for:"
msgstr ""
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
msgid "Fetch domain expected config from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:133 mailbox_manager/admin.py:133
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
msgid "Send pending mailboxes to dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
#: build/lib/mailbox_manager/enums.py:13 mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
#: build/lib/mailbox_manager/enums.py:22 build/lib/mailbox_manager/enums.py:32
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr ""
#: build/lib/mailbox_manager/enums.py:23 build/lib/mailbox_manager/enums.py:33
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr ""
#: build/lib/mailbox_manager/enums.py:24 build/lib/mailbox_manager/enums.py:34
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr ""
#: build/lib/mailbox_manager/enums.py:25 mailbox_manager/enums.py:25
msgid "Action required"
msgstr ""
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
msgid "[La Suite] Your domain is ready"
msgstr ""
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
msgid "[La Suite] Your domain requires action"
msgstr ""
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
msgid "[La Suite] Your domain has failed"
msgstr ""
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
msgid "support email"
msgstr ""
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
msgid "last check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
msgid "A JSON object containing the last health check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
msgid "expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
msgid "A JSON object containing the expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
msgid "Mail domain"
msgstr ""
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
msgid "Mail domains"
msgstr ""
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
msgid "User/mail domain relation"
msgstr ""
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
msgid "User/mail domain relations"
msgstr ""
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
msgid "local_part"
msgstr ""
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
msgid "secondary email address"
msgstr ""
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
msgid "email"
msgstr ""
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
msgid "Mailbox"
msgstr ""
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
msgid "Mailboxes"
msgstr ""
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
msgid "Mail domain invitation"
msgstr ""
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
msgid "Mail domain invitations"
msgstr ""
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
msgid "Your new mailbox information"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
msgid "Your password has been updated"
msgstr ""
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr ""
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:195
#: core/templates/mail/text/maildomain_action_required.txt:5
msgid "Some actions are required on your domain"
@@ -638,236 +861,3 @@ msgstr ""
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
msgstr ""
#: mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr ""
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr ""
#: mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr ""
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr ""
#: mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr ""
#: mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr ""
#: mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr ""
#: mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr ""
#: mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr ""
#: mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr ""
#: mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr ""
#: mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr ""
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr ""
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr ""
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr ""
#: mailbox_manager/models.py:35
msgid "[La Suite] Your domain is ready"
msgstr ""
#: mailbox_manager/models.py:40
msgid "[La Suite] Your domain requires action"
msgstr ""
#: mailbox_manager/models.py:45
msgid "[La Suite] Your domain has failed"
msgstr ""
#: mailbox_manager/models.py:71
msgid "support email"
msgstr ""
#: mailbox_manager/models.py:75
msgid "last check details"
msgstr ""
#: mailbox_manager/models.py:76
msgid "A JSON object containing the last health check details"
msgstr ""
#: mailbox_manager/models.py:81
msgid "expected config"
msgstr ""
#: mailbox_manager/models.py:82
msgid "A JSON object containing the expected config"
msgstr ""
#: mailbox_manager/models.py:87
msgid "Mail domain"
msgstr ""
#: mailbox_manager/models.py:88
msgid "Mail domains"
msgstr ""
#: mailbox_manager/models.py:204
msgid "User/mail domain relation"
msgstr ""
#: mailbox_manager/models.py:205
msgid "User/mail domain relations"
msgstr ""
#: mailbox_manager/models.py:278
msgid "local_part"
msgstr ""
#: mailbox_manager/models.py:292
msgid "secondary email address"
msgstr ""
#: mailbox_manager/models.py:303
msgid "email"
msgstr ""
#: mailbox_manager/models.py:309
msgid "Mailbox"
msgstr ""
#: mailbox_manager/models.py:310
msgid "Mailboxes"
msgstr ""
#: mailbox_manager/models.py:352
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: mailbox_manager/models.py:411
msgid "Mail domain invitation"
msgstr ""
#: mailbox_manager/models.py:412
msgid "Mail domain invitations"
msgstr ""
#: mailbox_manager/models.py:441
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: mailbox_manager/models.py:485
msgid "destination address"
msgstr ""
#: mailbox_manager/models.py:503
msgid "Alias"
msgstr ""
#: mailbox_manager/models.py:504
msgid "Aliases"
msgstr ""
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr ""
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr ""
#: people/settings.py:159
msgid "English"
msgstr ""
#: people/settings.py:160
msgid "French"
msgstr ""
+318 -328
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-13 15:21+0000\n"
"PO-Revision-Date: 2026-03-24 08:54\n"
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
"PO-Revision-Date: 2025-06-11 09:29\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,336 +17,559 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: core/admin.py:62
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Personal info"
msgstr "Informations personnelles"
#: core/admin.py:73
#: build/lib/core/admin.py:73 core/admin.py:73
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:85
#: build/lib/core/admin.py:85 core/admin.py:85
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:124
#: build/lib/core/admin.py:124 core/admin.py:124
msgid "User"
msgstr "Utilisateur"
#: core/admin.py:226
#: build/lib/core/admin.py:226 core/admin.py:226
msgid "Run post creation plugins"
msgstr "Exécuter les plugins de post-création"
#: core/admin.py:234
#: build/lib/core/admin.py:234 core/admin.py:234
msgid "Post creation plugins have been run for the selected organizations."
msgstr "Les plugins de post-création ont été exécutés pour les organisations sélectionnées."
#: core/apps.py:65
#: build/lib/core/apps.py:65 core/apps.py:65
msgid "People core application"
msgstr "Application cœur de People"
#: core/authentication/backends.py:102
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
msgid "Claims contained no recognizable user identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: core/authentication/backends.py:122
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
msgid "Claims contained no recognizable organization identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: core/authentication/backends.py:170 core/authentication/backends.py:172
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: core/authentication/backends.py:177
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
msgid "Invalid api key."
msgstr "Clé API invalide."
#: core/enums.py:24
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Failure"
msgstr "En échec"
#: core/enums.py:25 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
#: build/lib/core/enums.py:25 build/lib/mailbox_manager/enums.py:21
#: build/lib/mailbox_manager/enums.py:31 core/enums.py:25
#: mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr "En attente"
#: core/enums.py:26
#: build/lib/core/enums.py:26 core/enums.py:26
msgid "Success"
msgstr "Réussi"
#: core/exceptions.py:16
msgid "Email already known. Invitation not sent but access created instead."
msgstr "Adresse email déjà connue. Aucune invitation envoyée mais accès créé à la place."
#: core/models.py:77
#: build/lib/core/models.py:76 core/models.py:76
msgid "Member"
msgstr "Membre"
#: core/models.py:78 core/models.py:90 mailbox_manager/enums.py:14
#: build/lib/core/models.py:77 build/lib/core/models.py:89
#: build/lib/mailbox_manager/enums.py:14 core/models.py:77 core/models.py:89
#: mailbox_manager/enums.py:14
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:79 mailbox_manager/enums.py:15
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
#: core/models.py:78 mailbox_manager/enums.py:15
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:102
#: build/lib/core/models.py:101 core/models.py:101
msgid "id"
msgstr "id"
#: core/models.py:103
#: build/lib/core/models.py:102 core/models.py:102
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: core/models.py:109
#: build/lib/core/models.py:108 core/models.py:108
msgid "created at"
msgstr "créé le"
#: core/models.py:110
#: build/lib/core/models.py:109 core/models.py:109
msgid "date and time at which a record was created"
msgstr "date et heure de création de l'enregistrement"
#: core/models.py:115
#: build/lib/core/models.py:114 core/models.py:114
msgid "updated at"
msgstr "mis à jour le"
#: core/models.py:116
#: build/lib/core/models.py:115 core/models.py:115
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: core/models.py:155
#: build/lib/core/models.py:154 core/models.py:154
msgid "full name"
msgstr "nom complet"
#: core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "short name"
msgstr "nom court"
#: core/models.py:159
#: build/lib/core/models.py:158 core/models.py:158
msgid "notes"
msgstr "notes"
#: core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "contact information"
msgstr "informations de contact"
#: core/models.py:162
#: build/lib/core/models.py:161 core/models.py:161
msgid "A JSON object containing the contact information"
msgstr "Un objet JSON contenant les informations de contact"
#: core/models.py:176
#: build/lib/core/models.py:175 core/models.py:175
msgid "contact"
msgstr "contact"
#: core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "contacts"
msgstr "contacts"
#: core/models.py:251 core/models.py:365 core/models.py:511 core/models.py:1125
#: mailbox_manager/models.py:56
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1106
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
msgid "name"
msgstr "nom"
#: core/models.py:253
#: build/lib/core/models.py:252 core/models.py:252
msgid "audience id"
msgstr "ID d'audience"
#: core/models.py:258
#: build/lib/core/models.py:257 core/models.py:257
msgid "service provider"
msgstr "fournisseur de services"
#: core/models.py:259
#: build/lib/core/models.py:258 core/models.py:258
msgid "service providers"
msgstr "fournisseurs de services"
#: core/models.py:373
#: build/lib/core/models.py:372 core/models.py:372
msgid "registration ID list"
msgstr "liste d'identifiants d'inscription"
#: core/models.py:380
#: build/lib/core/models.py:379 core/models.py:379
msgid "domain list"
msgstr "liste des domaines"
#: core/models.py:387
#: build/lib/core/models.py:386 core/models.py:386
msgid "metadata"
msgstr "métadonnées"
#: core/models.py:388
#: build/lib/core/models.py:387 core/models.py:387
msgid "A JSON object containing the organization metadata"
msgstr "Un objet JSON contenant les métadonnées de l'organisation"
#: core/models.py:398 core/models.py:536
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
#: core/models.py:535
msgid "active"
msgstr "actif"
#: core/models.py:404
#: build/lib/core/models.py:403 core/models.py:403
msgid "organization"
msgstr "organisation"
#: core/models.py:405
#: build/lib/core/models.py:404 core/models.py:404
msgid "organizations"
msgstr "organisations"
#: core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "An organization must have at least a registration ID or a domain."
msgstr "Une organisation doit avoir au moins un ID d'enregistrement ou un domaine."
#: core/models.py:496
#: build/lib/core/models.py:495 core/models.py:495
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des chiffres et des caractères @/./+/-/_."
#: core/models.py:502
#: build/lib/core/models.py:501 core/models.py:501
msgid "sub"
msgstr "sub"
#: core/models.py:504
#: build/lib/core/models.py:503 core/models.py:503
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ seulement."
#: core/models.py:510 core/models.py:974
#: build/lib/core/models.py:509 build/lib/core/models.py:958 core/models.py:509
#: core/models.py:958
msgid "email address"
msgstr "adresse email"
#: core/models.py:516
#: build/lib/core/models.py:515 core/models.py:515
msgid "language"
msgstr "langue"
#: core/models.py:517
#: build/lib/core/models.py:516 core/models.py:516
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: core/models.py:523
#: build/lib/core/models.py:522 core/models.py:522
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:526
#: build/lib/core/models.py:525 core/models.py:525
msgid "device"
msgstr "appareil"
#: core/models.py:528
#: build/lib/core/models.py:527 core/models.py:527
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:531
#: build/lib/core/models.py:530 core/models.py:530
msgid "staff status"
msgstr "statut d'équipe"
#: core/models.py:533
#: build/lib/core/models.py:532 core/models.py:532
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:539
#: build/lib/core/models.py:538 core/models.py:538
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: core/models.py:558
#: build/lib/core/models.py:557 core/models.py:557
msgid "user"
msgstr "utilisateur"
#: core/models.py:559
#: build/lib/core/models.py:558 core/models.py:558
msgid "users"
msgstr "utilisateurs"
#: core/models.py:697
#: build/lib/core/models.py:696 core/models.py:696
msgid "Organization/user relation"
msgstr "Relation organisation/utilisateur"
#: core/models.py:698
#: build/lib/core/models.py:697 core/models.py:697
msgid "Organization/user relations"
msgstr "Relations organisation/utilisateur"
#: core/models.py:703
#: build/lib/core/models.py:702 core/models.py:702
msgid "This user is already in this organization."
msgstr "Cet utilisateur est déjà dans cette organisation."
#: core/models.py:762
msgid "external_id"
msgstr "ID externe"
#: core/models.py:763
msgid "Team external UUID for synchronization with external systems"
msgstr "UUID externe de l'équipe pour la synchronisation avec des systèmes externes"
#: core/models.py:788
#: build/lib/core/models.py:774 core/models.py:774
msgid "is visible for all SP"
msgstr "est visible pour tous les fournisseurs de service"
#: core/models.py:790
#: build/lib/core/models.py:776 core/models.py:776
msgid "Whether this team is visible to all service providers."
msgstr "Si cette équipe est visible pour tous les fournisseurs de services."
#: core/models.py:798
#: build/lib/core/models.py:784 core/models.py:784
msgid "Team"
msgstr "Équipe"
#: core/models.py:799
#: build/lib/core/models.py:785 core/models.py:785
msgid "Teams"
msgstr "Équipes"
#: core/models.py:850
#: build/lib/core/models.py:836 core/models.py:836
msgid "Team/user relation"
msgstr "Relation équipe/utilisateur"
#: core/models.py:851
#: build/lib/core/models.py:837 core/models.py:837
msgid "Team/user relations"
msgstr "Relations équipe/utilisateur"
#: core/models.py:856
#: build/lib/core/models.py:842 core/models.py:842
msgid "This user is already in this team."
msgstr "Cet utilisateur est déjà dans cette équipe."
#: core/models.py:942
#: build/lib/core/models.py:931 core/models.py:931
msgid "url"
msgstr "url"
#: core/models.py:943
#: build/lib/core/models.py:932 core/models.py:932
msgid "secret"
msgstr "secret"
#: core/models.py:957
#: build/lib/core/models.py:941 core/models.py:941
msgid "Team webhook"
msgstr "Webhook d'équipe"
#: core/models.py:958
#: build/lib/core/models.py:942 core/models.py:942
msgid "Team webhooks"
msgstr "Webhooks d'équipe"
#: core/models.py:1019
#: build/lib/core/models.py:986 core/models.py:986
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie !"
#: core/models.py:1065
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Team invitation"
msgstr "Invitation d'équipe"
#: core/models.py:1066
#: build/lib/core/models.py:1047 core/models.py:1047
msgid "Team invitations"
msgstr "Invitations d'équipe"
#: core/models.py:1079
#: build/lib/core/models.py:1060 core/models.py:1060
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr "[La Suite] Vous avez été invité(e) à être %(role)s d'un groupe"
#: core/models.py:1127
#: build/lib/core/models.py:1108 core/models.py:1108
msgid "api key"
msgstr "Clé d'API"
#: core/models.py:1132
#: build/lib/core/models.py:1113 core/models.py:1113
msgid "allowed scopes"
msgstr "périmètres autorisés"
#: core/models.py:1133
#: build/lib/core/models.py:1114 core/models.py:1114
msgid "Allowed scopes for this service"
msgstr "Périmètres d'application autorisés pour ce service"
#: core/models.py:1138
#: build/lib/core/models.py:1119 core/models.py:1119
msgid "Account service"
msgstr "Compte de service"
#: core/models.py:1139
#: build/lib/core/models.py:1120 core/models.py:1120
msgid "Account services"
msgstr "Comptes de service"
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
msgid "Import emails from dimail"
msgstr "Importer les emails depuis dimail"
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:42 mailbox_manager/admin.py:42
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
#: build/lib/mailbox_manager/admin.py:49 mailbox_manager/admin.py:49
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
#: build/lib/mailbox_manager/admin.py:54 mailbox_manager/admin.py:54
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
#: build/lib/mailbox_manager/admin.py:71 mailbox_manager/admin.py:71
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr "- %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
#: build/lib/mailbox_manager/admin.py:85 mailbox_manager/admin.py:85
#, python-format
msgid "Domains updated: %(domains)s"
msgstr "Domaines mis à jour : %(domains)s"
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:133 mailbox_manager/admin.py:133
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
#: build/lib/mailbox_manager/enums.py:13 mailbox_manager/enums.py:13
msgid "Viewer"
msgstr "Lecteur"
#: build/lib/mailbox_manager/enums.py:22 build/lib/mailbox_manager/enums.py:32
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr "Actif"
#: build/lib/mailbox_manager/enums.py:23 build/lib/mailbox_manager/enums.py:33
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En erreur"
#: build/lib/mailbox_manager/enums.py:24 build/lib/mailbox_manager/enums.py:34
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr "Désactivé"
#: build/lib/mailbox_manager/enums.py:25 mailbox_manager/enums.py:25
msgid "Action required"
msgstr "Action requise"
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
msgid "support email"
msgstr "adresse email du support"
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
msgid "last check details"
msgstr "détails de la dernière vérification"
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
msgid "expected config"
msgstr "configuration attendue"
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
msgid "local_part"
msgstr "local_part"
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
msgid "secondary email address"
msgstr "adresse email secondaire"
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
msgid "email"
msgstr "email"
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
msgid "Mailbox"
msgstr "Boîte mail"
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
msgid "Mailboxes"
msgstr "Boîtes mail"
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: build/lib/mailbox_manager/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr "Anglais"
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr "Français"
#: core/templates/mail/html/maildomain_action_required.html:195
#: core/templates/mail/text/maildomain_action_required.txt:5
msgid "Some actions are required on your domain"
@@ -429,7 +652,7 @@ msgstr "Hourra !"
#: core/templates/mail/html/maildomain_enabled.html:206
#, python-format
msgid "Your domain <b>%(name)s</b> can be used now."
msgstr "Votre domaine <b>%(name)s</b> est prêt à être utilisé."
msgstr "Votre domaine <b>%(name)s</b> est prêt à être exploité."
#: core/templates/mail/html/maildomain_enabled.html:211
#: core/templates/mail/html/maildomain_invitation.html:211
@@ -638,236 +861,3 @@ msgstr "Le domaine %(name)s a rencontré une erreur. Tant que cette erreur persi
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
msgstr "Pour en savoir plus : Visitez le site de la Suite numérique [%(link)s] pour découvrir lensemble des outils proposés."
#: mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr "Importer les emails depuis dimail"
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
#: mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
#: mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr "Importer des alias depuis dimail"
#: mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr "Synchronisation réussie pour %(domain)s. %(count_imported)s alias importés : %(aliases)s"
#: mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
#: mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr "- %(domain)s avec le message : %(err)s"
#: mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
#: mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr "Domaines mis à jour : %(domains)s"
#: mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
#: mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
#: mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
#: mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
#: mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
#: mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr "Lecteur"
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr "Actif"
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En erreur"
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr "Désactivé"
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr "Action requise"
#: mailbox_manager/models.py:35
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: mailbox_manager/models.py:40
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: mailbox_manager/models.py:45
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: mailbox_manager/models.py:71
msgid "support email"
msgstr "adresse email du support"
#: mailbox_manager/models.py:75
msgid "last check details"
msgstr "détails de la dernière vérification"
#: mailbox_manager/models.py:76
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: mailbox_manager/models.py:81
msgid "expected config"
msgstr "configuration attendue"
#: mailbox_manager/models.py:82
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: mailbox_manager/models.py:87
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: mailbox_manager/models.py:88
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: mailbox_manager/models.py:204
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: mailbox_manager/models.py:205
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: mailbox_manager/models.py:278
msgid "local_part"
msgstr "local_part"
#: mailbox_manager/models.py:292
msgid "secondary email address"
msgstr "adresse email secondaire"
#: mailbox_manager/models.py:303
msgid "email"
msgstr "email"
#: mailbox_manager/models.py:309
msgid "Mailbox"
msgstr "Boîte mail"
#: mailbox_manager/models.py:310
msgid "Mailboxes"
msgstr "Boîtes mail"
#: mailbox_manager/models.py:352
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: mailbox_manager/models.py:411
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: mailbox_manager/models.py:412
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: mailbox_manager/models.py:441
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: mailbox_manager/models.py:485
msgid "destination address"
msgstr "adresse de destination"
#: mailbox_manager/models.py:503
msgid "Alias"
msgstr "Alias"
#: mailbox_manager/models.py:504
msgid "Aliases"
msgstr "Alias"
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: people/settings.py:159
msgid "English"
msgstr "Anglais"
#: people/settings.py:160
msgid "French"
msgstr "Français"
+5 -58
View File
@@ -10,6 +10,9 @@ from requests import exceptions
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
# Prevent Ruff complaining about mark_safe below
# ruff: noqa: S308
@admin.action(description=_("Import emails from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
@@ -48,51 +51,6 @@ def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disabl
)
@admin.action(description=_("Import aliases from dimail"))
def sync_aliases_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""
Admin action to import existing aliases from dimail.
Checks alias is not a duplicate and that usernames don't clash with existing mailboxes.
"""
excluded_domains = []
client = DimailAPIClient()
for domain in queryset:
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
try:
imported_aliases = client.import_aliases(domain)
except exceptions.HTTPError as err:
messages.error(
request,
_("Synchronisation failed for %(domain)s with message: %(err)s")
% {"domain": domain.name, "err": err},
)
else:
messages.success(
request,
_(
"Synchronisation succeed for %(domain)s.\
Imported %(count_imported)s aliases: %(aliases)s"
)
% {
"domain": domain.name,
"count_imported": len(imported_aliases),
"aliases": ", ".join(imported_aliases),
},
)
if excluded_domains:
messages.warning(
request,
_("Sync require enabled domains. Excluded domains: %(domains)s")
% {"domains": ", ".join(excluded_domains)},
)
@admin.action(description=_("Check and update status from dimail"))
def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to check domain health with dimail and update domain status."""
@@ -236,7 +194,6 @@ class MailDomainAdmin(admin.ModelAdmin):
inlines = (UserMailDomainAccessInline,)
actions = (
sync_mailboxes_from_dimail,
sync_aliases_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
send_pending_mailboxes,
@@ -250,8 +207,8 @@ class MailboxAdmin(UserAdmin):
list_display = ("__str__", "domain", "status", "updated_at")
list_filter = ("status",)
search_fields = ("local_part", "domain__name", "first_name", "last_name")
readonly_fields = ["updated_at"]
search_fields = ("local_part", "domain__name")
readonly_fields = ["updated_at", "local_part", "domain"]
fieldsets = None
add_fieldsets = (
@@ -310,13 +267,3 @@ class MailDomainInvitationAdmin(admin.ModelAdmin):
def is_expired(self, obj):
"""Return the expiration date of the invitation."""
return obj.is_expired
@admin.register(models.Alias)
class AliasAdmin(admin.ModelAdmin):
"""Admin for alias model."""
list_display = ("local_part", "domain", "destination", "updated_at")
list_filter = ("domain",)
search_fields = ("local_part", "domain__name", "destination")
readonly_fields = ["updated_at"]
@@ -3,8 +3,6 @@
from logging import getLogger
from django.contrib.auth.hashers import make_password
from django.core import exceptions as django_exceptions
from django.shortcuts import get_object_or_404
from requests.exceptions import HTTPError
from rest_framework import exceptions, serializers
@@ -31,6 +29,7 @@ class MailboxSerializer(serializers.ModelSerializer):
"secondary_email",
"status",
]
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id", "status"]
def create(self, validated_data):
@@ -46,21 +45,12 @@ class MailboxSerializer(serializers.ModelSerializer):
"password": make_password(None), # generate an unusable password
}
)
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
# send new mailbox request to dimail
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
client = DimailAPIClient()
try:
response = client.create_mailbox(
mailbox, self.context["request"].user.sub
)
except django_exceptions.ValidationError as exc:
mailbox.delete()
raise exc
# send new mailbox request to dimail
response = client.create_mailbox(mailbox, self.context["request"].user.sub)
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox_data = response.json()
mailbox.set_password(mailbox_data["password"])
mailbox.save()
if mailbox.secondary_email:
@@ -77,25 +67,10 @@ class MailboxSerializer(serializers.ModelSerializer):
mailbox,
)
# actually save mailbox on our database
return mailbox
class MailboxUpdateSerializer(MailboxSerializer):
"""A more restrictive serializer when updating mailboxes"""
class Meta:
model = models.Mailbox
fields = [
"id",
"first_name",
"last_name",
"local_part",
"secondary_email",
"status",
]
read_only_fields = ("id", "local_part", "status")
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
@@ -228,10 +203,23 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
"You must set a domain slug in kwargs to create a new domain access."
) from exc
# we're sure that an access exists. otherwise it would have failed at permissions.
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
try:
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
except models.MailDomainAccess.DoesNotExist as exc:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
) from exc
# Authenticated user must be owner or admin of current domain to set new roles
if access.role not in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
)
# only an owner can set an owner role to another user
if (
@@ -289,41 +277,11 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer):
) from exc
domain = models.MailDomain.objects.get(slug=domain_slug)
if not domain.get_abilities(user)["manage_accesses"]:
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this domain."
)
attrs["domain"] = domain
attrs["issuer"] = user
return attrs
class AliasSerializer(serializers.ModelSerializer):
"""Serialize aliases."""
domain = MailDomainSerializer(default="")
class Meta:
model = models.Alias
fields = [
"id",
"local_part",
"destination",
"domain",
]
read_only_fields = ["id", "domain"]
def validate_domain(self, value): # pylint: disable=unused-argument
"""Forcefully set domain field to url domain."""
return get_object_or_404(models.MailDomain, slug=self.context["domain_slug"])
def create(self, validated_data):
"""
Override create function to fire a request to dimail on alias creation.
"""
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
alias = models.Alias(**validated_data)
# send new alias request to dimail
client = DimailAPIClient()
client.create_alias(alias, self.context["request"].user.sub)
return super().create(validated_data)
return None
@@ -1,16 +1,13 @@
"""API endpoints"""
from django.db.models import Q, Subquery
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import exceptions, filters, mixins, status, viewsets
from rest_framework import exceptions, filters, mixins, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from core import models as core_models
from core.api.client.serializers import UserSerializer
from core.exceptions import EmailAlreadyKnownException
from mailbox_manager import enums, models
from mailbox_manager.api import permissions
@@ -43,10 +40,7 @@ class MailDomainViewSet(
Fetch domain status and expected config from dimail.
"""
permission_classes = [
permissions.DomainPermission,
permissions.DomainResourcePermission,
]
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.MailDomainSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "name"]
@@ -118,7 +112,7 @@ class MailDomainAccessViewSet(
Delete targeted domain access
"""
permission_classes = [permissions.DomainResourcePermission]
permission_classes = [permissions.MailDomainAccessRolePermission]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["role", "user__email", "user__name"]
@@ -234,11 +228,9 @@ class MailDomainAccessViewSet(
class MailBoxViewSet(
viewsets.GenericViewSet,
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""MailBox ViewSet
@@ -260,15 +252,9 @@ class MailBoxViewSet(
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/reset/
Send a request to mail-provider to reset password.
PUT /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to update mailbox. Cannot modify domain or local_part.
PATCH /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to partially update mailbox. Cannot modify domain or local_part.
"""
permission_classes = [permissions.DomainResourcePermission]
permission_classes = [permissions.MailBoxPermission]
serializer_class = serializers.MailboxSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["-created_at"]
@@ -281,32 +267,6 @@ class MailBoxViewSet(
return self.queryset.filter(domain__slug=domain_slug)
return self.queryset
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
return context
def get_permissions(self):
"""Add a specific permission for domain viewers to update their own mailbox."""
if self.action in ["list", "retrieve"]:
permission_classes = [permissions.DomainPermission]
elif self.action in ["update", "partial_update"]:
permission_classes = [
permissions.DomainResourcePermission
| permissions.IsMailboxOwnerPermission
]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_class(self):
"""Chooses list or detail serializer according to the action."""
if self.action in {"update", "partial_update"}:
return serializers.MailboxUpdateSerializer
return self.serializer_class
def perform_create(self, serializer):
"""Create new mailbox."""
domain_slug = self.kwargs.get("domain_slug", "")
@@ -350,7 +310,6 @@ class MailDomainInvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to domain management.
@@ -365,17 +324,13 @@ class MailDomainInvitationViewset(
- issuer : User, automatically added from user making query, if allowed
- domain : Domain, automatically added from requested URI
Return a newly created invitation
or an access if email is already linked to an existing user
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/<version>/mail-domains/<domain_slug>/invitations/:<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
permission_classes = [permissions.DomainResourcePermission]
permission_classes = [permissions.AccessPermission]
queryset = (
models.MailDomainInvitation.objects.all()
.select_related("domain")
@@ -412,126 +367,3 @@ class MailDomainInvitationViewset(
)
return queryset
def create(self, request, *args, **kwargs):
"""Attempt to create invitation. If user is already registered,
they don't need an invitation but an access, which we create here."""
email = request.data["email"]
try:
return super().create(request, *args, **kwargs)
except EmailAlreadyKnownException as exc:
user = models.User.objects.get(email__iexact=email)
models.MailDomainAccess.objects.create(
user=user,
domain=models.MailDomain.objects.get(slug=kwargs["domain_slug"]),
role=request.data["role"],
)
raise exc
@action(detail=True, methods=["post"])
def refresh(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""Enable mailbox. Send a request to dimail and change status in our DB"""
invitation = get_object_or_404(models.MailDomainInvitation, id=kwargs["id"])
invitation.refresh()
invitation.email_invitation()
return Response(serializers.MailDomainInvitationSerializer(invitation).data)
class AliasViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for aliases.
GET /api/<version>/mail-domains/<domain_slug>/aliases/
Return list of aliases related to that domain
POST /api/<version>/mail-domains/<domain_slug>/aliases/ with expected data:
- local_part: str
- destination: str
Return a newly created alias
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/<alias_pk>/
Delete targeted alias
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/?local_part=<local_part>/
Delete all aliases of targeted local_part
"""
lookup_field = "pk"
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.AliasSerializer
queryset = models.Alias.objects.all().select_related("domain")
filter_backends = [filters.OrderingFilter]
ordering_fields = ["local_part"]
ordering = ["local_part"]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
if self.action == "list":
# Determine which role the logged-in user has in the domain
user_role_query = models.MailDomainAccess.objects.filter(
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
).values("role")
queryset = (
queryset
# Abilities are computed based on logged-in user's role and
# the user role on each domain access
.annotate(user_role=Subquery(user_role_query)).distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""
Override destroy method to delete specific alias and send request to dimail.
"""
instance = self.get_object()
self.perform_destroy(instance)
client = DimailAPIClient()
dimail_response = client.delete_alias(instance)
if dimail_response.status_code == status.HTTP_404_NOT_FOUND:
return Response(
"Domain out of sync with mailbox provider, please contact our support.",
status=status.HTTP_200_OK,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["DELETE"], detail=False)
def delete(self, request, *args, **kwargs):
"""Bulk delete aliases. Filtering is required and accepted filter is local_part."""
if "local_part" not in self.request.query_params:
return Response(status=status.HTTP_400_BAD_REQUEST)
local_part = self.request.query_params["local_part"]
queryset = self.get_queryset().filter(
local_part=local_part
) # Manually call get_queryset to filter by domain and role
if not queryset:
raise Http404("No Alias matches the given query.")
# view is bounded to a domain, fetch is from the queryset to spare a dedicated DB request"
domain_name = queryset[0].domain.name
queryset.delete()
client = DimailAPIClient()
client.delete_multiple_alias(local_part, domain_name)
return Response(status=status.HTTP_204_NO_CONTENT)
+13 -37
View File
@@ -1,40 +1,11 @@
"""Permission handlers for the People mailbox manager app."""
from django.shortcuts import get_object_or_404
from rest_framework import permissions
from core.api.permissions import IsAuthenticated
from core.api import permissions as core_permissions
from mailbox_manager import models
class DomainPermission(IsAuthenticated):
"""Permission class to manage mailboxes and aliases for a mail domain"""
def has_permission(self, request, view):
"""Check permission based on domain."""
if not super().has_permission(request, view):
return False
if not view.kwargs.get("domain_slug"):
return True
domain = get_object_or_404(
models.MailDomain,
slug=view.kwargs.get("domain_slug", ""),
accesses__user=request.user,
)
abilities = domain.get_abilities(request.user)
if request.method.lower() == "delete":
return abilities.get("manage_accesses", False)
return abilities.get(request.method.lower(), False)
class DomainResourcePermission(DomainPermission):
class AccessPermission(core_permissions.IsAuthenticated):
"""Permission class for access objects."""
def has_object_permission(self, request, view, obj):
@@ -43,15 +14,20 @@ class DomainResourcePermission(DomainPermission):
return abilities.get(request.method.lower(), False)
class IsMailboxOwnerPermission(permissions.BasePermission):
"""Authorize update for domain viewers on their own mailbox."""
class MailBoxPermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
def has_permission(self, request, view):
"""This permission is specifically about updates"""
"""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"]
return abilities.get(request.method.lower(), False)
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
def has_object_permission(self, request, view, obj):
"""If the user is trying to update their own mailbox."""
return obj.get_email() == request.user.email
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
+1 -2
View File
@@ -15,5 +15,4 @@ class MailboxManagerConfig(AppConfig):
"""
Import signals when the app is ready.
"""
# pylint: disable=import-outside-toplevel, unused-import
import mailbox_manager.signals # noqa: PLC0415
import mailbox_manager.signals # pylint: disable=import-outside-toplevel, unused-import
-11
View File
@@ -98,14 +98,3 @@ class MailDomainInvitationFactory(factory.django.DjangoModelFactory):
[role[0] for role in enums.MailDomainRoleChoices.choices]
)
issuer = factory.SubFactory(core_factories.UserFactory)
class AliasFactory(factory.django.DjangoModelFactory):
"""A factory to create aliases."""
class Meta:
model = models.Alias
domain = factory.SubFactory(MailDomainEnabledFactory)
local_part = factory.Faker("word")
destination = factory.Faker("email")
@@ -1,25 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-08 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0025_alter_mailbox_secondary_email'),
]
operations = [
migrations.AlterUniqueTogether(
name='mailbox',
unique_together=set(),
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(fields=('local_part', 'domain'), name='unique_username'),
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(fields=('first_name', 'last_name', 'domain'), name='unique_ox_display_name'),
),
]
@@ -1,22 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-21 15:49
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0026_alter_mailbox_unique_together_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='mailbox',
name='unique_ox_display_name',
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('first_name'), django.db.models.functions.text.Lower('last_name'), models.F('domain'), name='unique_ox_display_name', violation_error_message='Mailbox with this First name, Last name and Domain already exists.'),
),
]
@@ -1,33 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-13 12:55
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0027_remove_mailbox_unique_ox_display_name_and_more'),
]
operations = [
migrations.CreateModel(
name='Alias',
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')),
('local_part', models.CharField(max_length=100)),
('destination', models.CharField(max_length=254, validators=[django.core.validators.EmailValidator(allowlist=['localhost', 'devnull'])], verbose_name='destination address')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='mailbox_manager.maildomain')),
],
options={
'verbose_name': 'Alias',
'verbose_name_plural': 'Aliases',
'db_table': 'people_aliases',
'ordering': ['-created_at'],
'unique_together': {('local_part', 'destination')},
},
),
]
@@ -1,21 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-17 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0028_alias'),
]
operations = [
migrations.AlterUniqueTogether(
name='alias',
unique_together=set(),
),
migrations.AddConstraint(
model_name='alias',
constraint=models.UniqueConstraint(fields=('domain', 'local_part', 'destination'), name='no_duplicate'),
),
]
+5 -123
View File
@@ -9,15 +9,12 @@ from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.validators import EmailValidator
from django.db import models
from django.db.models.functions import Lower
from django.template.loader import render_to_string
from django.utils.text import slugify
from django.utils.translation import get_language, gettext, override
from django.utils.translation import gettext_lazy as _
from core.exceptions import EmailAlreadyKnownException
from core.models import BaseInvitation, BaseModel, Organization, User
from mailbox_manager.enums import (
@@ -91,8 +88,6 @@ class MailDomain(BaseModel):
def __str__(self):
return self.name
objects = models.Manager()
def save(self, *args, **kwargs):
"""Override save function to compute the slug."""
self.slug = self.get_slug()
@@ -111,7 +106,7 @@ class MailDomain(BaseModel):
if user.is_authenticated:
try:
role = self.accesses.filter(user=user).values("role")[0]["role"]
except MailDomainAccess.DoesNotExist, IndexError:
except (MailDomainAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [
@@ -224,7 +219,7 @@ class MailDomainAccess(BaseModel):
authenticated_user_role = user.mail_domain_accesses.get(
domain=self.domain
).role
except MailDomainAccess.DoesNotExist, IndexError:
except (MailDomainAccess.DoesNotExist, IndexError):
return []
# only an owner can set an owner role
@@ -252,7 +247,7 @@ class MailDomainAccess(BaseModel):
if user.is_authenticated:
try:
role = user.mail_domain_accesses.filter(domain=self.domain).get().role
except MailDomainAccess.DoesNotExist, IndexError:
except (MailDomainAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [
@@ -308,23 +303,7 @@ class Mailbox(AbstractBaseUser, BaseModel):
db_table = "people_mail_box"
verbose_name = _("Mailbox")
verbose_name_plural = _("Mailboxes")
constraints = [
models.UniqueConstraint(
fields=["local_part", "domain"], name="unique_username"
),
models.UniqueConstraint(
Lower("first_name"),
Lower("last_name"),
"domain",
name="unique_ox_display_name",
violation_error_message="Mailbox with this First name, \
Last name and Domain already exists.",
),
# Display name in OpenXChange must be unique
# To avoid sending failing requests to dimail,
# we impose uniqueness here too
# And compare to lowercase to enforce case-insensitive uniqueness
]
unique_together = ("local_part", "domain")
ordering = ["-created_at"]
def __str__(self):
@@ -351,8 +330,6 @@ Last name and Domain already exists.",
raise exceptions.ValidationError(
_("You can't create or update a mailbox for a disabled domain.")
)
self.local_part = self.local_part.lower()
return super().save(*args, **kwargs)
@property
@@ -364,25 +341,6 @@ Last name and Domain already exists.",
"""Return the email address of the mailbox."""
return f"{self.local_part}@{self.domain.name}"
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
role = user.mail_domain_accesses.get(domain=self.domain).role
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
is_self = self.get_email() == user.email
return {
"get": bool(role),
"post": is_owner_or_admin,
"patch": is_owner_or_admin or is_self,
"put": is_owner_or_admin or is_self,
"delete": False,
}
class MailDomainInvitation(BaseInvitation):
"""User invitation to mail domains."""
@@ -416,23 +374,6 @@ class MailDomainInvitation(BaseInvitation):
)
]
def refresh(self):
"""Create domain access if refresh fail because user was created
after invitation expiration"""
try:
super().refresh()
except EmailAlreadyKnownException as exc:
user = User.objects.get(email__iexact=self.email)
MailDomainAccess.objects.create(
user=user,
domain=self.domain,
role=self.role,
)
self.delete() # delete related invitation
raise exc
def __str__(self):
return f"{self.email} invited to {self.domain}"
@@ -461,7 +402,7 @@ class MailDomainInvitation(BaseInvitation):
role = self.domain.accesses.filter(user=user).values("role")[0][
"role"
]
except self._meta.model.DoesNotExist, IndexError:
except (self._meta.model.DoesNotExist, IndexError):
role = None
can_delete = role in [
@@ -475,62 +416,3 @@ class MailDomainInvitation(BaseInvitation):
"patch": False,
"put": False,
}
class Alias(BaseModel):
"""Model for aliases."""
local_part = models.CharField(max_length=100, blank=False)
destination = models.CharField(
_("destination address"),
max_length=254,
null=False,
blank=False,
validators=[
EmailValidator(allowlist=["localhost", "devnull"]),
],
)
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
related_name="aliases",
null=False,
blank=False,
)
class Meta:
db_table = "people_aliases"
verbose_name = _("Alias")
verbose_name_plural = _("Aliases")
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["domain", "local_part", "destination"], name="no_duplicate"
)
]
def __str__(self):
return f"{self.local_part} to {self.destination}"
def get_abilities(self, user):
"""Compute and return abilities for a given user. Admin and owners can
edit aliases, but also viewer if the alias points to their email."""
try:
role = user.mail_domain_accesses.get(domain=self.domain).role
except MailDomainAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
is_self = self.destination == user.email
return {
"get": bool(role),
"post": is_owner_or_admin,
"patch": False,
"put": False,
"delete": is_owner_or_admin or is_self,
}
@@ -1,129 +0,0 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "bulk delete" action.
"""
# pylint: disable=W0613
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_bulk_delete__anonymous_get_401():
"""Anonymous user should not be able to bulk delete."""
mail_domain = factories.MailDomainFactory()
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
client = APIClient()
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 3
def test_api_aliases_bulk_delete__no_access_get_404():
"""User with no access to domain should not be able to bulk delete."""
mail_domain = factories.MailDomainFactory()
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Alias.objects.count() == 3
def test_api_aliases_bulk_delete__viewer_get_403():
"""Viewer user should not be able to bulk delete."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 3
@responses.activate
def test_api_aliases_bulk_delete__administrators_allowed_all_destination(
dimail_token_ok,
):
"""
Administrators of a domain should be allowed to bulk delete all aliases
of a given local_part.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.Alias.objects.count() == 3
assert not models.Alias.objects.filter(
domain=mail_domain, local_part=alias_.local_part
).exists()
def test_api_aliases_bulk_delete__no_local_part_bad_request():
"""Filtering by local part is mandatory when bulk deleting aliases."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.Alias.objects.count() == 3
@@ -1,214 +0,0 @@
"""
Tests for aliases API endpoint.
Focus on "create" action.
"""
# pylint: disable=W0613
import json
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_create__anonymous():
"""Anonymous user should not create aliases"""
domain = factories.MailDomainEnabledFactory()
response = APIClient().post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"whatever": "this should not be updated"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not models.Alias.objects.exists()
def test_api_aliases_create__no_access_forbidden_not_found():
"""Unrelated user not having domain permission should not create aliases."""
domain = factories.MailDomainEnabledFactory()
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert not models.Alias.objects.exists()
def test_api_aliases_create__viewer_forbidden():
"""Domain viewers should not create aliases."""
domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(role="viewer", domain=domain)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__duplicate_forbidden():
"""Cannot create alias if existing alias same domain + local part + destination."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
existing_alias = factories.AliasFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{
"local_part": existing_alias.local_part,
"destination": existing_alias.destination,
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.Alias.objects.filter(domain=access.domain).count() == 1
@responses.activate
def test_api_aliases_create__async_alias_bad_request(dimail_token_ok):
"""
If People fall out of sync with dimail, return a clear error if alias cannot be created
because it already exists on dimail.
"""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
# Mock dimail response
responses.add(
responses.POST,
re.compile(r".*/aliases/"),
body=json.dumps({"detail": "Alias already exists"}),
status=status.HTTP_409_CONFLICT,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{
"local_part": "already_existing_alias",
"destination": "someone@outsidedomain.com",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"NON_FIELD_ERRORS": [
"Alias already exists. Your domain is out of sync, please contact our support."
]
}
assert not models.Alias.objects.exists()
@responses.activate
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN],
)
def test_api_aliases_create__admins_ok(role, dimail_token_ok):
"""Domain admins should be able to create aliases."""
access = factories.MailDomainAccessFactory(role=role)
client = APIClient()
client.force_login(access.user)
# Prepare responses
# token response in fixtures
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "contact",
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": True,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "contact", "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_201_CREATED
alias = models.Alias.objects.get()
assert alias.local_part == "contact"
assert alias.destination == "someone@outsidedomain.com"
@responses.activate
def test_api_aliases_create__existing_mailbox_ok(dimail_token_ok):
"""Can create alias even if local_part is already used by a mailbox."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": mailbox.local_part,
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": mailbox.local_part, "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__devnull_destination_ok(dimail_token_ok):
"""Can create alias where destination is devnull@devnull."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "spammy-address",
"domain": access.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "spammy-address", "destination": "devnull@devnull"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@@ -1,139 +0,0 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "delete" action.
"""
# pylint: disable=W0613
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_delete__anonymous():
"""Anonymous user should not be able to delete aliases."""
alias_ = factories.AliasFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 1
def test_api_aliases_delete__no_access_forbidden_not_found():
"""
Authenticated users should not be allowed to delete an alias in a
mail domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
alias_ = factories.AliasFactory()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Alias.objects.count() == 1
def test_api_aliases_delete__viewer_forbidden():
"""
Authenticated users should not be allowed to delete aliases for a
mail domain in which they are a simple viewer.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 1
@responses.activate
def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
"""
Administrators of a mail domain should be allowed to delete aliases.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.Alias.objects.count() == 5
@responses.activate
def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
"""
Should return a clear message when dimail returns 404 not found.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
json={"detail": "Not found"},
status=status.HTTP_404_NOT_FOUND,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert (
response.json()
== "Domain out of sync with mailbox provider, please contact our support."
)
assert not models.Alias.objects.exists()
@@ -1,65 +0,0 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "list" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_aliases_list__anonymous():
"""Anonymous user should not be able to list aliases"""
domain = factories.MailDomainEnabledFactory()
factories.AliasFactory.create_batch(3, domain=domain)
response = APIClient().get(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_aliases_list__no_access_forbidden_not_found():
"""User authenticated but not having domain permission should not list aliases."""
factories.MailDomainAccessFactory() # access to another domain
domain = factories.MailDomainEnabledFactory()
factories.AliasFactory.create_batch(3, domain=domain)
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
],
)
def test_api_aliases_list__authorized_ok(role):
"""Domain viewers and admins should be able to list aliases."""
access = factories.MailDomainAccessFactory(role=role)
factories.AliasFactory.create_batch(2, local_part="support", domain=access.domain)
factories.AliasFactory.create_batch(3, domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["results"] == sorted(
response.json()["results"], key=lambda x: x["local_part"]
)
assert response.json()["count"] == 5
@@ -11,7 +11,8 @@ from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
from mailbox_manager import enums, factories
from mailbox_manager.api.client import serializers
pytestmark = pytest.mark.django_db
@@ -19,12 +20,13 @@ pytestmark = pytest.mark.django_db
def test_api_domain_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
domain = factories.MailDomainEnabledFactory()
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
response = APIClient().post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
{
"email": "some.email@domain.com",
"role": "admin",
},
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -33,23 +35,24 @@ def test_api_domain_invitations__create__anonymous():
}
def test_api_domain_invitations__create__no_access_forbidden_not_found():
def test_api_domain_invitations__create__authenticated_outsider():
"""Users should not be permitted to send domain management invitations
for a domain they don't manage."""
user = core_factories.UserFactory()
domain = factories.MailDomainEnabledFactory()
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
{
"email": "some.email@domain.com",
"role": "viewer",
},
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
@@ -62,25 +65,28 @@ def test_api_domain_invitations__admin_should_create_invites(role):
domain = factories.MailDomainEnabledFactory()
factories.MailDomainAccessFactory(domain=domain, user=user, role=role)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
{
"email": "some.email@domain.com",
"role": "owner",
},
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["some.email@domain.com"]
assert email.to == [invitation_values["email"]]
assert email.subject == "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
def test_api_domain_invitations__no_access_forbidden_not_found():
def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
"""
Domain viewers should not be able to invite new domain managers.
"""
@@ -90,19 +96,21 @@ def test_api_domain_invitations__no_access_forbidden_not_found():
domain=domain, user=user, role=enums.MailDomainRoleChoices.VIEWER
)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
{
"email": "some.email@domain.com",
"role": "viewer",
},
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
"detail": "You are not allowed to manage invitations for this domain."
}
@@ -117,81 +125,42 @@ def test_api_domain_invitations__should_not_create_duplicate_invitations():
domain=domain, user=user, role=enums.MailDomainRoleChoices.OWNER
)
# New invitation to the same domain with the exact same email address
# Create a new invitation to the same domain with the exact same email address
duplicated_invitation = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build(email=existing_invitation.email)
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
{
"email": existing_invitation.email,
"role": "owner",
},
duplicated_invitation,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Mail domain invitation with this Email address and Domain already exists."
]
assert models.MailDomainInvitation.objects.count() == 1 # and specifically, not 2
def test_api_domain_invitations__inviting_known_email_should_create_access():
def test_api_domain_invitations__should_not_invite_when_user_already_exists():
"""Already existing users should not be invited but given access directly."""
existing_user = core_factories.UserFactory()
# Grant privileged role on the domain to the user
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build(email=existing_user.email)
).data
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
{
"email": existing_user.email,
"role": "owner",
},
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
assert not models.MailDomainInvitation.objects.exists()
assert models.MailDomainAccess.objects.filter(user=existing_user).exists()
def test_api_domain_invitations__inviting_known_email_case_insensitive():
"""Email matching should be case-insensitive when creating access for existing users."""
# Create a user with lowercase email
existing_user = core_factories.UserFactory(email="john.doe@example.com")
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
# Try to invite with same email different case - should create access for existing user
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
{
"email": "John.Doe@Example.COM",
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
# No invitation should be created
assert not models.MailDomainInvitation.objects.exists()
# Access should be created for the existing user (not a new user)
assert models.MailDomainAccess.objects.filter(user=existing_user).exists()
assert models.MailDomainAccess.objects.filter(
user=existing_user, domain=access.domain
).exists()
# Ensure only one user exists (no duplicate user created)
assert models.User.objects.filter(email__iexact="john.doe@example.com").count() == 1
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
@@ -1,76 +0,0 @@
"""
Tests for MailDomainInvitation API endpoint in People's app mailbox_manager.
Focus on "delete" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__delete__anonymous():
"""Anonymous users should not be able to delete invitations."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__no_access_not_found():
"""Users should not be permitted to delete invitations
on domains they don't manage."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
other_access = factories.MailDomainAccessFactory(role="owner") # unrelated access
client = APIClient()
client.force_login(other_access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__viewers_forbidden():
"""Domain viewers should not be permitted to delete invitations."""
access = factories.MailDomainAccessFactory(role="viewer")
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainInvitation.objects.count() == 1
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_domain_invitations__delete_admins_ok(role):
"""Domain owners and admins should be able to delete invitations."""
access = factories.MailDomainAccessFactory(role=role)
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.MailDomainInvitation.objects.exists()
@@ -1,112 +0,0 @@
"""
Tests for MailDomainInvitations API endpoint in People's app mailbox_manager.
Focus on "refresh" action.
"""
import pytest
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__anonymous_cannot_refresh():
"""Anonymous are not allowed to refresh."""
invitation = factories.MailDomainInvitationFactory()
response = APIClient().post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__no_access_cannot_see_invitation():
"""Users with no permission on the domain should be returned a 404 not found."""
invitation = factories.MailDomainInvitationFactory()
otro_access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.ADMIN
)
client = APIClient()
client.force_login(otro_access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_domain_invitations__viewer_cannot_refresh():
"""Viewers cannot refresh invitations."""
invitation = factories.MailDomainInvitationFactory()
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_domain_invitations__admins_can_refresh_expired_invitations():
"""Admins can refresh invitations."""
with freeze_time("2025-12-16"):
invitation = factories.MailDomainInvitationFactory()
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER
)
assert invitation.is_expired is True # check invitation is correctly expired
client = APIClient()
client.force_login(access.user)
# can refresh expired invitations
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_expired"] is False
assert models.MailDomainInvitation.objects.count() == 1
invitation.refresh_from_db()
# can also refresh valid
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_expired"] is False
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__refreshing_invitation_can_convert_to_access():
"""In the event of someone creating their account after their invitation expired,
said invitation has not been converted to access.
We don't want to create a new invitation but create an access instead."""
with freeze_time("2025-12-16"):
invitation = factories.MailDomainInvitationFactory()
core_factories.UserFactory(email=invitation.email)
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
assert models.MailDomainAccess.objects.count() == 2
assert not models.MailDomainInvitation.objects.exists()
@@ -26,7 +26,7 @@ def test_api_domain_invitations__anonymous_user_should_not_retrieve_invitations(
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__no_access_forbidden_not_found():
def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
@@ -40,8 +40,8 @@ def test_api_domain_invitations__no_access_forbidden_not_found():
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 0
def test_api_domain_invitations__domain_managers_should_list_invitations():
@@ -41,14 +41,17 @@ def test_api_mail_domains__create_name_unique():
"""
Creating domain should raise an error if already existing name.
"""
existing_domain = factories.MailDomainFactory()
factories.MailDomainFactory(name="existing_domain.com")
user = core_factories.UserFactory()
client = APIClient()
client.force_login(core_factories.UserFactory())
client.force_login(user)
response = client.post(
"/api/v1.0/mail-domains/",
{"name": existing_domain.name},
{
"name": "existing_domain.com",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -2,6 +2,7 @@
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "fetch" action.
"""
import json
import re
import pytest
@@ -81,7 +82,7 @@ def test_api_mail_domains__fetch_from_dimail__viewer():
],
)
@responses.activate
def test_api_mail_domains__fetch_from_dimail_admin_successful(role):
def test_api_mail_domains__fetch_from_dimail(role):
"""
Authenticated users should be allowed to fetch a domain
from dimail if they are an owner or admin.
@@ -69,7 +69,7 @@ def test_api_mail_domains__retrieve_authenticated_unrelated():
@responses.activate
def test_api_mail_domains__retrieve_authenticated_related_successful():
def test_api_mail_domains__retrieve_authenticated_related():
"""
Authenticated users should be allowed to retrieve a domain
to which they have access.
@@ -35,7 +35,7 @@ def test_api_mail_domain__accesses_create_anonymous():
assert models.MailDomainAccess.objects.exists() is False
def test_api_mail_domain__accesses_create_no_access_forbidden_not_found():
def test_api_mail_domain__accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create domain accesses for a domain to
which they are not related.
@@ -56,14 +56,14 @@ def test_api_mail_domain__accesses_create_no_access_forbidden_not_found():
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "No MailDomain matches the given query.",
"detail": "You are not allowed to manage accesses for this domain."
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_viewer_forbidden():
def test_api_mail_domain__accesses_create_authenticated_viewer():
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
@@ -85,7 +85,7 @@ def test_api_mail_domain__accesses_create_viewer_forbidden():
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action.",
"detail": "You are not allowed to manage accesses for this domain."
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
@@ -13,7 +13,7 @@ from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_accesses_delete__anonymous_forbidden():
def test_api_mail_domain__accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a mail domain access."""
access = factories.MailDomainAccessFactory()
@@ -25,7 +25,7 @@ def test_api_domain_accesses_delete__anonymous_forbidden():
assert models.MailDomainAccess.objects.count() == 1
def test_api_domain_accesses_delete__unrelated_notfound():
def test_api_mail_domain__accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain to which they are not related.
@@ -39,11 +39,11 @@ def test_api_domain_accesses_delete__unrelated_notfound():
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainAccess.objects.count() == 1
def test_api_domain_accesses_delete__viewer_forbidden():
def test_api_mail_domain__accesses_delete_viewer():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain in which they are a simple viewer.
@@ -65,7 +65,7 @@ def test_api_domain_accesses_delete__viewer_forbidden():
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
def test_api_domain_accesses_delete__administrators_successful():
def test_api_mail_domain__accesses_delete_administrators():
"""
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
"""
@@ -89,7 +89,7 @@ def test_api_domain_accesses_delete__administrators_successful():
assert models.MailDomainAccess.objects.count() == 1
def test_api_domain_accesses_delete__owners_successful():
def test_api_mail_domain__accesses_delete_owners():
"""
An owner should be able to delete the mail domain access of another user including
a owner access.
@@ -114,7 +114,7 @@ def test_api_domain_accesses_delete__owners_successful():
assert models.MailDomainAccess.objects.count() == 1
def test_api_domain_accesses_delete__last_owner_forbidden():
def test_api_mail_domain__accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a mail domain
"""
@@ -27,7 +27,8 @@ def test_api_mail_domain__accesses_list_anonymous():
def test_api_mail_domain__accesses_list_authenticated_unrelated():
"""
User with no access should not be allowed to list domain accesses.
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
to which they are not related.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
@@ -42,9 +43,12 @@ def test_api_mail_domain__accesses_list_authenticated_unrelated():
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == 200
assert response.json() == {
"detail": "No MailDomain matches the given query.",
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@@ -174,12 +178,8 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
# related users :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - filter on slug and domain on permission (to return 404 instead of 403 when no access)
# - count ?
# - distinct from viewset
with django_assert_num_queries(5):
with django_assert_num_queries(3):
client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
@@ -189,7 +189,7 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
factories.MailDomainAccessFactory(domain=mail_domain)
# num queries should still be the same
with django_assert_num_queries(5):
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
@@ -43,7 +43,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
assert response.json() == {"detail": "No MailDomainAccess matches the given query."}
# Accesses related to another mail_domain should be excluded even if the user is related to it
for other_access in [
@@ -55,7 +55,9 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
assert response.json() == {
"detail": "No MailDomainAccess matches the given query."
}
def test_api_mail_domain__accesses_retrieve_authenticated_related():
@@ -27,7 +27,7 @@ def test_api_mail_domain__accesses_update_anonymous():
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_no_access_forbidden_not_found():
def test_api_mail_domain__accesses_update_authenticated_unrelated():
"""
An authenticated user should not be allowed to update a mail domain access
for a mail_domain to which they are not related.
@@ -42,7 +42,7 @@ def test_api_mail_domain__accesses_update_no_access_forbidden_not_found():
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_403_FORBIDDEN
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
@@ -30,7 +30,9 @@ def test_api_mail_domain__available_users_anonymous():
def test_api_mail_domain__available_users_forbidden():
"""Users with no access should not be able to list available users."""
"""Authenticated user without accesses on maildomain should not be able to see available
users.
"""
authenticated_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
@@ -38,7 +40,7 @@ def test_api_mail_domain__available_users_forbidden():
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
File diff suppressed because it is too large Load Diff
@@ -26,7 +26,7 @@ def test_api_mailboxes__disable_anonymous_forbidden():
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
def test_api_mailboxes__disable_no_access_forbidden_not_found():
def test_api_mailboxes__disable_authenticated_failure():
"""Authenticated users should not be able to disable mailbox
without specific role on mail domain."""
user = core_factories.UserFactory()
@@ -39,7 +39,7 @@ def test_api_mailboxes__disable_no_access_forbidden_not_found():
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/disable/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
@@ -26,8 +26,9 @@ def test_api_mailboxes__enable_anonymous_forbidden():
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
def test_api_mailboxes__enable_unrelated_not_found():
"""Unrelated users should not be able to enable mailbox."""
def test_api_mailboxes__enable_authenticated_failure():
"""Authenticated users should not be able to enable mailbox
without specific role on mail domain."""
user = core_factories.UserFactory()
client = APIClient()
@@ -38,7 +39,7 @@ def test_api_mailboxes__enable_unrelated_not_found():
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/enable/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
@@ -25,8 +25,8 @@ def test_api_mailboxes__list_anonymous():
}
def test_api_mailboxes__list_unrelated_not_found():
"""Unrelated users should not be able to list mailboxes"""
def test_api_mailboxes__list_authenticated():
"""Authenticated users should not be able to list mailboxes"""
user = core_factories.UserFactory()
client = APIClient()
@@ -36,8 +36,10 @@ def test_api_mailboxes__list_unrelated_not_found():
factories.MailboxFactory.create_batch(2, domain=mail_domain)
response = client.get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
@@ -1,190 +0,0 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mailboxes_patch__anonymous_forbidden():
"""Anonymous users should not be able to update a mailbox."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = APIClient().patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"secondary_email": "updated@example.com"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_patch__no_access_forbidden_not_found():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.local_part}/",
{"secondary_email": "updated@example.com"},
format="json",
)
# permission denied at domain level
assert response.status_code == status.HTTP_404_NOT_FOUND
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_patch__viewer_forbidden():
"""User having viewer access on a domain should not be able to update
anything on a mailbox that's not theirs."""
viewer = core_factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailDomainAccessFactory(
user=viewer,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(viewer)
# should not be able to update any field
# we only try one field as 403 is global in our implementation
old_value = mailbox.secondary_email
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"secondary_email": "updated@example.com"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mailbox.refresh_from_db()
assert mailbox.secondary_email == old_value
def test_api_mailboxes_patch__unauthorized_no_mailbox():
"""No mailbox returns a 404."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.ADMIN)
client = APIClient()
client.force_login(access.user)
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/nonexistent_mailbox_pk/",
{"secondary_email": "updated@example.com"},
format="json",
)
# permission denied at domain level
# the existence of the mailbox is not checked
assert response.status_code == status.HTTP_404_NOT_FOUND
# UPDATABLE FIELDS : SECONDARY EMAIL FIRST NAME AND LAST NAME
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_mailboxes_patch__admins_can_update_mailboxes(role):
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
valid_new_values = {
"secondary_email": "updated_mail@validformat.com",
"first_name": "Marsha",
"last_name": "Johnson",
}
for field_name in ["first_name", "last_name", "secondary_email"]:
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{field_name: valid_new_values[field_name]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert getattr(mailbox, field_name) == valid_new_values[field_name]
def test_api_mailboxes_patch__viewer_can_update_own_mailbox():
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(user)
valid_new_values = {
"secondary_email": "updated_mail@validformat.com",
"first_name": "Marsha",
"last_name": "Johnson",
}
for field_name in ["first_name", "last_name", "secondary_email"]:
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{field_name: valid_new_values[field_name]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert getattr(mailbox, field_name) == valid_new_values[field_name]
# DOMAIN AND LOCAL PART
@pytest.mark.parametrize(
"role",
["viewer", "owner", "administrator"],
)
def test_api_mailboxes_patch__no_updating_domain_or_local_part(role):
"""Domain and local parts should not be updated."""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
if role == "viewer":
# viewer has similar privileges as admins on own mailbox
user = core_factories.UserFactory(
email=f"{mailbox.local_part}@{mailbox.domain}"
)
access = factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
other_domain = factories.MailDomainEnabledFactory()
local_part = mailbox.local_part
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"local_part": "new.local.part", "domain": other_domain.name},
format="json",
)
assert response.status_code == status.HTTP_200_OK # a 400 would be better
mailbox.refresh_from_db()
assert mailbox.local_part == local_part
assert mailbox.domain == access.domain
@@ -1,192 +0,0 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
# PUT expects all the object's updatable fields.
# We'll use this dict on all the following tests
NEW_VALUES = {
"secondary_email": "marsha.p@social.us",
"first_name": "Marsha",
"last_name": "Johnson",
}
def test_api_mailboxes_put__anonymous_forbidden():
"""Anonymous users should not be able to update a mailbox."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = APIClient().put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_put__no_access_forbidden_not_found():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
client = APIClient()
client.force_login(core_factories.UserFactory())
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
# permission denied at domain level
assert response.status_code == status.HTTP_404_NOT_FOUND
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_put__unauthorized_no_mailbox():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
client = APIClient()
client.force_login(core_factories.UserFactory())
domain = factories.MailDomainEnabledFactory()
response = client.put(
f"/api/v1.0/mail-domains/{domain.slug}/mailboxes/nonexistent_mailbox_pk/",
NEW_VALUES,
format="json",
)
# permission denied at domain level
# the existence of the mailbox is not checked
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mailboxes_put__viewer_forbidden():
"""User having viewer access on a domain should not be able to update
anything on a mailbox that's not theirs."""
viewer = core_factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailDomainAccessFactory(
user=viewer,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(viewer)
# should not be able to update any field
# we only try one field as 403 is global in our implementation
old_value = mailbox.secondary_email
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mailbox.refresh_from_db()
assert mailbox.secondary_email == old_value
# UPDATABLE FIELDS : SECONDARY EMAIL, FIRST NAME AND LAST NAME
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_mailboxes_put__admins_can_update_mailboxes(role):
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
result = response.json()
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert result["first_name"] == NEW_VALUES["first_name"]
assert result["last_name"] == NEW_VALUES["last_name"]
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
def test_api_mailboxes_put__viewer_can_update_own_mailbox():
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
result = response.json()
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert result["first_name"] == NEW_VALUES["first_name"]
assert result["last_name"] == NEW_VALUES["last_name"]
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
# DOMAIN AND LOCAL PART
@pytest.mark.parametrize(
"role",
["viewer", "owner", "administrator"],
)
def test_api_mailboxes_put__no_updating_domain_or_local_part(role):
"""Domain and local parts should not be updated."""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
if role == "viewer":
# viewer has similar privileges as admins on own mailbox
user = core_factories.UserFactory(
email=f"{mailbox.local_part}@{mailbox.domain}"
)
access = factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
other_domain = factories.MailDomainEnabledFactory()
old_local_part = mailbox.local_part
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{**NEW_VALUES, "local_part": "new.local.part", "domain": other_domain.name},
format="json",
)
assert response.status_code == status.HTTP_200_OK # This should not be a 200
mailbox.refresh_from_db()
assert mailbox.local_part == old_local_part
assert mailbox.domain == access.domain
@@ -28,7 +28,7 @@ def test_api_mailboxes__reset_password_anonymous_unauthorized():
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__reset_password_no_access_forbidden_not_found():
def test_api_mailboxes__reset_password_unrelated_forbidden():
"""Authenticated users not managing the domain
should not be able to reset its mailboxes password."""
user = core_factories.UserFactory()
@@ -41,9 +41,9 @@ def test_api_mailboxes__reset_password_no_access_forbidden_not_found():
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "No MailDomain matches the given query.",
"detail": "You do not have permission to perform this action."
}
@@ -113,7 +113,7 @@ Please add a valid secondary email before trying again."
],
)
@responses.activate
def test_api_mailboxes__reset_password_admin_successful(role, dimail_token_ok): # pylint: disable=W0613
def test_api_mailboxes__reset_password_admin_successful(role):
"""Owner and admin users should be able to reset password on mailboxes.
New password should be sent to secondary email."""
mail_domain = factories.MailDomainEnabledFactory()
@@ -124,7 +124,12 @@ def test_api_mailboxes__reset_password_admin_successful(role, dimail_token_ok):
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
# token response in fixtures
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
@@ -156,7 +161,7 @@ def test_api_mailboxes__reset_password_non_existing():
@responses.activate
def test_api_mailboxes__reset_password_connexion_failed(dimail_token_ok): # pylint: disable=W0613
def test_api_mailboxes__reset_password_connexion_failed():
"""
No mail is sent when password reset failed because of connexion error.
"""
@@ -170,7 +175,12 @@ def test_api_mailboxes__reset_password_connexion_failed(dimail_token_ok): # pyl
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
# token response in fixtures
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
@@ -1,68 +0,0 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories
pytestmark = pytest.mark.django_db
def test_api_mailboxes__retrieve_anonymous_forbidden():
"""Anonymous users should not be able to retrieve a new mailbox via the API."""
mailbox = factories.MailboxFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__retrieve_no_access_forbidden_not_found():
"""Authenticated but unauthorized users should not be able to
retrieve mailbox. Response should be indistinguisable from normal 404"""
client = APIClient()
client.force_login(core_factories.UserFactory())
mailbox = factories.MailboxFactory()
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
# even if they have a mailbox on this domain
mailbox = factories.MailboxFactory()
client.force_login(core_factories.UserFactory(email=str(mailbox)))
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mailboxes__retrieve_authorized_ok():
"""Authorized users should be able to retrieve mailboxes."""
access = factories.MailDomainAccessFactory()
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(mailbox.id),
"first_name": mailbox.first_name,
"last_name": mailbox.last_name,
"local_part": mailbox.local_part,
"secondary_email": mailbox.secondary_email,
"status": mailbox.status,
}
@@ -1,35 +0,0 @@
"""
Fixtures for mailbox manager tests
"""
import re
import pytest
import responses
from rest_framework import status
from mailbox_manager import factories
## DIMAIL RESPONSES
@pytest.fixture(name="dimail_token_ok")
def fixture_dimail_token_ok():
"""Mock dimail response when /token/ endpoit is given valid credentials."""
responses.get(
re.compile(r".*/token/"),
json={"access_token": "token", "token_type": "bearer"},
status=status.HTTP_200_OK,
content_type="application/json",
)
@pytest.fixture(name="mailbox_data")
def fixture_mailbox_data():
"""Provides valid mailbox data for tests."""
example = factories.MailboxFactory.build()
return {
"first_name": example.first_name,
"last_name": example.last_name,
"local_part": example.local_part,
"secondary_email": example.secondary_email,
}
+12 -2
View File
@@ -3,8 +3,9 @@
import json
## USERS
def response_user_created(user_sub):
"""mimic dimail response upon successful user creation."""
return json.dumps(
@@ -18,6 +19,7 @@ def response_user_created(user_sub):
## DOMAINS
CHECK_DOMAIN_BROKEN = {
"name": "example.fr",
"state": "broken",
@@ -287,7 +289,13 @@ DOMAIN_SPEC = [
]
## TOKEN
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
## ALLOWS
def response_allows_created(user_name, domain_name):
"""mimic dimail response upon successful allows creation.
Dimail expects a name but our names are ProConnect's uuids."""
@@ -295,6 +303,8 @@ def response_allows_created(user_name, domain_name):
## MAILBOXES
def response_mailbox_created(email_address):
"""mimic dimail response upon successful mailbox creation."""
return json.dumps({"email": email_address.lower(), "password": "password"})
return json.dumps({"email": email_address, "password": "password"})
@@ -1,19 +0,0 @@
"""
Unit tests for the Alias model
"""
import pytest
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_models_aliases__devnull_destination_ok():
"""Can create alias where destination is devnull@devnull."""
models.Alias.objects.create(
local_part="spam",
domain=factories.MailDomainEnabledFactory(),
destination="devnull@devnull",
)
@@ -19,6 +19,7 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
TOKEN_OK,
response_mailbox_created,
)
@@ -100,9 +101,7 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(
client, dimail_token_ok
): # pylint: disable=W0613
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
"""Test admin action should switch domain state to ENABLED
when dimail's response is "ok". It should also activate any pending mailbox."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
@@ -128,7 +127,13 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(
content_type="application/json",
)
# we need to get a token to create mailboxes
# token response in fixtures
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
@@ -203,7 +208,7 @@ def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(clie
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes(client, dimail_token_ok): # pylint: disable=W0613
def test_send_pending_mailboxes(client):
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -218,7 +223,13 @@ def test_send_pending_mailboxes(client, dimail_token_ok): # pylint: disable=W06
url = reverse("admin:mailbox_manager_maildomain_changelist")
for mailbox in mailboxes:
# token response in fixtures
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
@@ -236,7 +247,7 @@ def test_send_pending_mailboxes(client, dimail_token_ok): # pylint: disable=W06
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes__listing_failed_mailboxes(client, dimail_token_ok): # pylint: disable=W0613
def test_send_pending_mailboxes__listing_failed_mailboxes(client):
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -250,7 +261,13 @@ def test_send_pending_mailboxes__listing_failed_mailboxes(client, dimail_token_o
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
# token response in fixtures
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
@@ -2,10 +2,12 @@
Unit tests for dimail client
"""
# pylint: disable=W0613
import json
import logging
import re
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from logging import Logger
from unittest import mock
import pytest
import responses
@@ -19,14 +21,14 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN_EXTERNAL,
CHECK_DOMAIN_BROKEN_INTERNAL,
CHECK_DOMAIN_OK,
TOKEN_OK,
response_mailbox_created,
)
pytestmark = pytest.mark.django_db
@responses.activate
def test_dimail_synchronization__already_sync(dimail_token_ok):
def test_dimail_synchronization__already_sync():
"""
No mailbox should be created when everything is already synced.
"""
@@ -37,26 +39,35 @@ def test_dimail_synchronization__already_sync(dimail_token_ok):
assert pre_sync_mailboxes.count() == 3
dimail_client = DimailAPIClient()
# Ensure successful response using "responses":
# token response in fixtures
responses.get(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
json=[
{
"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.import_mailboxes(domain)
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.import_mailboxes(domain)
post_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
assert post_sync_mailboxes.count() == 3
@@ -64,159 +75,97 @@ def test_dimail_synchronization__already_sync(dimail_token_ok):
assert set(models.Mailbox.objects.filter(domain=domain)) == set(pre_sync_mailboxes)
@responses.activate
def test_dimail_synchronization__synchronize_mailboxes(caplog, dimail_token_ok): # pylint: disable=W0613, R0914
"""Importing mailboxes from dimail should synchronize valid mailboxes
and log errors for invalid ones."""
caplog.set_level(logging.INFO)
@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()
existing_alias = factories.AliasFactory(domain=domain)
assert not models.Mailbox.objects.exists()
dimail_client = DimailAPIClient()
# Ensure successful response using "responses":
# successful token in fixtures
mailbox_valid = {
"type": "mailbox",
"status": "ok",
"email": f"validmailbox@{domain.name}",
"givenName": "Michael",
"surName": "Roch",
"displayName": "Michael Roch",
}
mailbox_oxadmin = {
"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",
}
mailbox_existing_alias = {
"type": "mailbox",
"status": "broken",
"email": f"{existing_alias.local_part}@{domain.name}",
"givenName": "Support",
"surName": "email",
"displayName": "Support email",
}
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",
)
responses.get(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
json=[
mailbox_valid,
mailbox_oxadmin,
mailbox_with_wrong_domain,
mailbox_with_invalid_domain,
mailbox_with_invalid_local_part,
mailbox_existing_alias,
],
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",
}
imported_mailboxes = dimail_client.import_mailboxes(domain)
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",
)
# 4 imports failed: oxadmin, wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert len(caplog.records) == 5
log_messages = [record.message for record in caplog.records]
imported_mailboxes = dimail_client.import_mailboxes(domain)
expected_messages = [
f"Not importing OX technical address: oxadmin@{domain.name}",
f"Import of email {mailbox_with_wrong_domain['email']} failed because of a wrong domain",
f"Import of email {mailbox_with_invalid_domain['email']} failed with error Invalid Domain",
f"Import of email {mailbox_with_invalid_local_part['email']} failed with error local-part \
contains non-ASCII characters)",
]
for message in expected_messages:
assert message in log_messages
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert mock_warning.call_count == 3
assert models.Mailbox.objects.count() == 2
assert imported_mailboxes == [
mailbox_valid["email"],
mailbox_existing_alias["email"],
]
# 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)
@responses.activate
def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint: disable=unused-argument
"""Importing aliases from dimail should synchronize valid aliases
and log errors for invalid ones."""
alias = factories.AliasFactory()
dimail_client = DimailAPIClient()
# 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)
existing_mailbox = factories.MailboxFactory(domain=alias.domain)
# Ensure successful response using "responses":
# token response in fixtures
incoming_aliases = [
{
"username": "contact",
"domain": alias.domain.name,
"destination": alias.destination, # same destination = ok
"allow_to_send": False,
},
{
"username": alias.local_part, # same username = ok
"domain": alias.domain.name,
"destination": "maheius.endorecles@somethingelse.com",
"allow_to_send": False,
},
{ # same username + same destination = big nono
"username": alias.local_part,
"domain": alias.domain.name,
"destination": alias.destination,
"allow_to_send": False,
},
{ # mailbox with same username = ok
"username": existing_mailbox.local_part,
"domain": alias.domain.name,
"destination": existing_mailbox.secondary_email,
"allow_to_send": False,
},
{ # alias to devnull@devnull
"username": "spam",
"domain": alias.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
]
responses.get(
re.compile(rf".*/domains/{alias.domain.name}/aliases/"),
json=incoming_aliases,
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_aliases = dimail_client.import_aliases(alias.domain)
assert len(imported_aliases) == 4
assert models.Alias.objects.count() == 5
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == "oxadmin"
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
assert imported_mailboxes == [mailbox_valid["email"]]
@pytest.mark.parametrize(
@@ -234,9 +183,10 @@ def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
domain = factories.MailDomainFactory(status=domain_status)
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.get(
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
json=body_content,
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -271,9 +221,10 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
domain = factories.MailDomainFactory(status=domain_status)
body_domain_broken = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.get(
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
json=body_domain_broken,
body=json.dumps(body_domain_broken),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -287,9 +238,10 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
# Now domain is OK again
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.get(
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
json=body_domain_ok,
body=json.dumps(body_domain_ok),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -315,16 +267,18 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
# nothing can be done by support team, domain should be in failed
body_domain_broken = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.get(
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
json=body_domain_broken,
body=json.dumps(body_domain_broken),
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and still returns KO for internal checks
responses.get(
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
json=body_domain_broken,
body=json.dumps(body_domain_broken),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -351,9 +305,10 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
# with all checks KO, domain should be in action required
body_domain_broken = CHECK_DOMAIN_BROKEN.copy()
body_domain_broken["name"] = domain.name
responses.get(
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
json=body_domain_broken,
body=json.dumps(body_domain_broken),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -369,18 +324,20 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
# the fetch_domain_status call
body_domain_broken_internal = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken_internal["name"] = domain.name
responses.get(
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
json=body_domain_broken_internal,
body=json.dumps(body_domain_broken_internal),
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and returns OK. Hooray!
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.get(
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
json=body_domain_ok,
body=json.dumps(body_domain_ok),
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -391,8 +348,7 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
assert domain.last_check_details == body_domain_ok
@responses.activate
def test_dimail__send_pending_mailboxes(caplog, dimail_token_ok):
def test_dimail__send_pending_mailboxes(caplog):
"""Status of pending mailboxes should switch to "enabled"
when calling send_pending_mailboxes."""
caplog.set_level(logging.INFO)
@@ -409,16 +365,22 @@ def test_dimail__send_pending_mailboxes(caplog, dimail_token_ok):
)
dimail_client = DimailAPIClient()
# Ensure successful response using "responses":
# token response in fixtures
responses.post(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.send_pending_mailboxes(domain=domain)
with responses.RequestsMock() as rsps:
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.send_pending_mailboxes(domain=domain)
mailbox1.refresh_from_db()
mailbox2.refresh_from_db()
+1 -5
View File
@@ -29,11 +29,7 @@ maildomain_related_router.register(
viewsets.MailDomainInvitationViewset,
basename="invitations",
)
maildomain_related_router.register(
"aliases",
viewsets.AliasViewSet,
basename="aliases",
)
urlpatterns = [
path(
+49 -261
View File
@@ -2,6 +2,7 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
import ast
import json
import smtplib
from email.errors import HeaderParseError, NonASCIILocalPartDefect
@@ -45,7 +46,7 @@ class DimailAPIClient:
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
API_TIMEOUT = settings.MAIL_PROVISIONING_API_TIMEOUT
def _get_headers(self):
def get_headers(self):
"""
Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
to get a token from dimail /token/ endpoint.
@@ -82,7 +83,7 @@ class DimailAPIClient:
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def create_domain(self, domain_name, request_user):
"""Send a domain creation request to dimail API."""
@@ -117,7 +118,7 @@ class DimailAPIClient:
)
return response
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def create_mailbox(self, mailbox, request_user=None):
"""Send a CREATE mailbox request to mail provisioning API."""
@@ -130,7 +131,7 @@ class DimailAPIClient:
# displayName value has to be unique
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
headers = self._get_headers()
headers = self.get_headers()
try:
response = session.post(
@@ -165,36 +166,7 @@ class DimailAPIClient:
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
# Dimail doesn't return a clear error for duplicate yet
# but a 500 internal error
if response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
try:
address = session.get(
f"{self.API_URL}/domains/{mailbox.domain.name}/address/{mailbox.local_part}/",
json=payload,
headers=headers,
verify=True,
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if address.status_code == status.HTTP_200_OK:
primary = address.json()["ox_primary_email"]
raise exceptions.ValidationError(
{
"NON_FIELD_ERRORS": [
f"First name + last name combination already in use in this context : {primary}."
]
}
)
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def create_user(self, user_id):
"""Send a request to dimail, to create a new user there. In dimail, user ids are subs."""
@@ -231,7 +203,7 @@ class DimailAPIClient:
)
return response
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def create_allow(self, user_id, domain_name):
"""Send a request to dimail for a new 'allow' between user and the domain."""
@@ -273,9 +245,9 @@ class DimailAPIClient:
)
return response
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def _raise_exception_for_unexpected_response(self, response):
def raise_exception_for_unexpected_response(self, response):
"""Raise error when encountering an unexpected error in dimail."""
try:
error_content = json.loads(
@@ -299,7 +271,7 @@ class DimailAPIClient:
title, template_name, recipient, mailbox_data, issuer
)
def _notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
def notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
"""
Send email to notify of password reset
and send new password.
@@ -359,7 +331,7 @@ class DimailAPIClient:
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/mailboxes/",
headers=self._get_headers(),
headers=self.get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -372,48 +344,43 @@ class DimailAPIClient:
raise error
if response.status_code != status.HTTP_200_OK:
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
dimail_mailboxes = ast.literal_eval(
response.content.decode("utf-8")
) # format output str to proper list
dimail_mailboxes = response.json()
people_mailboxes = models.Mailbox.objects.filter(domain=domain)
imported_mailboxes = []
for dimail_mailbox in dimail_mailboxes:
try:
address = Address(addr_spec=dimail_mailbox["email"])
except (HeaderParseError, NonASCIILocalPartDefect) as error:
logger.warning(
"Import of email %s failed with error %s",
dimail_mailbox["email"],
error,
)
continue
if address.username == "oxadmin":
logger.warning(
"Not importing OX technical address: %s", dimail_mailbox["email"]
)
continue
if str(address) not in [
if not dimail_mailbox["email"] in [
str(people_mailbox) for people_mailbox in people_mailboxes
]:
# sometimes dimail api returns email from another domain,
# so we decide to exclude this kind of 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,
status=enums.MailboxStatusChoices.ENABLED,
password=make_password(None), # unusable password
)
imported_mailboxes.append(str(mailbox))
else:
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,
status=enums.MailboxStatusChoices.ENABLED,
password=make_password(None), # unusable password
)
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 because of a wrong domain",
"Import of email %s failed with error %s",
dimail_mailbox["email"],
err,
)
return imported_mailboxes
@@ -422,7 +389,7 @@ class DimailAPIClient:
response = session.patch(
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}",
json={"active": "no"},
headers=self._get_headers(),
headers=self.get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -434,7 +401,7 @@ class DimailAPIClient:
request_user,
)
return response
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def enable_mailbox(self, mailbox, request_user=None):
"""Send a request to enable a mailbox to dimail API"""
@@ -446,7 +413,7 @@ class DimailAPIClient:
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
},
headers=self._get_headers(),
headers=self.get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -458,7 +425,7 @@ class DimailAPIClient:
request_user,
)
return response
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def send_pending_mailboxes(self, domain):
"""Send requests for all pending mailboxes of a domain. Returns a list of failed mailboxes for this domain."""
@@ -507,7 +474,7 @@ class DimailAPIClient:
raise error
if response.status_code == status.HTTP_200_OK:
return response.json()
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def fix_domain(self, domain):
"""Send a request to dimail to fix a domain.
@@ -524,7 +491,7 @@ class DimailAPIClient:
str(domain),
)
return response.json()
return self._raise_exception_for_unexpected_response(response)
return self.raise_exception_for_unexpected_response(response)
def fetch_domain_status(self, domain):
"""Send a request to check and update status of a domain."""
@@ -649,7 +616,7 @@ class DimailAPIClient:
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
headers=self._get_headers(),
headers=self.get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -663,7 +630,7 @@ class DimailAPIClient:
if response.status_code == status.HTTP_200_OK:
# send new password to secondary email
self._notify_mailbox_password_reset(
self.notify_mailbox_password_reset(
recipient=mailbox.secondary_email,
mailbox_data={
"email": response.json()["email"],
@@ -675,183 +642,4 @@ class DimailAPIClient:
mailbox,
)
return response
return self._raise_exception_for_unexpected_response(response)
def create_alias(self, alias, request_user=None):
"""Send a Create alias request to mail provisioning API."""
payload = {
"user_name": alias.local_part,
"destination": alias.destination,
}
headers = self._get_headers()
try:
response = session.post(
f"{self.API_URL}/domains/{alias.domain.name}/aliases/",
json=payload,
headers=headers,
verify=True,
timeout=self.API_TIMEOUT,
)
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(
"User %s linked alias %s to a new email.",
request_user,
f"{alias.local_part}@{alias.domain}",
)
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(alias.domain),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
if response.status_code == status.HTTP_409_CONFLICT:
logger.error(
"[DIMAIL] Out of sync with dimail. Admin, please import aliases for domain %s",
str(alias.domain),
)
raise exceptions.ValidationError(
{
"NON_FIELD_ERRORS": [
"Alias already exists. Your domain is out of sync, please contact our support."
]
}
)
return self._raise_exception_for_unexpected_response(response)
def delete_alias(self, alias, request_user=None):
"""Send a Delete alias request to mail provisioning API."""
headers = self._get_headers()
try:
response = session.delete(
f"{self.API_URL}/domains/{alias.domain.name}/aliases/{alias.local_part}/{alias.destination}",
json={},
headers=headers,
verify=True,
timeout=self.API_TIMEOUT,
)
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_204_NO_CONTENT:
logger.info(
"User %s removed destination %s from alias %s.",
request_user,
alias.destination,
f"{alias.local_part}@{alias.domain}",
)
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(alias.domain),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
if response.status_code == status.HTTP_404_NOT_FOUND:
logger.error(
"[DIMAIL] 404, alias %s not found. Domain out of sync with dimail. Admin, please import aliases for domain %s",
str(alias.local_part),
str(alias.domain),
)
# we don't raise error because we actually want this alias to be deleted
return response
return self._raise_exception_for_unexpected_response(response)
def delete_multiple_alias(self, local_part, domain_name):
"""Send a Delete alias request to mail provisioning API."""
try:
response = session.delete(
f"{self.API_URL}/domains/{domain_name}/aliases/{local_part}/all",
json={},
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
# response.raise_for_status()
return response
def import_aliases(self, domain):
"""Import aliases from dimail. Useful if people fall out of sync with dimail."""
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/aliases/",
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
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._raise_exception_for_unexpected_response(response)
incoming_aliases = response.json()
known_aliases = [
(known_alias.local_part, known_alias.destination)
for known_alias in models.Alias.objects.filter(domain=domain)
]
imported_aliases = []
for incoming_alias in incoming_aliases:
if (
incoming_alias["username"],
incoming_alias["destination"],
) not in known_aliases:
try:
new_alias = models.Alias.objects.create(
local_part=incoming_alias["username"],
destination=incoming_alias["destination"],
domain=domain,
)
except (HeaderParseError, NonASCIILocalPartDefect) as err:
logger.warning(
"Import of alias %s to %s failed with error %s",
incoming_alias["username"],
incoming_alias["destination"],
err,
)
else:
imported_aliases.append(str(new_alias))
return imported_aliases
return self.raise_exception_for_unexpected_response(response)
+31 -10
View File
@@ -74,7 +74,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* SENTRY_DSN
* DJANGO_SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -102,7 +102,7 @@ class Base(Configuration):
DATABASES = {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql",
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
environ_prefix=None,
),
@@ -173,6 +173,9 @@ class Base(Configuration):
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"),
os.path.join(
BASE_DIR, "admin", "templates"
), # enforce load before Django's admin
],
"OPTIONS": {
"context_processors": [
@@ -224,6 +227,7 @@ class Base(Configuration):
INSTALLED_APPS = [
# People
"admin.apps.PeopleAdminConfig", # replaces 'django.contrib.admin'
"core",
"demo",
"mailbox_manager.apps.MailboxManagerConfig",
@@ -242,7 +246,6 @@ class Base(Configuration):
"parler",
"rest_framework",
"treebeard",
"lasuite.admin", # must be before django.contrib.admin
# Django
"django.contrib.auth",
"django.contrib.contenttypes",
@@ -251,7 +254,6 @@ class Base(Configuration):
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.admin",
# OIDC third party
"mozilla_django_oidc",
]
@@ -339,7 +341,7 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -579,14 +581,29 @@ class Base(Configuration):
environ_name="MAIL_CHECK_DOMAIN_INTERVAL",
environ_prefix=None,
)
MATRIX_BASE_HOME_SERVER = values.Value(
default="https://matrix.agent.dinum.tchap.gouv.fr",
environ_name="MATRIX_BASE_HOME_SERVER",
DNS_PROVISIONING_TARGET_ZONE = values.Value(
default=None,
environ_name="DNS_PROVISIONING_TARGET_ZONE",
environ_prefix=None,
)
MATRIX_BOT_ACCESS_TOKEN = values.Value(
DNS_PROVISIONING_API_URL = values.Value(
default="https://api.scaleway.com",
environ_name="DNS_PROVISIONING_API_URL",
environ_prefix=None,
)
DNS_PROVISIONING_RESOURCE_ID = values.Value(
default=None,
environ_name="MATRIX_BOT_ACCESS_TOKEN",
environ_name="DNS_PROVISIONING_RESOURCE_ID",
environ_prefix=None,
)
DNS_PROVISIONING_API_CREDENTIALS = values.Value(
default=None,
environ_name="DNS_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
TCHAP_ACCESS_TOKEN = values.Value(
default=None,
environ_name="TCHAP_ACCESS_TOKEN",
environ_prefix=None,
)
@@ -1044,6 +1061,10 @@ class Production(Base):
},
},
}
SENTRY_DSN = values.Value(
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
environ_name="SENTRY_DSN",
)
class Feature(Production):
+13
View File
@@ -11,9 +11,22 @@ from core.plugins.registry import register_hook
from plugins.la_suite.hooks_utils.all_organizations import (
get_organization_name_and_metadata_from_siret,
)
from plugins.la_suite.hooks_utils.communes import CommuneCreation
@register_hook("organization_created")
def get_organization_name_and_metadata_from_siret_hook(organization):
"""After creating an organization, update the organization name & metadata."""
get_organization_name_and_metadata_from_siret(organization)
@register_hook("organization_created")
def commune_organization_created(organization):
"""After creating an organization, update the organization name."""
CommuneCreation().run_after_create(organization)
@register_hook("organization_access_granted")
def commune_organization_access_granted(organization_access):
"""After granting an organization access, check for needed domain access grant."""
CommuneCreation().run_after_grant_access(organization_access)
@@ -21,45 +21,25 @@ API_URL = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def _get_organization_name_and_metadata_from_results(data, siret):
"""Return the organization name and metadata from the results of a SIRET search."""
# Find matching organization
match = next(
(
(res, org)
for res in data.get("results", [])
for org in res.get("matching_etablissements", [])
if org.get("siret") == siret
),
None,
)
org_metadata = {}
for result in data["results"]:
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
org_metadata["is_public_service"] = result.get("complements", {}).get(
"est_service_public", False
)
org_metadata["is_commune"] = (
str(result.get("nature_juridique", "")) == "7210"
)
if not match:
logger.warning("No organization name found for SIRET %s", siret)
return None, {}
result, organization = match
# Extract metadata
is_commune = str(result.get("nature_juridique", "")) == "7210"
metadata = {
"is_public_service": result.get("complements", {}).get(
"est_service_public", False
),
"is_commune": is_commune,
}
# Extract name (priority: commune name > store signs > business name)
name = None
if is_commune:
name = result.get("siege", {}).get("libelle_commune")
if not name: # Fallback for non-communes OR if commune has no libelle_commune
store_signs = organization.get("liste_enseignes") or []
name = store_signs[0] if store_signs else result.get("nom_raison_sociale")
if name:
return name.title(), metadata
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title(), org_metadata
if name := result.get("nom_raison_sociale"):
return name.title(), org_metadata
logger.warning("No organization name found for SIRET %s", siret)
return None, metadata
return None, org_metadata
def get_organization_name_and_metadata_from_siret(organization):
@@ -0,0 +1,243 @@
"""Organization related plugins."""
import logging
import re
from django.conf import settings
from django.utils.text import slugify
import requests
from requests.adapters import HTTPAdapter, Retry
from mailbox_manager.enums import MailDomainRoleChoices
from mailbox_manager.models import MailDomain, MailDomainAccess
logger = logging.getLogger(__name__)
class ApiCall:
"""Encapsulates a call to an external API"""
inputs: dict = {}
method: str = "GET"
base: str = ""
url: str = ""
params: dict = {}
headers: dict = {}
response_data = None
def execute(self):
"""Call the specified API endpoint with supplied parameters and record response"""
if self.method in ("POST", "PATCH"):
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
json=self.params,
headers=self.headers,
timeout=20,
)
else:
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
params=self.params,
headers=self.headers,
timeout=20,
)
self.response_data = response.json()
logger.info(
"API call: %s %s %s %s",
self.method,
self.url,
self.params,
self.response_data,
)
class CommuneCreation:
"""
This plugin handles setup tasks for French communes.
"""
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def get_organization_name_from_results(self, data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
nature = "nature_juridique"
commune = nature in result and result[nature] == "7210"
if commune:
return result["siege"]["libelle_commune"].title()
logger.warning("Not a commune: SIRET %s", siret)
return None
def dns_call(self, spec):
"""Call to add a DNS record"""
zone_name = self.zone_name(spec.inputs["name"])
records = [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec.response_data
]
result = ApiCall()
result.method = "PATCH"
result.base = "https://api.scaleway.com"
result.url = f"/domain/v2beta1/dns-zones/{zone_name}/records"
result.params = {"changes": [{"add": {"records": records}}]}
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
return result
def normalize_name(self, name: str) -> str:
"""Map the name to a standard form"""
name = re.sub("'", "-", name)
return slugify(name)
def zone_name(self, name: str) -> str:
"""Derive the zone name from the commune name"""
normalized = self.normalize_name(name)
return f"{normalized}.{settings.DNS_PROVISIONING_TARGET_ZONE}"
def complete_commune_creation(self, name: str) -> ApiCall:
"""Specify the tasks to be completed after a commune is created."""
inputs = {"name": self.normalize_name(name)}
create_zone = ApiCall()
create_zone.method = "POST"
create_zone.base = "https://api.scaleway.com"
create_zone.url = "/domain/v2beta1/dns-zones"
create_zone.params = {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": settings.DNS_PROVISIONING_TARGET_ZONE,
"subdomain": inputs["name"],
}
create_zone.headers = {
"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS
}
zone_name = self.zone_name(inputs["name"])
create_domain = ApiCall()
create_domain.method = "POST"
create_domain.base = settings.MAIL_PROVISIONING_API_URL
create_domain.url = "/domains/"
create_domain.params = {
"name": zone_name,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": zone_name,
}
create_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
spec_domain = ApiCall()
spec_domain.inputs = inputs
spec_domain.base = settings.MAIL_PROVISIONING_API_URL
spec_domain.url = f"/domains/{zone_name}/spec"
spec_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_zone, create_domain, spec_domain]
def complete_zone_creation(self, spec_call):
"""Specify the tasks to be performed to set up the zone."""
return self.dns_call(spec_call)
def run_after_create(self, organization):
"""After creating an organization, update the organization name."""
logger.info("In CommuneCreation")
if not organization.registration_id_list:
# No registration ID to convert...
return
# In the nominal case, there is only one registration ID because
# the organization has been created from it.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.get(self._api_url.format(siret=siret), timeout=10)
response.raise_for_status()
data = response.json()
name = self.get_organization_name_from_results(data, siret)
# Not a commune ?
if not name:
return
except requests.RequestException as exc:
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
return
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
zone_name = self.zone_name(name)
support = "support-regie@numerique.gouv.fr"
MailDomain.objects.get_or_create(name=zone_name, support_email=support)
# Compute and execute the rest of the process
tasks = self.complete_commune_creation(name)
for task in tasks:
task.execute()
last_task = self.complete_zone_creation(tasks[-1])
last_task.execute()
def complete_grant_access(self, sub, zone_name):
"""Specify the tasks to be completed after making a user admin"""
create_user = ApiCall()
create_user.method = "POST"
create_user.base = settings.MAIL_PROVISIONING_API_URL
create_user.url = "/users/"
create_user.params = {
"name": sub,
"password": "no",
"is_admin": False,
"perms": [],
}
create_user.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
grant_access = ApiCall()
grant_access.method = "POST"
grant_access.base = settings.MAIL_PROVISIONING_API_URL
grant_access.url = "/allows/"
grant_access.params = {
"user": sub,
"domain": zone_name,
}
grant_access.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_user, grant_access]
def run_after_grant_access(self, organization_access):
"""After granting an organization access, check for needed domain access grant."""
orga = organization_access.organization
user = organization_access.user
zone_name = self.zone_name(orga.name)
try:
domain = MailDomain.objects.get(name=zone_name)
except MailDomain.DoesNotExist:
domain = None
if domain:
MailDomainAccess.objects.create(
domain=domain, user=user, role=MailDomainRoleChoices.OWNER
)
tasks = self.complete_grant_access(user.sub, zone_name)
for task in tasks:
task.execute()
@@ -0,0 +1,231 @@
"""Tests for the CommuneCreation plugin."""
from django.conf import settings
from django.test.utils import override_settings
import pytest
import responses
from plugins.la_suite.hooks_utils.communes import ApiCall, CommuneCreation
pytestmark = pytest.mark.django_db
def test_extract_name_from_org_data_when_commune():
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
plugin = CommuneCreation()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
def test_api_call_execution():
"""Test that calling execute() faithfully executes the API call"""
task = ApiCall()
task.method = "POST"
task.base = "https://some_host"
task.url = "some_url"
task.params = {"some_key": "some_value"}
task.headers = {"Some-Header": "Some-Header-Value"}
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
url="https://some_host/some_url",
body='{"some_key": "some_value"}',
content_type="application/json",
headers={"Some-Header": "Some-Header-Value"},
)
task.execute()
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_zone_creation():
"""Test the first task in commune creation: creating the DNS sub-zone"""
plugin = CommuneCreation()
name = "Varzy"
tasks = plugin.complete_commune_creation(name)
assert tasks[0].base == "https://api.scaleway.com"
assert tasks[0].url == "/domain/v2beta1/dns-zones"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": "collectivite.fr",
"subdomain": "varzy",
}
assert tasks[0].headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_dimail_domain_creation():
"""Test the second task in commune creation: creating the domain in Dimail"""
plugin = CommuneCreation()
name = "Merlaut"
tasks = plugin.complete_commune_creation(name)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/domains/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"name": "merlaut.collectivite.fr",
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": "merlaut.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_fetching_spec():
"""Test the third task in commune creation: asking Dimail for the spec"""
plugin = CommuneCreation()
name = "Loc-Eguiner"
tasks = plugin.complete_commune_creation(name)
assert tasks[2].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[2].url == "/domains/loc-eguiner.collectivite.fr/spec"
assert tasks[2].method == "GET"
assert (
tasks[2].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_dns_records():
"""Test the next several tasks in commune creation: creating records"""
plugin = CommuneCreation()
name = "Abidos"
spec_response = [
{"target": "", "type": "mx", "value": "mx.dev.ox.numerique.gouv.fr."},
{
"target": "dimail._domainkey",
"type": "txt",
"value": "v=DKIM1; h=sha256; k=rsa; p=MIICIjANB<truncated>AAQ==",
},
{"target": "imap", "type": "cname", "value": "imap.dev.ox.numerique.gouv.fr."},
{"target": "smtp", "type": "cname", "value": "smtp.dev.ox.numerique.gouv.fr."},
{
"target": "",
"type": "txt",
"value": "v=spf1 include:_spf.dev.ox.numerique.gouv.fr -all",
},
{
"target": "webmail",
"type": "cname",
"value": "webmail.dev.ox.numerique.gouv.fr.",
},
]
tasks = plugin.complete_commune_creation(name)
tasks[2].response_data = spec_response
expected = {
"changes": [
{
"add": {
"records": [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec_response
]
}
}
]
}
zone_call = plugin.complete_zone_creation(tasks[2])
assert zone_call.params == expected
assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records"
assert (
zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_grant_access():
"""Test the final tasks after making user admin of an org"""
plugin = CommuneCreation()
tasks = plugin.complete_grant_access("some-sub", "mezos.collectivite.fr")
assert tasks[0].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[0].url == "/users/"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"name": "some-sub",
"password": "no",
"is_admin": False,
"perms": [],
}
assert (
tasks[0].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/allows/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"user": "some-sub",
"domain": "mezos.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_normalize_name():
"""Test name normalization"""
plugin = CommuneCreation()
assert plugin.normalize_name("Asnières-sur-Saône") == "asnieres-sur-saone"
assert plugin.normalize_name("Bâgé-le-Châtel") == "bage-le-chatel"
assert plugin.normalize_name("Courçais") == "courcais"
assert plugin.normalize_name("Moÿ-de-l'Aisne") == "moy-de-l-aisne"
assert plugin.normalize_name("Salouël") == "salouel"
assert (
plugin.normalize_name("Bors (Canton de Tude-et-Lavalette)")
== "bors-canton-de-tude-et-lavalette"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_zone_name():
"""Test transforming a commune name to a sub-zone of collectivite.fr"""
plugin = CommuneCreation()
assert plugin.zone_name("Bâgé-le-Châtel") == "bage-le-chatel.collectivite.fr"
@@ -26,6 +26,15 @@ def test_hooks_loaded():
]
assert organization_created_hook_names == [
"get_organization_name_and_metadata_from_siret_hook",
"commune_organization_created",
]
organization_access_granted_hook_names = [
callback.__name__
for callback in registry.get_callbacks("organization_access_granted")
]
assert organization_access_granted_hook_names == [
"commune_organization_access_granted"
]
# cleanup the hooks
+45 -46
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.24.0"
version = "1.17.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,51 +17,50 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.11",
]
description = "An application to handle contacts and teams."
keywords = ["Django", "Contacts", "Teams", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = "~=3.14.2"
requires-python = ">=3.11"
dependencies = [
"Brotli==1.2.0",
"PyJWT==2.12.0",
"boto3==1.42.62",
"celery[redis]==5.6.2",
"django-celery-beat==2.9.0",
"Brotli==1.1.0",
"PyJWT==2.10.1",
"boto3==1.38.33",
"celery[redis]==5.5.3",
"django-celery-beat==2.8.1",
"django-celery-results==2.6.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-extensions==4.1",
"django-lasuite==0.0.25",
"django-oauth-toolkit==3.2.0",
"django-lasuite==0.0.9",
"django-oauth-toolkit==3.0.1",
"django-parler==2.3",
"django-redis==6.0.0",
"django-redis==5.4.0",
"django-storages==1.14.6",
"django-timezone-field>=5.1",
"django-treebeard==5.0.5",
"django-zxcvbn-password-validator==1.5.3",
"django==6.0.3",
"djangorestframework==3.16.1",
"dockerflow==2026.3.4",
"drf_spectacular==0.29.0",
"drf_spectacular[sidecar]==0.29.0",
"easy_thumbnails==2.10.1",
"django-treebeard==4.7.1",
"django-zxcvbn-password-validator==1.4.5",
"django==5.2.3",
"djangorestframework==3.16.0",
"dockerflow==2024.4.2",
"drf_spectacular==0.28.0",
"drf_spectacular[sidecar]==0.28.0",
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"flower==2.0.1",
"gunicorn==25.1.0",
"jaraco.context>=6.1.0",
"joserfc==1.6.3",
"jsonschema==4.26.0",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.3",
"redis<=7.2.1",
"requests==2.32.5",
"sentry-sdk[django]==2.54.0",
"whitenoise==6.12.0",
"gunicorn==23.0.0",
"joserfc==1.1.0",
"jsonschema==4.24.0",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.9",
"redis==5.2.1",
"requests==2.32.4",
"sentry-sdk[django]==2.29.1",
"whitenoise==6.9.0",
]
[project.urls]
@@ -72,22 +71,22 @@ dependencies = [
[project.optional-dependencies]
dev = [
"drf-spectacular-sidecar==2026.3.1",
"drf-spectacular-sidecar==2025.6.1",
"ipdb==0.13.13",
"ipython==9.11.0",
"jq==1.11.0",
"pyfakefs==6.1.4",
"pylint-django==2.7.0",
"pylint==4.0.5",
"pytest-cov==7.0.0",
"pytest-django==4.12.0",
"pytest==9.0.2",
"ipython==9.3.0",
"jq==1.8.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.1.1",
"pytest-django==4.11.1",
"pytest==8.4.0",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.0",
"ruff==0.15.5",
"types-requests==2.32.4.20260107",
"freezegun==1.5.5",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.11.13",
"types-requests==2.32.0.20250602",
"freezegun==1.5.2",
]
[tool.setuptools]
-1803
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -47,13 +47,6 @@ RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
# Upgrade system packages to install security updates
USER root
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
USER -
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+1 -1
View File
@@ -1 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
+3 -862
View File
@@ -1,864 +1,5 @@
import { getThemesFromGlobals } from '@gouvfr-lasuite/cunningham-tokens';
import { cunninghamConfig } from '@gouvfr-lasuite/ui-kit';
export const commonTokenOverrides = {
components: {
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
modal: {
'width-small': '342px',
},
tooltip: {
padding: '4px 8px',
},
button: {
'medium-height': '40px',
'medium-text-height': '40px',
'border-radius': '4px',
'border-radius--active': '4px',
'border-radius--focus': '4px',
},
datagrid: {
'header--color': 'ref(contextuals.content.semantic.neutral.primary)',
'header--size': '12px',
'header--weight': '500',
'body--background-color-hover':
'ref(contextuals.background.semantic.neutral.tertiary)',
},
'forms-checkbox': {
'font-size': 'ref(globals.font.sizes.sm)',
},
badge: {
'font-size': 'ref(globals.font.sizes.xs)',
'border-radius': '12px',
'padding-inline': 'ref(globals.spacings.xs)',
'padding-block': 'ref(globals.spacings.2xs)',
accent: {
'background-color':
'ref(contextuals.background.semantic.brand.secondary)',
color: 'ref(contextuals.content.semantic.brand.secondary)',
},
neutral: {
'background-color':
'ref(contextuals.background.semantic.neutral.secondary)',
color: 'ref(contextuals.content.semantic.neutral.secondary)',
},
danger: {
'background-color':
'ref(contextuals.background.semantic.error.secondary)',
color: 'ref(contextuals.content.semantic.error.secondary)',
},
success: {
'background-color':
'ref(contextuals.background.semantic.success.secondary)',
color: 'ref(contextuals.content.semantic.success.secondary)',
},
warning: {
'background-color':
'ref(contextuals.background.semantic.warning.secondary)',
color: 'ref(contextuals.content.semantic.warning.secondary)',
},
info: {
'background-color':
'ref(contextuals.background.semantic.info.secondary)',
color: 'ref(contextuals.content.semantic.info.secondary)',
},
},
},
export default {
...cunninghamConfig,
};
export const commonGlobals = {
components: {
'la-gaufre': false,
'home-proconnect': false,
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
'xl-alt': '5rem',
'lg-alt': '4.5rem',
'md-alt': '4rem',
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: {
thin: 100,
extrabold: 800,
black: 900,
},
families: {
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
spacings: {
'0': '0',
none: '0',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
'2xl': '3rem',
xxxl: '3.5rem',
'3xl': '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
xs: '480px',
mobile: '768px',
tablet: '1024px',
},
};
export const whiteLabelGlobals = {
colors: {
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
'brand-200': '#BEC5F0',
'brand-250': '#AFB5F1',
'brand-300': '#A0A5F6',
'brand-350': '#8F94FD',
'brand-400': '#8184FC',
'brand-450': '#7576EE',
'brand-500': '#6969DF',
'brand-550': '#5E5CD0',
'brand-600': '#534FC2',
'brand-650': '#4844AD',
'brand-700': '#3E3B98',
'brand-750': '#36347D',
'brand-800': '#2D2F5F',
'brand-850': '#262848',
'brand-900': '#1C1E32',
'brand-950': '#11131F',
'gray-000': '#FFFFFF',
'gray-025': '#F8F8F9',
'gray-050': '#F0F0F3',
'gray-100': '#E2E2EA',
'gray-150': '#D3D4E0',
'gray-200': '#C5C6D5',
'gray-250': '#B7B7CB',
'gray-300': '#A9A9BF',
'gray-350': '#9C9CB2',
'gray-400': '#8F8FA4',
'gray-450': '#828297',
'gray-500': '#75758A',
'gray-550': '#69697D',
'gray-600': '#5D5D70',
'gray-650': '#515164',
'gray-700': '#454558',
'gray-750': '#3A3A4C',
'gray-800': '#2F303D',
'gray-850': '#25252F',
'gray-900': '#1B1B23',
'gray-950': '#111114',
'gray-1000': '#000000',
'info-050': '#EAF2F9',
'info-100': '#D5E4F3',
'info-150': '#BFD7F0',
'info-200': '#A7CAEE',
'info-250': '#8DBDEF',
'info-300': '#6EB0F2',
'info-350': '#50A2F5',
'info-400': '#3593F4',
'info-450': '#1185ED',
'info-500': '#0077DE',
'info-550': '#0069CF',
'info-600': '#005BC0',
'info-650': '#0D4EAA',
'info-700': '#124394',
'info-750': '#163878',
'info-800': '#192F5A',
'info-850': '#192541',
'info-900': '#141B2D',
'info-950': '#0C111C',
'success-050': '#E8F1EA',
'success-100': '#CFE4D4',
'success-150': '#BAD9C1',
'success-200': '#A2CFAD',
'success-250': '#86C597',
'success-300': '#6CBA83',
'success-350': '#4FB070',
'success-400': '#40A363',
'success-450': '#309556',
'success-500': '#1E884A',
'success-550': '#027B3E',
'success-600': '#016D31',
'success-650': '#006024',
'success-700': '#005317',
'success-750': '#0D4511',
'success-800': '#11380E',
'success-850': '#132A11',
'success-900': '#101E0F',
'success-950': '#091209',
'warning-050': '#F8F0E9',
'warning-100': '#F1E0D3',
'warning-150': '#ECD0BC',
'warning-200': '#E8C0A4',
'warning-250': '#E8AE8A',
'warning-300': '#EB9970',
'warning-350': '#E98456',
'warning-400': '#E57036',
'warning-450': '#DA5E18',
'warning-500': '#CB5000',
'warning-550': '#BC4200',
'warning-600': '#AD3300',
'warning-650': '#9E2300',
'warning-700': '#882011',
'warning-750': '#731E16',
'warning-800': '#58201A',
'warning-850': '#401D18',
'warning-900': '#2E1714',
'warning-950': '#1D0F0D',
'error-050': '#F9EFEC',
'error-100': '#F4DFD9',
'error-150': '#F0CEC6',
'error-200': '#EEBCB2',
'error-250': '#EEA99D',
'error-300': '#EF9486',
'error-350': '#F37C6E',
'error-400': '#F65F53',
'error-450': '#F0463D',
'error-500': '#E82322',
'error-550': '#D7010E',
'error-600': '#C00100',
'error-650': '#AA0000',
'error-700': '#910C06',
'error-750': '#731E16',
'error-800': '#58201A',
'error-850': '#401D18',
'error-900': '#2E1714',
'error-950': '#1D0F0D',
'red-050': '#FAEFEE',
'red-100': '#F4DEDD',
'red-150': '#F1CDCB',
'red-200': '#EFBBBA',
'red-250': '#EEA8A8',
'red-300': '#F09394',
'red-350': '#F37B7E',
'red-400': '#EF6569',
'red-450': '#E94A55',
'red-500': '#DA3B49',
'red-550': '#CA2A3C',
'red-600': '#BB1330',
'red-650': '#A90021',
'red-700': '#910A13',
'red-750': '#731E16',
'red-800': '#58201A',
'red-850': '#411D18',
'red-900': '#2E1714',
'red-950': '#1D0F0D',
'orange-050': '#F8F0E9',
'orange-100': '#F1E0D3',
'orange-150': '#ECD0BD',
'orange-200': '#EABFA6',
'orange-250': '#EBAC90',
'orange-300': '#EC9772',
'orange-350': '#E5845A',
'orange-400': '#D6774D',
'orange-450': '#C86A40',
'orange-500': '#B95D33',
'orange-550': '#AB5025',
'orange-600': '#9D4315',
'orange-650': '#8F3600',
'orange-700': '#812900',
'orange-750': '#6C2511',
'orange-800': '#572017',
'orange-850': '#401D18',
'orange-900': '#2E1714',
'orange-950': '#1D0F0D',
'brown-050': '#F6F0E8',
'brown-100': '#F1E0D3',
'brown-150': '#EBD0BA',
'brown-200': '#E2C0A6',
'brown-250': '#D4B398',
'brown-300': '#C6A58B',
'brown-350': '#B8987E',
'brown-400': '#AA8B71',
'brown-450': '#9D7E65',
'brown-500': '#8F7158',
'brown-550': '#82654C',
'brown-600': '#765841',
'brown-650': '#694C35',
'brown-700': '#5D412A',
'brown-750': '#51361E',
'brown-800': '#452A13',
'brown-850': '#392008',
'brown-900': '#29180A',
'brown-950': '#1B0F08',
'yellow-050': '#F3F0E7',
'yellow-100': '#E9E2CF',
'yellow-150': '#E1D4B7',
'yellow-200': '#D9C599',
'yellow-250': '#D2B677',
'yellow-300': '#CAA756',
'yellow-350': '#C2972E',
'yellow-400': '#B98900',
'yellow-450': '#AB7B00',
'yellow-500': '#9D6E00',
'yellow-550': '#916100',
'yellow-600': '#855400',
'yellow-650': '#784700',
'yellow-700': '#6C3A00',
'yellow-750': '#5F2E00',
'yellow-800': '#512302',
'yellow-850': '#3E1D10',
'yellow-900': '#2D1711',
'yellow-950': '#1D0F0D',
'green-050': '#E6F1E9',
'green-100': '#CFE4D5',
'green-150': '#B8D8C1',
'green-200': '#A0CFAE',
'green-250': '#84C59A',
'green-300': '#65BA86',
'green-350': '#45B173',
'green-400': '#23A562',
'green-450': '#029755',
'green-500': '#008948',
'green-550': '#017B3B',
'green-600': '#006E2E',
'green-650': '#006022',
'green-700': '#005314',
'green-750': '#0D4510',
'green-800': '#11380E',
'green-850': '#132A11',
'green-900': '#101E0F',
'green-950': '#091209',
'blue-1-050': '#EBF1F9',
'blue-1-100': '#D6E4F4',
'blue-1-150': '#C1D7F0',
'blue-1-200': '#AACAEF',
'blue-1-250': '#8FBCEF',
'blue-1-300': '#7CAFEB',
'blue-1-350': '#68A1E4',
'blue-1-400': '#5B94D6',
'blue-1-450': '#4E86C7',
'blue-1-500': '#4279B9',
'blue-1-550': '#356CAC',
'blue-1-600': '#28609E',
'blue-1-650': '#1B5390',
'blue-1-700': '#0B4783',
'blue-1-750': '#0F3C6E',
'blue-1-800': '#133059',
'blue-1-850': '#152641',
'blue-1-900': '#121C2D',
'blue-1-950': '#0B111C',
'blue-2-050': '#E7F3F4',
'blue-2-100': '#CEE7E9',
'blue-2-150': '#B2DCE0',
'blue-2-200': '#91D1D7',
'blue-2-250': '#68C7D0',
'blue-2-300': '#43BBC5',
'blue-2-350': '#00AFBA',
'blue-2-400': '#01A0AA',
'blue-2-450': '#00929D',
'blue-2-500': '#00848F',
'blue-2-550': '#007682',
'blue-2-600': '#016874',
'blue-2-650': '#005B67',
'blue-2-700': '#004E5A',
'blue-2-750': '#00424E',
'blue-2-800': '#003642',
'blue-2-850': '#002A38',
'blue-2-900': '#061E28',
'blue-2-950': '#071219',
'purple-050': '#F7F0F6',
'purple-100': '#EEE0EE',
'purple-150': '#E7D1E7',
'purple-200': '#DBBFE4',
'purple-250': '#D3AEE2',
'purple-300': '#CB99E1',
'purple-350': '#C188D9',
'purple-400': '#B47BCB',
'purple-450': '#A66EBD',
'purple-500': '#9961AF',
'purple-550': '#8B55A1',
'purple-600': '#7E4894',
'purple-650': '#723C87',
'purple-700': '#633376',
'purple-750': '#552A65',
'purple-800': '#452551',
'purple-850': '#35213D',
'purple-900': '#261A2C',
'purple-950': '#17111C',
'pink-050': '#F8EFF4',
'pink-100': '#F0DFEA',
'pink-150': '#EACEDF',
'pink-200': '#E9BBD1',
'pink-250': '#E9A7C2',
'pink-300': '#E095B4',
'pink-350': '#D685A8',
'pink-400': '#C7799B',
'pink-450': '#B86C8D',
'pink-500': '#AA5F80',
'pink-550': '#9C5374',
'pink-600': '#8E4767',
'pink-650': '#813B5B',
'pink-700': '#732E4F',
'pink-750': '#632643',
'pink-800': '#521F38',
'pink-850': '#3E1C2B',
'pink-900': '#2D171F',
'pink-950': '#1C0E12',
'black-000': '#1B1B2300',
'black-050': '#1B1B230D',
'black-100': '#1B1B231A',
'black-150': '#1B1B2326',
'black-200': '#1B1B2333',
'black-250': '#1B1B2340',
'black-300': '#1B1B234D',
'black-350': '#1B1B2359',
'black-400': '#1B1B2366',
'black-450': '#1B1B2373',
'black-500': '#1B1B2380',
'black-550': '#1B1B238C',
'black-600': '#1B1B2399',
'black-650': '#1B1B23A6',
'black-700': '#1B1B23B2',
'black-750': '#1B1B23BF',
'black-800': '#1B1B23CC',
'black-850': '#1B1B23D9',
'black-900': '#1B1B23E5',
'black-950': '#111114F2',
'white-000': '#F8F8F900',
'white-050': '#F8F8F90D',
'white-100': '#F8F8F91A',
'white-150': '#F8F8F926',
'white-200': '#F8F8F933',
'white-250': '#F8F8F940',
'white-300': '#F8F8F94D',
'white-350': '#F8F8F959',
'white-400': '#F8F8F966',
'white-450': '#F8F8F973',
'white-500': '#F8F8F980',
'white-550': '#F8F8F98C',
'white-600': '#F8F8F999',
'white-650': '#F8F8F9A6',
'white-700': '#F8F8F9B2',
'white-750': '#F8F8F9BF',
'white-800': '#F8F8F9CC',
'white-850': '#F8F8F9D9',
'white-900': '#F8F8F9E5',
'white-950': '#F8F8F9F2',
'white-975': '#F8F8F9F9',
},
...commonGlobals,
font: {
...commonGlobals.font,
families: {
base: 'Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif',
accent: 'Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif',
},
},
};
export const dsfrGlobals = {
...commonGlobals,
components: {
...commonGlobals.components,
'la-gaufre': true,
'home-proconnect': true,
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
},
colors: {
'logo-1': '#2845C1',
'logo-2': '#C83F49',
'brand-050': '#EDF0FF',
'brand-100': '#DAE2FF',
'brand-150': '#C8D3FF',
'brand-200': '#B5C4FF',
'brand-250': '#A2B6FF',
'brand-300': '#90A7FF',
'brand-350': '#7E98FF',
'brand-400': '#6C89FE',
'brand-450': '#5C7AF7',
'brand-500': '#4C6CEF',
'brand-550': '#3E5DE7',
'brand-600': '#304DDF',
'brand-650': '#2845C1',
'brand-700': '#223E9E',
'brand-750': '#1F367D',
'brand-800': '#1B2E5F',
'brand-850': '#172446',
'brand-900': '#121B30',
'brand-950': '#0C111A',
'gray-100': '#DFE2EA',
'gray-150': '#CFD5DE',
'gray-200': '#C1C7D3',
'gray-250': '#B2B9C7',
'gray-300': '#A4ABBC',
'gray-350': '#969EB0',
'gray-400': '#8891A4',
'gray-450': '#7B8498',
'gray-500': '#6D778C',
'gray-550': '#626A80',
'gray-600': '#555E74',
'gray-650': '#4A5267',
'gray-700': '#3F4759',
'gray-750': '#363B4C',
'gray-800': '#2B303D',
'gray-850': '#222631',
'gray-900': '#181B24',
'gray-950': '#0F1117',
'gray-1000': '#000000',
'info-050': '#E7F2FF',
'info-100': '#CFE5FF',
'info-150': '#B7D7FF',
'info-200': '#A0CAFE',
'info-250': '#8CBCF9',
'info-300': '#77AEF4',
'info-350': '#63A0EE',
'info-400': '#5092E7',
'info-450': '#4185DC',
'info-500': '#3677CC',
'info-550': '#2F6ABB',
'info-600': '#265EAA',
'info-650': '#28528F',
'info-700': '#274775',
'info-750': '#243C5E',
'info-800': '#20314A',
'info-850': '#1B2637',
'info-900': '#141C27',
'info-950': '#0D1118',
'success-050': '#DEF7E6',
'success-100': '#BAEECF',
'success-150': '#A5E2C0',
'success-200': '#95D4B3',
'success-250': '#85C6A7',
'success-300': '#74B99B',
'success-350': '#65AB8F',
'success-400': '#579E84',
'success-450': '#4B9079',
'success-500': '#40836F',
'success-550': '#367664',
'success-600': '#2B695A',
'success-650': '#2C5A50',
'success-700': '#2A4D45',
'success-750': '#26403C',
'success-800': '#213430',
'success-850': '#1B2826',
'success-900': '#151D1C',
'success-950': '#0D1212',
'warning-050': '#FFEEDF',
'warning-100': '#FFDCBE',
'warning-150': '#FFCA9C',
'warning-200': '#FFB778',
'warning-250': '#FDA54F',
'warning-300': '#F59425',
'warning-350': '#E78613',
'warning-400': '#D7790C',
'warning-450': '#C86C08',
'warning-500': '#B85F03',
'warning-550': '#A75400',
'warning-600': '#984800',
'warning-650': '#814112',
'warning-700': '#6C3A19',
'warning-750': '#58321C',
'warning-800': '#452A1A',
'warning-850': '#352117',
'warning-900': '#261813',
'warning-950': '#170F0C',
'error-050': '#FFEDEB',
'error-100': '#FFDAD7',
'error-150': '#FFC7C2',
'error-200': '#FFB3AD',
'error-250': '#FF9F99',
'error-300': '#FF8984',
'error-350': '#FF706E',
'error-400': '#FB5759',
'error-450': '#F63A45',
'error-500': '#E32C39',
'error-550': '#CF202D',
'error-600': '#BD0F23',
'error-650': '#9D2227',
'error-700': '#812727',
'error-750': '#672624',
'error-800': '#512220',
'error-850': '#3D1C1B',
'error-900': '#2A1614',
'error-950': '#190E0D',
'red-050': '#FFEDEB',
'red-100': '#FFDAD7',
'red-150': '#FFC7C2',
'red-200': '#FFB3AD',
'red-250': '#FF9F99',
'red-300': '#FF8984',
'red-350': '#FF706E',
'red-400': '#FB5759',
'red-450': '#F63A45',
'red-500': '#E32C39',
'red-550': '#CF202D',
'red-600': '#BD0F23',
'red-650': '#9D2227',
'red-700': '#812727',
'red-750': '#672624',
'red-800': '#512220',
'red-850': '#3D1C1B',
'red-900': '#410003',
'red-950': '#190E0D',
'orange-050': '#FCEDEB',
'orange-100': '#F8DCD7',
'orange-150': '#F1CCC5',
'orange-200': '#EABCB4',
'orange-250': '#E2ACA2',
'orange-300': '#DA9C92',
'orange-350': '#D28C81',
'orange-400': '#CA7C70',
'orange-450': '#BE6E62',
'orange-500': '#AE6257',
'orange-550': '#9E564D',
'orange-600': '#8F4B42',
'orange-650': '#79443D',
'orange-700': '#643C37',
'orange-750': '#513430',
'orange-800': '#412B28',
'orange-850': '#312220',
'orange-900': '#231918',
'orange-950': '#150F0F',
'brown-050': '#F9EFEA',
'brown-100': '#F3DFD3',
'brown-150': '#EACFC1',
'brown-200': '#E2BFAE',
'brown-250': '#D8B19C',
'brown-300': '#D0A189',
'brown-350': '#C3937B',
'brown-400': '#B5866D',
'brown-450': '#A77A62',
'brown-500': '#996D57',
'brown-550': '#8B614D',
'brown-600': '#7C5542',
'brown-650': '#6A4C3C',
'brown-700': '#594236',
'brown-750': '#49382F',
'brown-800': '#3B2E28',
'brown-850': '#2D2420',
'brown-900': '#201A18',
'brown-950': '#13100F',
'yellow-050': '#FDF1C5',
'yellow-100': '#FBE18E',
'yellow-150': '#F4D261',
'yellow-200': '#EAC244',
'yellow-250': '#DFB41B',
'yellow-300': '#D1A516',
'yellow-350': '#C49711',
'yellow-400': '#B78A0C',
'yellow-450': '#A87D07',
'yellow-500': '#9B6F02',
'yellow-550': '#8D6300',
'yellow-600': '#7F5600',
'yellow-650': '#6E4C11',
'yellow-700': '#5D4219',
'yellow-750': '#4D371B',
'yellow-800': '#3D2E1A',
'yellow-850': '#2F2417',
'yellow-900': '#221A12',
'yellow-950': '#14100C',
'green-050': '#E7F9B3',
'green-100': '#D5EC98',
'green-150': '#C5DE86',
'green-200': '#B5D174',
'green-250': '#A5C464',
'green-300': '#95B755',
'green-350': '#85AA45',
'green-400': '#769D39',
'green-450': '#688F30',
'green-500': '#5A8228',
'green-550': '#4D7621',
'green-600': '#416919',
'green-650': '#3A5B20',
'green-700': '#324E22',
'green-750': '#2C4122',
'green-800': '#24351D',
'green-850': '#1D2919',
'green-900': '#161E13',
'green-950': '#0E120C',
'blue-1-050': '#E7F2FF',
'blue-1-100': '#CFE5FF',
'blue-1-150': '#B7D7FF',
'blue-1-200': '#A0CAFE',
'blue-1-250': '#8CBCF9',
'blue-1-300': '#77AEF4',
'blue-1-350': '#63A0EE',
'blue-1-400': '#5092E7',
'blue-1-450': '#4185DC',
'blue-1-500': '#3677CC',
'blue-1-550': '#2F6ABB',
'blue-1-600': '#265EAA',
'blue-1-650': '#28528F',
'blue-1-700': '#274775',
'blue-1-750': '#243C5E',
'blue-1-800': '#20314A',
'blue-1-850': '#1B2637',
'blue-1-900': '#141C27',
'blue-1-950': '#0D1118',
'blue-2-050': '#E2F4FD',
'blue-2-100': '#C4E8F8',
'blue-2-150': '#AADCF2',
'blue-2-200': '#93CFEB',
'blue-2-250': '#7CC2E2',
'blue-2-300': '#6CB4D6',
'blue-2-350': '#5CA7C9',
'blue-2-400': '#5099BC',
'blue-2-450': '#458BAE',
'blue-2-500': '#3A7EA0',
'blue-2-550': '#327191',
'blue-2-600': '#286483',
'blue-2-650': '#2B5770',
'blue-2-700': '#294A5E',
'blue-2-750': '#263E4D',
'blue-2-800': '#22323D',
'blue-2-850': '#1C272E',
'blue-2-900': '#151D21',
'blue-2-950': '#0E1114',
'purple-050': '#F5EEFF',
'purple-100': '#ECDCFF',
'purple-150': '#E2CBFF',
'purple-200': '#D9B9FF',
'purple-250': '#D0A7FF',
'purple-300': '#C894FE',
'purple-350': '#BE83FA',
'purple-400': '#B570F5',
'purple-450': '#AB5EF0',
'purple-500': '#A04BE8',
'purple-550': '#933CDB',
'purple-600': '#8530C8',
'purple-650': '#7033A5',
'purple-700': '#5D3185',
'purple-750': '#4C2C6A',
'purple-800': '#3C2652',
'purple-850': '#2D203C',
'purple-900': '#21182A',
'purple-950': '#130F19',
'pink-050': '#FFEBF6',
'pink-100': '#FFD8ED',
'pink-150': '#FCC4E3',
'pink-200': '#F7B2D9',
'pink-250': '#F29FCE',
'pink-300': '#ED8CC3',
'pink-350': '#E779B8',
'pink-400': '#E264AD',
'pink-450': '#D2579E',
'pink-500': '#C24B8E',
'pink-550': '#B0417F',
'pink-600': '#9F3670',
'pink-650': '#873560',
'pink-700': '#6F3250',
'pink-750': '#5A2C43',
'pink-800': '#472635',
'pink-850': '#351F29',
'pink-900': '#26171D',
'pink-950': '#170F12',
'black-000': '#181B2400',
'black-050': '#181B240D',
'black-100': '#181B241A',
'black-150': '#181B2426',
'black-200': '#181B2433',
'black-250': '#181B2440',
'black-300': '#181B244D',
'black-350': '#181B2459',
'black-400': '#181B2466',
'black-450': '#181B2473',
'black-500': '#181B2480',
'black-550': '#181B248C',
'black-600': '#181B2499',
'black-650': '#181B24A6',
'black-700': '#181B24B2',
'black-750': '#181B24BF',
'black-800': '#181B24CC',
'black-850': '#181B24D9',
'black-900': '#181B24E5',
'black-950': '#0F1117F2',
'white-000': '#F6F8F900',
'white-050': '#F6F8F90D',
'white-100': '#F6F8F91A',
'white-150': '#F6F8F926',
'white-200': '#F6F8F933',
'white-250': '#F6F8F940',
'white-300': '#F6F8F94D',
'white-350': '#F6F8F959',
'white-400': '#F6F8F966',
'white-450': '#F6F8F973',
'white-500': '#F6F8F980',
'white-550': '#F6F8F98C',
'white-600': '#F6F8F999',
'white-650': '#F6F8F9A6',
'white-700': '#F6F8F9B2',
'white-750': '#F6F8F9BF',
'white-800': '#F6F8F9CC',
'white-850': '#F6F8F9D9',
'white-900': '#F6F8F9E5',
'white-950': '#F6F8F9F2',
'white-975': '#F6F8F9F9',
},
};
const whiteLabelThemes = getThemesFromGlobals(whiteLabelGlobals, {
overrides: commonTokenOverrides,
});
const dsfrThemes = getThemesFromGlobals(dsfrGlobals, {
overrides: commonTokenOverrides,
});
if (dsfrThemes.dark) {
dsfrThemes.dark.globals.components.logo.src =
'/assets/logo-gouv-darkmode.svg';
dsfrThemes.dark.globals.colors['logo-1'] = '#95ABFF';
dsfrThemes.dark.globals.colors['logo-2'] = '#E78087';
}
console.log(dsfrThemes);
const themes = {
themes: {
default: whiteLabelThemes.light,
dark: whiteLabelThemes.dark,
dsfr: dsfrThemes.light,
'dsfr-dark': dsfrThemes.dark,
},
};
const config = { ...themes };
export default config;
-1
View File
@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
-1
View File
@@ -41,7 +41,6 @@ const nextConfig = {
return config;
},
turbopack: {},
};
module.exports = nextConfig;

Some files were not shown because too many files have changed in this diff Show More