Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f866529307 | |||
| 99eaec9653 | |||
| 2ab3a67a44 | |||
| 0fd29e82d5 | |||
| 0776b7d95a | |||
| 19f7bb77c0 | |||
| 83cae4036c | |||
| ed07eecad4 | |||
| 46ba75b119 | |||
| 1b4d1043af | |||
| bc458b3f68 | |||
| 5bc845172b | |||
| 8a35e862ff | |||
| bc0dbcb678 | |||
| 68ec85e94a | |||
| ca754d5bcd | |||
| 06df255072 | |||
| da1a9e1d7a | |||
| 74ada8fd6b | |||
| 0c123bb9de | |||
| 2a6367c3ac | |||
| 79bdc6606e | |||
| c53434411d | |||
| b9c69e7c08 | |||
| b1e0a24626 | |||
| a1b58fb864 | |||
| e1a8cc31f9 | |||
| d0e5aa5952 | |||
| 3b58fb7e1e | |||
| 1e02ded7b8 | |||
| 92fcb359c9 | |||
| 8c9b35ec47 | |||
| 4e75d675f8 | |||
| 1d4d40aad0 | |||
| aaa9b27c61 | |||
| a29ef05d8b | |||
| 40c39bd9ba | |||
| 59f9f54b34 | |||
| 7804583632 | |||
| 63b8984eb2 | |||
| 0df8f53037 | |||
| 41084baa0f | |||
| 32de0b9221 | |||
| 1384640a3a | |||
| 569aff05a1 | |||
| b13f4db536 | |||
| 8ace3099b9 | |||
| 38d2125ecf | |||
| 6ae195b90c | |||
| be64abb22f | |||
| ca56eb0cac | |||
| 99ca34719f | |||
| 014fb62f95 | |||
| 99433a6722 | |||
| 5ebc88bcff | |||
| a65e61bd96 | |||
| b8beb56135 | |||
| 37e5b5b346 | |||
| 31201fbd59 | |||
| eb0683ffe0 | |||
| 54219d25b8 | |||
| 89e7703d53 | |||
| 7d44e54913 | |||
| 01583ba94f | |||
| 5feee53bdd | |||
| 9c62efc9f8 | |||
| d2ef9e0beb | |||
| bc1cbef168 | |||
| 8ab1b2e2ef | |||
| f498e3b6d2 | |||
| e47573e22f | |||
| c7aab6f5b5 | |||
| 2144ad5da1 | |||
| f00deeebe9 | |||
| 30aea9b350 | |||
| e01b422c13 | |||
| 040f949e5e | |||
| befcecf33c | |||
| c5edabf3b0 | |||
| 3a2b5a6428 | |||
| 15337a2226 | |||
| 0f7e312eb6 | |||
| 23561cd0e0 | |||
| 53d0336755 | |||
| b79e12b4be | |||
| c237bb4b10 | |||
| 64068efff4 | |||
| 6866babb32 | |||
| 80caa062e9 | |||
| 513dbe6f83 | |||
| 490fdd2ed4 | |||
| fa7d24545f | |||
| e5938e144e | |||
| 39af4313cf | |||
| abfe32569e | |||
| 964981bbfb | |||
| 33faa5f224 | |||
| 99967b450e | |||
| 71a7bf688f | |||
| 302671bc69 | |||
| 4262f469d6 | |||
| b24cb23a83 | |||
| 608f8c6988 | |||
| 2ddfef59d8 | |||
| 6b8cb16bd5 | |||
| 8b89a1112d | |||
| 230ff21220 | |||
| a37c2a8e83 | |||
| 80da8bea74 | |||
| 76f4bf36c7 | |||
| 78a5d907ca | |||
| 0ad60ab292 | |||
| ac443d3b6f | |||
| 48ff0e9f3a | |||
| 675e719a22 | |||
| 0875fae87b | |||
| 668a296142 | |||
| f1892b7049 | |||
| 1bfad507ef | |||
| 72e73bff45 | |||
| e45cf8dd8b | |||
| 79f8e5276a | |||
| 4f97685204 | |||
| 935058582e | |||
| 636b27321b | |||
| 2fdddb3e12 | |||
| f7a97e11e8 | |||
| a56f86965f | |||
| 89674ad718 | |||
| 4e592382dc | |||
| 1fdd2e052b | |||
| 25918830ff | |||
| b47282430e | |||
| ebd3f467c4 | |||
| 0f4ec63a9f | |||
| fdec3b4196 |
@@ -10,7 +10,7 @@ jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
|
||||
synchronize-with-crowdin:
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- 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@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
install-dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
@@ -20,19 +20,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .
|
||||
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
|
||||
working-directory: src/backend
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
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 python manage.py makemessages -a --keep-pot
|
||||
DJANGO_CONFIGURATION=Build uv run python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '18.x'
|
||||
default: '22.x'
|
||||
type: string
|
||||
with-front-dependencies-installation:
|
||||
type: boolean
|
||||
@@ -20,16 +20,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
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@v4
|
||||
uses: actions/setup-node@v6
|
||||
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@v4
|
||||
uses: actions/cache@v5
|
||||
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@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
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@v4
|
||||
uses: actions/setup-node@v6
|
||||
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@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -58,7 +58,10 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
@@ -82,7 +85,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -99,7 +102,10 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
dependencies:
|
||||
uses: ./.github/workflows/dependencies.yml
|
||||
with:
|
||||
node_version: '18.x'
|
||||
node_version: '22.x'
|
||||
with-front-dependencies-installation: true
|
||||
with-build_mails: true
|
||||
|
||||
@@ -22,20 +22,24 @@ jobs:
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
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:
|
||||
@@ -45,17 +49,17 @@ jobs:
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
|
||||
run: git log --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@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
@@ -69,10 +73,10 @@ jobs:
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
run: cd src/frontend/ && yarn ci:build
|
||||
|
||||
- name: Cache build frontend
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
@@ -92,10 +96,10 @@ jobs:
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
@@ -109,10 +113,10 @@ jobs:
|
||||
needs: dependencies
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
@@ -132,7 +136,7 @@ jobs:
|
||||
shardTotal: [4]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
@@ -140,7 +144,7 @@ jobs:
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
@@ -148,14 +152,14 @@ jobs:
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
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@v4
|
||||
uses: actions/cache@v5
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
@@ -216,19 +220,22 @@ jobs:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
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
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
run: uv run ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
run: uv run ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
run: uv run pylint .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -262,29 +269,35 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
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
|
||||
|
||||
- name: Install gettext (required to compile messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
run: uv run pytest -n 2
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
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
|
||||
+118
-1
@@ -8,8 +8,115 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✨(mailboxes) enforce lowercase on mailboxes
|
||||
- 🐛(i18n) fix missing translations for status tag labels
|
||||
- 🚚(route) prioritize mail domains as default landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- 🍱(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
|
||||
@@ -416,7 +523,17 @@ 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.18.1...main
|
||||
[unreleased]: https://github.com/suitenumerique/people/compare/v1.23.1...main
|
||||
[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
|
||||
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
|
||||
|
||||
+37
-25
@@ -1,10 +1,7 @@
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
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
|
||||
FROM python:3.14.2-alpine AS base
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
@@ -13,17 +10,30 @@ RUN apk update && \
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Copy required python dependencies
|
||||
COPY ./src/backend /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
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
# 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
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
FROM node:22 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -42,14 +52,13 @@ 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
|
||||
@@ -80,14 +89,18 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy people application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /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.
|
||||
@@ -102,10 +115,9 @@ USER root:root
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall people and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
RUN pip uninstall -y people
|
||||
RUN pip install -e .[dev]
|
||||
# Install development dependencies
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.10,source=/uv,target=/bin/uv \
|
||||
uv sync --all-extras --locked
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
@@ -107,7 +107,8 @@ bootstrap: \
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev
|
||||
@$(COMPOSE) build app-dev frontend-dev
|
||||
@$(COMPOSE) build dimail
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -123,7 +124,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 frontend-dev celery-dev celery-beat-dev nginx maildev
|
||||
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
|
||||
.PHONY: run-dev
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@@ -214,7 +215,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
|
||||
@@ -240,21 +241,21 @@ resetdb: ## flush database and create a superuser "admin"
|
||||
.PHONY: resetdb
|
||||
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
cp --update=none env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/france:
|
||||
cp -n env.d/development/france.dist env.d/development/france
|
||||
cp --update=none env.d/development/france.dist env.d/development/france
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
cp --update=none env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
cp --update=none env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
cp --update=none 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,6 +1,6 @@
|
||||
# People
|
||||
|
||||
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
|
||||
People is an application to handle users and teams, and distribute permissions across [La Suite](https://lasuite.numerique.gouv.fr/).
|
||||
|
||||
It is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/).
|
||||
|
||||
+4
-4
@@ -42,12 +42,11 @@ 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:
|
||||
@@ -119,6 +118,7 @@ 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:18
|
||||
image: node:22
|
||||
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.2.11
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.5.2
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
|
||||
+27
-23
@@ -17,48 +17,52 @@ 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:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
- Create a new branch named: `release/4.18.1`.
|
||||
|
||||
2. Bump the release number for backend and frontend project.
|
||||
- Bump the release number for backend and frontend project.
|
||||
|
||||
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
- Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
- Commit your changes with the following format: 🔖 release emoji, type of release (patch/minor/patch) and release version:
|
||||
|
||||
```text
|
||||
🔖(minor) release version 4.18.1
|
||||
```
|
||||
|
||||
3. Open a pull request ask you to wait for an approval from your peers and merge it.
|
||||
- Triggers Crowdin translation action and open related PR
|
||||
|
||||
4. Ask you to tag and push your commit:
|
||||
- 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:
|
||||
|
||||
```bash
|
||||
git tag v4.18.1 && git push origin tag v4.18.1
|
||||
```
|
||||
|
||||
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
6. Create a PR on the [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement) repository to bump the preprod version.
|
||||
# Deploying
|
||||
|
||||
7. The release is now done!
|
||||
## Staging
|
||||
|
||||
# Deploying
|
||||
The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
## Pre-prod and production
|
||||
|
||||
Making a new release doesn't publish it automatically in production.
|
||||
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.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
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
|
||||
|
||||
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
|
||||
```
|
||||
The release is now done! 🎉
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""People custom admin site."""
|
||||
@@ -1,9 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,15 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -621,6 +621,7 @@ 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,8 +17,6 @@ from core.models import (
|
||||
AccountService,
|
||||
Contact,
|
||||
Organization,
|
||||
OrganizationAccess,
|
||||
OrganizationRoleChoices,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -131,15 +129,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
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,4 +1,3 @@
|
||||
# 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(len(alphabet), alphabet)
|
||||
converter = NumConv(alphabet)
|
||||
|
||||
nodes = Team.objects.all().order_by("created_at")
|
||||
for i, node in enumerate(nodes, 1):
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -813,7 +814,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]
|
||||
@@ -900,7 +901,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]
|
||||
@@ -996,19 +997,22 @@ class BaseInvitation(BaseModel):
|
||||
super().clean()
|
||||
|
||||
# Check if a user already exists for the provided email
|
||||
if User.objects.filter(email=self.email).exists():
|
||||
raise exceptions.ValidationError(
|
||||
{"email": _("This email is already associated to a registered user.")}
|
||||
)
|
||||
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()
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Calculate if invitation is still valid or has expired."""
|
||||
if not self.created_at:
|
||||
if not self.updated_at:
|
||||
return None
|
||||
|
||||
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
return timezone.now() > (self.created_at + validity_duration)
|
||||
return timezone.now() > (self.updated_at + validity_duration)
|
||||
|
||||
def _get_mail_subject(self):
|
||||
"""Get the subject of the invitation."""
|
||||
@@ -1096,7 +1100,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]
|
||||
|
||||
@@ -18,6 +18,7 @@ class BasePluginAppConfigMixIn:
|
||||
Initialize the hooks registry when the application is ready.
|
||||
This is called by Django when the application is loaded.
|
||||
"""
|
||||
from .registry import registry # pylint: disable=import-outside-toplevel
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .registry import registry # noqa: PLC0415
|
||||
|
||||
registry.register_app(self.name) # pylint: disable=no-member
|
||||
|
||||
@@ -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: 12 KiB After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before 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(2):
|
||||
with django_assert_num_queries(4):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -160,7 +160,8 @@ def test_authentication_getter_existing_user_via_email(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(3): # user by email + user by sub + update sub
|
||||
with django_assert_num_queries(5):
|
||||
# 5 = user by email + user by sub + update sub + 2 from django-lasuite
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -390,6 +391,8 @@ 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
|
||||
|
||||
@@ -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.rfc7518.rsa_key import RSAKey
|
||||
from joserfc.jwk import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
from lasuite.oidc_resource_server.authentication import (
|
||||
ResourceServerAuthentication,
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
|
||||
@@ -21,18 +20,20 @@ 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(5):
|
||||
with django_assert_num_queries(6):
|
||||
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": 5,
|
||||
"active_domains": 7,
|
||||
"mailboxes": 0,
|
||||
"teams": 3,
|
||||
"aliases": 2,
|
||||
}
|
||||
# no new request made due to caching
|
||||
with django_assert_num_queries(0):
|
||||
@@ -41,9 +42,10 @@ def test_api_stats__anonymous(django_assert_num_queries):
|
||||
assert response.json() == {
|
||||
"total_users": 0,
|
||||
"mau": 0,
|
||||
"active_domains": 5,
|
||||
"active_domains": 7,
|
||||
"mailboxes": 0,
|
||||
"teams": 3,
|
||||
"aliases": 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +60,9 @@ def test_api_stats__expected_count():
|
||||
|
||||
core_factories.TeamFactory.create_batch(3)
|
||||
domains_factories.MailDomainFactory.create_batch(1)
|
||||
domains_factories.MailDomainEnabledFactory.create_batch(2)
|
||||
domains_factories.MailboxFactory.create_batch(
|
||||
10, domain=domains_models.MailDomain.objects.all()[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)
|
||||
|
||||
# clear cache to allow stats count
|
||||
cache.clear()
|
||||
@@ -73,4 +74,5 @@ def test_api_stats__expected_count():
|
||||
"active_domains": 2,
|
||||
"mailboxes": 10,
|
||||
"teams": 3,
|
||||
"aliases": 2,
|
||||
}
|
||||
|
||||
@@ -171,10 +171,11 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["email"] == [
|
||||
"This email is already associated to a registered user."
|
||||
]
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert (
|
||||
response.json()["detail"]
|
||||
== "Email already known. Invitation not sent but access created instead."
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_invitations__list__anonymous_user():
|
||||
|
||||
@@ -16,6 +16,7 @@ from faker import Faker
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core import factories, models
|
||||
from core.exceptions import EmailAlreadyKnownException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -48,6 +49,17 @@ 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'):
|
||||
|
||||
@@ -230,6 +230,9 @@ 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
|
||||
|
||||
@@ -45,7 +45,7 @@ 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:
|
||||
if "tchap.gouv.fr" in webhook_url and "i.tchap.gouv.fr" not in webhook_url:
|
||||
base_url = f"matrix.{base_url}"
|
||||
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
|
||||
|
||||
|
||||
@@ -4,5 +4,7 @@ NB_OBJECTS = {
|
||||
"users": 1000,
|
||||
"teams": 100,
|
||||
"max_users_per_team": 100,
|
||||
"domains": 20,
|
||||
"domains": 10,
|
||||
"mailboxes_per_domain": 2,
|
||||
"aliases_per_domain": 2,
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ def create_oidc_people_idp_client_user():
|
||||
)
|
||||
|
||||
|
||||
def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
def create_demo(stdout): # pylint: disable=too-many-branches too-many-statements too-many-locals # noqa: PLR0912, PLR0915
|
||||
"""
|
||||
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,14 +288,12 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
domains = mailbox_models.MailDomain.objects.all()
|
||||
with Timeit(stdout, "Creating accesses to domains"):
|
||||
domains_ids = list(
|
||||
mailbox_models.MailDomain.objects.values_list("id", flat=True)
|
||||
)
|
||||
for domain_id in domains_ids:
|
||||
for domain in domains:
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domain_id,
|
||||
domain=domain,
|
||||
user_id=random.choice(users_ids),
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
@@ -303,6 +301,40 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
|
||||
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
|
||||
@@ -337,7 +369,7 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
queue.push(user_with_mail)
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domains_ids[0],
|
||||
domain=domains[0],
|
||||
user_id=user_with_mail.pk,
|
||||
role=role,
|
||||
)
|
||||
@@ -363,7 +395,7 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
)
|
||||
queue.push(
|
||||
mailbox_models.MailDomainAccess(
|
||||
domain_id=domains_ids[0],
|
||||
domain=domains[0],
|
||||
user_id=team_mail_user.pk,
|
||||
role=domain_role,
|
||||
)
|
||||
@@ -371,17 +403,62 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
|
||||
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",
|
||||
# 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}",
|
||||
)
|
||||
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.Alias.objects.create(
|
||||
local_part=fake.word(),
|
||||
destination=fake.email(),
|
||||
domain=many_objects_domain,
|
||||
)
|
||||
|
||||
# OIDC configuration
|
||||
|
||||
@@ -19,7 +19,9 @@ TEST_NB_OBJECTS = {
|
||||
"users": 100,
|
||||
"teams": 100,
|
||||
"max_users_per_team": 5,
|
||||
"domains": 100,
|
||||
"domains": 10,
|
||||
"mailboxes_per_domain": 2,
|
||||
"aliases_per_domain": 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -33,25 +35,38 @@ def test_commands_create_demo(settings):
|
||||
call_command("create_demo")
|
||||
|
||||
# Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
|
||||
# 3 user with team rights
|
||||
# 3 user with domain rights
|
||||
# 3 users with team rights
|
||||
# 3 users 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
|
||||
mailbox_models.MailDomain.objects.count()
|
||||
== TEST_NB_OBJECTS["domains"] + 1 + 1 + 1
|
||||
)
|
||||
|
||||
# 3 domain access for each user with domain rights
|
||||
# 3 x 3 domain access for each user with both rights
|
||||
# 1 domain for E2E mail owner user
|
||||
# 2 domains for E2E mail owner user
|
||||
assert (
|
||||
mailbox_models.MailDomainAccess.objects.count()
|
||||
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 1
|
||||
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 2
|
||||
)
|
||||
|
||||
# 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():
|
||||
"""
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-19 15:31+0000\n"
|
||||
"PO-Revision-Date: 2025-06-30 10:05\n"
|
||||
"POT-Creation-Date: 2026-02-13 11:10+0000\n"
|
||||
"PO-Revision-Date: 2026-02-16 14:19\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,567 +17,336 @@ msgstr ""
|
||||
"X-Crowdin-File: backend.pot\n"
|
||||
"X-Crowdin-File-ID: 2\n"
|
||||
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
#: core/admin.py:62
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:73 core/admin.py:73
|
||||
#: core/admin.py:73
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:85 core/admin.py:85
|
||||
#: core/admin.py:85
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:124 core/admin.py:124
|
||||
#: core/admin.py:124
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:226 core/admin.py:226
|
||||
#: core/admin.py:226
|
||||
msgid "Run post creation plugins"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:234 core/admin.py:234
|
||||
#: core/admin.py:234
|
||||
msgid "Post creation plugins have been run for the selected organizations."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:65 core/apps.py:65
|
||||
#: core/apps.py:65
|
||||
msgid "People core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:104
|
||||
#: core/authentication/backends.py:104
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:124
|
||||
#: core/authentication/backends.py:124
|
||||
#: core/authentication/backends.py:122
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:181
|
||||
#: build/lib/core/authentication/backends.py:183
|
||||
#: core/authentication/backends.py:181 core/authentication/backends.py:183
|
||||
#: core/authentication/backends.py:170 core/authentication/backends.py:172
|
||||
msgid "Invalid authorization header."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:188
|
||||
#: core/authentication/backends.py:188
|
||||
#: core/authentication/backends.py:177
|
||||
msgid "Invalid api key."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/enums.py:24 core/enums.py:24
|
||||
#: core/enums.py:24
|
||||
msgid "Failure"
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#: core/enums.py:25 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/enums.py:26 core/enums.py:26
|
||||
#: core/enums.py:26
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:76 core/models.py:76
|
||||
#: core/exceptions.py:16
|
||||
msgid "Email already known. Invitation not sent but access created instead."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:77
|
||||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#: core/models.py:78 core/models.py:90 mailbox_manager/enums.py:14
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
|
||||
#: core/models.py:78 mailbox_manager/enums.py:15
|
||||
#: core/models.py:79 mailbox_manager/enums.py:15
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:101 core/models.py:101
|
||||
#: core/models.py:102
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:102 core/models.py:102
|
||||
#: core/models.py:103
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:108 core/models.py:108
|
||||
#: core/models.py:109
|
||||
msgid "created at"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:109 core/models.py:109
|
||||
#: core/models.py:110
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:114 core/models.py:114
|
||||
#: core/models.py:115
|
||||
msgid "updated at"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:115 core/models.py:115
|
||||
#: core/models.py:116
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:155
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: core/models.py:156
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
#: core/models.py:159
|
||||
msgid "notes"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: core/models.py:161
|
||||
msgid "contact information"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:162
|
||||
msgid "A JSON object containing the contact information"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
#: core/models.py:176
|
||||
msgid "contact"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: core/models.py:177
|
||||
msgid "contacts"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:250 build/lib/core/models.py:364
|
||||
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
|
||||
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
|
||||
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:53
|
||||
#: core/models.py:251 core/models.py:365 core/models.py:511 core/models.py:1125
|
||||
#: mailbox_manager/models.py:56
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:252 core/models.py:252
|
||||
#: core/models.py:253
|
||||
msgid "audience id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:257 core/models.py:257
|
||||
#: core/models.py:258
|
||||
msgid "service provider"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:258 core/models.py:258
|
||||
#: core/models.py:259
|
||||
msgid "service providers"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:372 core/models.py:372
|
||||
#: core/models.py:373
|
||||
msgid "registration ID list"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:379 core/models.py:379
|
||||
#: core/models.py:380
|
||||
msgid "domain list"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:386 core/models.py:386
|
||||
#: core/models.py:387
|
||||
msgid "metadata"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:387 core/models.py:387
|
||||
#: core/models.py:388
|
||||
msgid "A JSON object containing the organization metadata"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
|
||||
#: core/models.py:535
|
||||
#: core/models.py:398 core/models.py:536
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:403 core/models.py:403
|
||||
#: core/models.py:404
|
||||
msgid "organization"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
#: core/models.py:405
|
||||
msgid "organizations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: core/models.py:412
|
||||
msgid "An organization must have at least a registration ID or a domain."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:495 core/models.py:495
|
||||
#: core/models.py:496
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:501 core/models.py:501
|
||||
#: core/models.py:502
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:503 core/models.py:503
|
||||
#: core/models.py:504
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:509 build/lib/core/models.py:973 core/models.py:509
|
||||
#: core/models.py:973
|
||||
#: core/models.py:510 core/models.py:974
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:515 core/models.py:515
|
||||
#: core/models.py:516
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:516 core/models.py:516
|
||||
#: core/models.py:517
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:522 core/models.py:522
|
||||
#: core/models.py:523
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:525 core/models.py:525
|
||||
#: core/models.py:526
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:527 core/models.py:527
|
||||
#: core/models.py:528
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:530 core/models.py:530
|
||||
#: core/models.py:531
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:532 core/models.py:532
|
||||
#: core/models.py:533
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:538 core/models.py:538
|
||||
#: core/models.py:539
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:557 core/models.py:557
|
||||
#: core/models.py:558
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:558 core/models.py:558
|
||||
#: core/models.py:559
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:696 core/models.py:696
|
||||
#: core/models.py:697
|
||||
msgid "Organization/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:697 core/models.py:697
|
||||
#: core/models.py:698
|
||||
msgid "Organization/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:702 core/models.py:702
|
||||
#: core/models.py:703
|
||||
msgid "This user is already in this organization."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
#: core/models.py:762
|
||||
msgid "external_id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: core/models.py:763
|
||||
msgid "Team external UUID for synchronization with external systems"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:787 core/models.py:787
|
||||
#: core/models.py:788
|
||||
msgid "is visible for all SP"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:789 core/models.py:789
|
||||
#: core/models.py:790
|
||||
msgid "Whether this team is visible to all service providers."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:797 core/models.py:797
|
||||
#: core/models.py:798
|
||||
msgid "Team"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:798 core/models.py:798
|
||||
#: core/models.py:799
|
||||
msgid "Teams"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:849 core/models.py:849
|
||||
#: core/models.py:850
|
||||
msgid "Team/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: core/models.py:851
|
||||
msgid "Team/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#: core/models.py:856
|
||||
msgid "This user is already in this team."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:941 core/models.py:941
|
||||
#: core/models.py:942
|
||||
msgid "url"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:942 core/models.py:942
|
||||
#: core/models.py:943
|
||||
msgid "secret"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
#: core/models.py:957
|
||||
msgid "Team webhook"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:957 core/models.py:957
|
||||
#: core/models.py:958
|
||||
msgid "Team webhooks"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1001 core/models.py:1001
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1015 core/models.py:1015
|
||||
#: core/models.py:1019
|
||||
msgid "Invitation to join La Régie!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1061 core/models.py:1061
|
||||
#: core/models.py:1065
|
||||
msgid "Team invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1062 core/models.py:1062
|
||||
#: core/models.py:1066
|
||||
msgid "Team invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1075 core/models.py:1075
|
||||
#: core/models.py:1079
|
||||
#, python-format
|
||||
msgid "[La Suite] You have been invited to become a %(role)s of a group"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1123 core/models.py:1123
|
||||
#: core/models.py:1127
|
||||
msgid "api key"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1128 core/models.py:1128
|
||||
#: core/models.py:1132
|
||||
msgid "allowed scopes"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1129 core/models.py:1129
|
||||
#: core/models.py:1133
|
||||
msgid "Allowed scopes for this service"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1134 core/models.py:1134
|
||||
#: core/models.py:1138
|
||||
msgid "Account service"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1135 core/models.py:1135
|
||||
#: core/models.py:1139
|
||||
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"
|
||||
@@ -869,3 +638,236 @@ 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:409
|
||||
msgid "Mail domain invitation"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:410
|
||||
msgid "Mail domain invitations"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:439
|
||||
msgid "[La Suite] You have been invited to join La Régie"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:483
|
||||
msgid "destination address"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:501
|
||||
msgid "Alias"
|
||||
msgstr ""
|
||||
|
||||
#: mailbox_manager/models.py:502
|
||||
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 ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-19 15:31+0000\n"
|
||||
"PO-Revision-Date: 2025-07-02 08:51\n"
|
||||
"POT-Creation-Date: 2026-02-13 11:10+0000\n"
|
||||
"PO-Revision-Date: 2026-02-16 14:19\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,567 +17,336 @@ msgstr ""
|
||||
"X-Crowdin-File: backend.pot\n"
|
||||
"X-Crowdin-File-ID: 2\n"
|
||||
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
#: core/admin.py:62
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: build/lib/core/admin.py:73 core/admin.py:73
|
||||
#: core/admin.py:73
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: build/lib/core/admin.py:85 core/admin.py:85
|
||||
#: core/admin.py:85
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: build/lib/core/admin.py:124 core/admin.py:124
|
||||
#: core/admin.py:124
|
||||
msgid "User"
|
||||
msgstr "Utilisateur"
|
||||
|
||||
#: build/lib/core/admin.py:226 core/admin.py:226
|
||||
#: core/admin.py:226
|
||||
msgid "Run post creation plugins"
|
||||
msgstr "Exécuter les plugins de post-création"
|
||||
|
||||
#: build/lib/core/admin.py:234 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."
|
||||
|
||||
#: build/lib/core/apps.py:65 core/apps.py:65
|
||||
#: core/apps.py:65
|
||||
msgid "People core application"
|
||||
msgstr "Application cœur de People"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:104
|
||||
#: core/authentication/backends.py:104
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:124
|
||||
#: core/authentication/backends.py:124
|
||||
#: core/authentication/backends.py:122
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:181
|
||||
#: build/lib/core/authentication/backends.py:183
|
||||
#: core/authentication/backends.py:181 core/authentication/backends.py:183
|
||||
#: core/authentication/backends.py:170 core/authentication/backends.py:172
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "En-tête d'autorisation invalide."
|
||||
|
||||
#: build/lib/core/authentication/backends.py:188
|
||||
#: core/authentication/backends.py:188
|
||||
#: core/authentication/backends.py:177
|
||||
msgid "Invalid api key."
|
||||
msgstr "Clé API invalide."
|
||||
|
||||
#: build/lib/core/enums.py:24 core/enums.py:24
|
||||
#: core/enums.py:24
|
||||
msgid "Failure"
|
||||
msgstr "En échec"
|
||||
|
||||
#: 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
|
||||
#: core/enums.py:25 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: build/lib/core/enums.py:26 core/enums.py:26
|
||||
#: core/enums.py:26
|
||||
msgid "Success"
|
||||
msgstr "Réussi"
|
||||
|
||||
#: build/lib/core/models.py:76 core/models.py:76
|
||||
#: 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
|
||||
msgid "Member"
|
||||
msgstr "Membre"
|
||||
|
||||
#: 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
|
||||
#: core/models.py:78 core/models.py:90 mailbox_manager/enums.py:14
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
|
||||
#: core/models.py:78 mailbox_manager/enums.py:15
|
||||
#: core/models.py:79 mailbox_manager/enums.py:15
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: build/lib/core/models.py:101 core/models.py:101
|
||||
#: core/models.py:102
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:102 core/models.py:102
|
||||
#: core/models.py:103
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement en tant que UUID"
|
||||
|
||||
#: build/lib/core/models.py:108 core/models.py:108
|
||||
#: core/models.py:109
|
||||
msgid "created at"
|
||||
msgstr "créé le"
|
||||
|
||||
#: build/lib/core/models.py:109 core/models.py:109
|
||||
#: core/models.py:110
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure de création de l'enregistrement"
|
||||
|
||||
#: build/lib/core/models.py:114 core/models.py:114
|
||||
#: core/models.py:115
|
||||
msgid "updated at"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: build/lib/core/models.py:115 core/models.py:115
|
||||
#: core/models.py:116
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
#: core/models.py:155
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: core/models.py:156
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
#: core/models.py:159
|
||||
msgid "notes"
|
||||
msgstr "notes"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: core/models.py:161
|
||||
msgid "contact information"
|
||||
msgstr "informations de contact"
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
#: core/models.py:162
|
||||
msgid "A JSON object containing the contact information"
|
||||
msgstr "Un objet JSON contenant les informations de contact"
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
#: core/models.py:176
|
||||
msgid "contact"
|
||||
msgstr "contact"
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: core/models.py:177
|
||||
msgid "contacts"
|
||||
msgstr "contacts"
|
||||
|
||||
#: build/lib/core/models.py:250 build/lib/core/models.py:364
|
||||
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
|
||||
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
|
||||
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:53
|
||||
#: core/models.py:251 core/models.py:365 core/models.py:511 core/models.py:1125
|
||||
#: mailbox_manager/models.py:56
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
#: build/lib/core/models.py:252 core/models.py:252
|
||||
#: core/models.py:253
|
||||
msgid "audience id"
|
||||
msgstr "ID d'audience"
|
||||
|
||||
#: build/lib/core/models.py:257 core/models.py:257
|
||||
#: core/models.py:258
|
||||
msgid "service provider"
|
||||
msgstr "fournisseur de services"
|
||||
|
||||
#: build/lib/core/models.py:258 core/models.py:258
|
||||
#: core/models.py:259
|
||||
msgid "service providers"
|
||||
msgstr "fournisseurs de services"
|
||||
|
||||
#: build/lib/core/models.py:372 core/models.py:372
|
||||
#: core/models.py:373
|
||||
msgid "registration ID list"
|
||||
msgstr "liste d'identifiants d'inscription"
|
||||
|
||||
#: build/lib/core/models.py:379 core/models.py:379
|
||||
#: core/models.py:380
|
||||
msgid "domain list"
|
||||
msgstr "liste des domaines"
|
||||
|
||||
#: build/lib/core/models.py:386 core/models.py:386
|
||||
#: core/models.py:387
|
||||
msgid "metadata"
|
||||
msgstr "métadonnées"
|
||||
|
||||
#: build/lib/core/models.py:387 core/models.py:387
|
||||
#: core/models.py:388
|
||||
msgid "A JSON object containing the organization metadata"
|
||||
msgstr "Un objet JSON contenant les métadonnées de l'organisation"
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
|
||||
#: core/models.py:535
|
||||
#: core/models.py:398 core/models.py:536
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: build/lib/core/models.py:403 core/models.py:403
|
||||
#: core/models.py:404
|
||||
msgid "organization"
|
||||
msgstr "organisation"
|
||||
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
#: core/models.py:405
|
||||
msgid "organizations"
|
||||
msgstr "organisations"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: core/models.py:412
|
||||
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."
|
||||
|
||||
#: build/lib/core/models.py:495 core/models.py:495
|
||||
#: core/models.py:496
|
||||
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 @/./+/-/_."
|
||||
|
||||
#: build/lib/core/models.py:501 core/models.py:501
|
||||
#: core/models.py:502
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: build/lib/core/models.py:503 core/models.py:503
|
||||
#: core/models.py:504
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ seulement."
|
||||
|
||||
#: build/lib/core/models.py:509 build/lib/core/models.py:973 core/models.py:509
|
||||
#: core/models.py:973
|
||||
#: core/models.py:510 core/models.py:974
|
||||
msgid "email address"
|
||||
msgstr "adresse email"
|
||||
|
||||
#: build/lib/core/models.py:515 core/models.py:515
|
||||
#: core/models.py:516
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: build/lib/core/models.py:516 core/models.py:516
|
||||
#: core/models.py:517
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
|
||||
|
||||
#: build/lib/core/models.py:522 core/models.py:522
|
||||
#: core/models.py:523
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: build/lib/core/models.py:525 core/models.py:525
|
||||
#: core/models.py:526
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: build/lib/core/models.py:527 core/models.py:527
|
||||
#: core/models.py:528
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: build/lib/core/models.py:530 core/models.py:530
|
||||
#: core/models.py:531
|
||||
msgid "staff status"
|
||||
msgstr "statut d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:532 core/models.py:532
|
||||
#: core/models.py:533
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: build/lib/core/models.py:538 core/models.py:538
|
||||
#: core/models.py:539
|
||||
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."
|
||||
|
||||
#: build/lib/core/models.py:557 core/models.py:557
|
||||
#: core/models.py:558
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:558 core/models.py:558
|
||||
#: core/models.py:559
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: build/lib/core/models.py:696 core/models.py:696
|
||||
#: core/models.py:697
|
||||
msgid "Organization/user relation"
|
||||
msgstr "Relation organisation/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:697 core/models.py:697
|
||||
#: core/models.py:698
|
||||
msgid "Organization/user relations"
|
||||
msgstr "Relations organisation/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:702 core/models.py:702
|
||||
#: core/models.py:703
|
||||
msgid "This user is already in this organization."
|
||||
msgstr "Cet utilisateur est déjà dans cette organisation."
|
||||
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
#: core/models.py:762
|
||||
msgid "external_id"
|
||||
msgstr "ID externe"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: core/models.py:763
|
||||
msgid "Team external UUID for synchronization with external systems"
|
||||
msgstr ""
|
||||
msgstr "UUID externe de l'équipe pour la synchronisation avec des systèmes externes"
|
||||
|
||||
#: build/lib/core/models.py:787 core/models.py:787
|
||||
#: core/models.py:788
|
||||
msgid "is visible for all SP"
|
||||
msgstr "est visible pour tous les fournisseurs de service"
|
||||
|
||||
#: build/lib/core/models.py:789 core/models.py:789
|
||||
#: core/models.py:790
|
||||
msgid "Whether this team is visible to all service providers."
|
||||
msgstr "Si cette équipe est visible pour tous les fournisseurs de services."
|
||||
|
||||
#: build/lib/core/models.py:797 core/models.py:797
|
||||
#: core/models.py:798
|
||||
msgid "Team"
|
||||
msgstr "Équipe"
|
||||
|
||||
#: build/lib/core/models.py:798 core/models.py:798
|
||||
#: core/models.py:799
|
||||
msgid "Teams"
|
||||
msgstr "Équipes"
|
||||
|
||||
#: build/lib/core/models.py:849 core/models.py:849
|
||||
#: core/models.py:850
|
||||
msgid "Team/user relation"
|
||||
msgstr "Relation équipe/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: core/models.py:851
|
||||
msgid "Team/user relations"
|
||||
msgstr "Relations équipe/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#: core/models.py:856
|
||||
msgid "This user is already in this team."
|
||||
msgstr "Cet utilisateur est déjà dans cette équipe."
|
||||
|
||||
#: build/lib/core/models.py:941 core/models.py:941
|
||||
#: core/models.py:942
|
||||
msgid "url"
|
||||
msgstr "url"
|
||||
|
||||
#: build/lib/core/models.py:942 core/models.py:942
|
||||
#: core/models.py:943
|
||||
msgid "secret"
|
||||
msgstr "secret"
|
||||
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
#: core/models.py:957
|
||||
msgid "Team webhook"
|
||||
msgstr "Webhook d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:957 core/models.py:957
|
||||
#: core/models.py:958
|
||||
msgid "Team webhooks"
|
||||
msgstr "Webhooks d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:1001 core/models.py:1001
|
||||
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:1015 core/models.py:1015
|
||||
#: core/models.py:1019
|
||||
msgid "Invitation to join La Régie!"
|
||||
msgstr "Invitation à rejoindre La Régie !"
|
||||
|
||||
#: build/lib/core/models.py:1061 core/models.py:1061
|
||||
#: core/models.py:1065
|
||||
msgid "Team invitation"
|
||||
msgstr "Invitation d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:1062 core/models.py:1062
|
||||
#: core/models.py:1066
|
||||
msgid "Team invitations"
|
||||
msgstr "Invitations d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:1075 core/models.py:1075
|
||||
#: core/models.py:1079
|
||||
#, 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"
|
||||
|
||||
#: build/lib/core/models.py:1123 core/models.py:1123
|
||||
#: core/models.py:1127
|
||||
msgid "api key"
|
||||
msgstr "Clé d'API"
|
||||
|
||||
#: build/lib/core/models.py:1128 core/models.py:1128
|
||||
#: core/models.py:1132
|
||||
msgid "allowed scopes"
|
||||
msgstr "périmètres autorisés"
|
||||
|
||||
#: build/lib/core/models.py:1129 core/models.py:1129
|
||||
#: core/models.py:1133
|
||||
msgid "Allowed scopes for this service"
|
||||
msgstr "Périmètres d'application autorisés pour ce service"
|
||||
|
||||
#: build/lib/core/models.py:1134 core/models.py:1134
|
||||
#: core/models.py:1138
|
||||
msgid "Account service"
|
||||
msgstr "Compte de service"
|
||||
|
||||
#: build/lib/core/models.py:1135 core/models.py:1135
|
||||
#: core/models.py:1139
|
||||
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"
|
||||
@@ -660,7 +429,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 exploité."
|
||||
msgstr "Votre domaine <b>%(name)s</b> est prêt à être utilisé."
|
||||
|
||||
#: core/templates/mail/html/maildomain_enabled.html:211
|
||||
#: core/templates/mail/html/maildomain_invitation.html:211
|
||||
@@ -869,3 +638,236 @@ 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 l’ensemble 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:409
|
||||
msgid "Mail domain invitation"
|
||||
msgstr "Invitation au domaine de messagerie"
|
||||
|
||||
#: mailbox_manager/models.py:410
|
||||
msgid "Mail domain invitations"
|
||||
msgstr "Invitations au domaine de messagerie"
|
||||
|
||||
#: mailbox_manager/models.py:439
|
||||
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:483
|
||||
msgid "destination address"
|
||||
msgstr "adresse de destination"
|
||||
|
||||
#: mailbox_manager/models.py:501
|
||||
msgid "Alias"
|
||||
msgstr "Alias"
|
||||
|
||||
#: mailbox_manager/models.py:502
|
||||
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"
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ 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
|
||||
@@ -51,6 +48,51 @@ 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."""
|
||||
@@ -194,6 +236,7 @@ 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,
|
||||
@@ -207,8 +250,8 @@ class MailboxAdmin(UserAdmin):
|
||||
|
||||
list_display = ("__str__", "domain", "status", "updated_at")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("local_part", "domain__name")
|
||||
readonly_fields = ["updated_at", "local_part", "domain"]
|
||||
search_fields = ("local_part", "domain__name", "first_name", "last_name")
|
||||
readonly_fields = ["updated_at"]
|
||||
|
||||
fieldsets = None
|
||||
add_fieldsets = (
|
||||
@@ -267,3 +310,13 @@ 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,6 +3,8 @@
|
||||
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
|
||||
@@ -29,7 +31,6 @@ 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):
|
||||
@@ -45,12 +46,21 @@ class MailboxSerializer(serializers.ModelSerializer):
|
||||
"password": make_password(None), # generate an unusable password
|
||||
}
|
||||
)
|
||||
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
|
||||
client = DimailAPIClient()
|
||||
|
||||
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
|
||||
# send new mailbox request to dimail
|
||||
response = client.create_mailbox(mailbox, self.context["request"].user.sub)
|
||||
client = DimailAPIClient()
|
||||
try:
|
||||
response = client.create_mailbox(
|
||||
mailbox, self.context["request"].user.sub
|
||||
)
|
||||
except django_exceptions.ValidationError as exc:
|
||||
mailbox.delete()
|
||||
raise exc
|
||||
|
||||
mailbox.status = enums.MailDomainStatusChoices.ENABLED
|
||||
mailbox_data = response.json()
|
||||
mailbox.set_password(mailbox_data["password"])
|
||||
mailbox.save()
|
||||
|
||||
if mailbox.secondary_email:
|
||||
@@ -67,10 +77,25 @@ 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."""
|
||||
|
||||
@@ -203,23 +228,10 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
|
||||
"You must set a domain slug in kwargs to create a new domain access."
|
||||
) from exc
|
||||
|
||||
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."
|
||||
)
|
||||
# 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
|
||||
)
|
||||
|
||||
# only an owner can set an owner role to another user
|
||||
if (
|
||||
@@ -277,11 +289,41 @@ 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,13 +1,16 @@
|
||||
"""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, viewsets
|
||||
from rest_framework import exceptions, filters, mixins, status, 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
|
||||
@@ -40,7 +43,10 @@ class MailDomainViewSet(
|
||||
Fetch domain status and expected config from dimail.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
permission_classes = [
|
||||
permissions.DomainPermission,
|
||||
permissions.DomainResourcePermission,
|
||||
]
|
||||
serializer_class = serializers.MailDomainSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at", "name"]
|
||||
@@ -112,7 +118,7 @@ class MailDomainAccessViewSet(
|
||||
Delete targeted domain access
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.MailDomainAccessRolePermission]
|
||||
permission_classes = [permissions.DomainResourcePermission]
|
||||
serializer_class = serializers.MailDomainAccessSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["role", "user__email", "user__name"]
|
||||
@@ -228,9 +234,11 @@ class MailDomainAccessViewSet(
|
||||
|
||||
|
||||
class MailBoxViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
):
|
||||
"""MailBox ViewSet
|
||||
|
||||
@@ -252,9 +260,15 @@ 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.MailBoxPermission]
|
||||
permission_classes = [permissions.DomainResourcePermission]
|
||||
serializer_class = serializers.MailboxSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering = ["-created_at"]
|
||||
@@ -267,6 +281,32 @@ 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", "")
|
||||
@@ -310,6 +350,7 @@ class MailDomainInvitationViewset(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to domain management.
|
||||
@@ -324,13 +365,17 @@ 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.AccessPermission]
|
||||
permission_classes = [permissions.DomainResourcePermission]
|
||||
queryset = (
|
||||
models.MailDomainInvitation.objects.all()
|
||||
.select_related("domain")
|
||||
@@ -367,3 +412,126 @@ 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)
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
"""Permission handlers for the People mailbox manager app."""
|
||||
|
||||
from core.api import permissions as core_permissions
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core.api.permissions import IsAuthenticated
|
||||
|
||||
from mailbox_manager import models
|
||||
|
||||
|
||||
class AccessPermission(core_permissions.IsAuthenticated):
|
||||
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):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
@@ -14,20 +43,15 @@ class AccessPermission(core_permissions.IsAuthenticated):
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class MailBoxPermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class to manage mailboxes for a mail domain"""
|
||||
class IsMailboxOwnerPermission(permissions.BasePermission):
|
||||
"""Authorize update for domain viewers on their own mailbox."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission based on domain."""
|
||||
"""This permission is specifically about updates"""
|
||||
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
|
||||
abilities = domain.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
|
||||
"""Permission class to manage mailboxes for a mail domain"""
|
||||
return abilities["get"]
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
"""If the user is trying to update their own mailbox."""
|
||||
return obj.get_email() == request.user.email
|
||||
|
||||
@@ -15,4 +15,5 @@ class MailboxManagerConfig(AppConfig):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
import mailbox_manager.signals # pylint: disable=import-outside-toplevel, unused-import
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
import mailbox_manager.signals # noqa: PLC0415
|
||||
|
||||
@@ -98,3 +98,14 @@ 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")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# 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.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -9,12 +9,15 @@ 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 (
|
||||
@@ -88,6 +91,8 @@ 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()
|
||||
@@ -106,7 +111,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 [
|
||||
@@ -219,7 +224,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
|
||||
@@ -247,7 +252,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 [
|
||||
@@ -303,7 +308,23 @@ class Mailbox(AbstractBaseUser, BaseModel):
|
||||
db_table = "people_mail_box"
|
||||
verbose_name = _("Mailbox")
|
||||
verbose_name_plural = _("Mailboxes")
|
||||
unique_together = ("local_part", "domain")
|
||||
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
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
@@ -330,6 +351,8 @@ class Mailbox(AbstractBaseUser, BaseModel):
|
||||
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
|
||||
@@ -341,6 +364,25 @@ class Mailbox(AbstractBaseUser, BaseModel):
|
||||
"""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."""
|
||||
@@ -374,6 +416,23 @@ 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}"
|
||||
|
||||
@@ -402,7 +461,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 [
|
||||
@@ -416,3 +475,62 @@ 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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
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
|
||||
+78
-47
@@ -11,8 +11,7 @@ from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import enums, factories
|
||||
from mailbox_manager.api.client import serializers
|
||||
from mailbox_manager import enums, factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -20,13 +19,12 @@ 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/",
|
||||
invitation_values,
|
||||
{
|
||||
"email": "some.email@domain.com",
|
||||
"role": "admin",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -35,24 +33,23 @@ def test_api_domain_invitations__create__anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_domain_invitations__create__authenticated_outsider():
|
||||
def test_api_domain_invitations__create__no_access_forbidden_not_found():
|
||||
"""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/",
|
||||
invitation_values,
|
||||
{
|
||||
"email": "some.email@domain.com",
|
||||
"role": "viewer",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -65,28 +62,25 @@ 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/",
|
||||
invitation_values,
|
||||
{
|
||||
"email": "some.email@domain.com",
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [invitation_values["email"]]
|
||||
assert email.to == ["some.email@domain.com"]
|
||||
assert email.subject == "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
|
||||
|
||||
|
||||
def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
|
||||
def test_api_domain_invitations__no_access_forbidden_not_found():
|
||||
"""
|
||||
Domain viewers should not be able to invite new domain managers.
|
||||
"""
|
||||
@@ -96,21 +90,19 @@ def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
|
||||
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/",
|
||||
invitation_values,
|
||||
{
|
||||
"email": "some.email@domain.com",
|
||||
"role": "viewer",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage invitations for this domain."
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@@ -125,42 +117,81 @@ def test_api_domain_invitations__should_not_create_duplicate_invitations():
|
||||
domain=domain, user=user, role=enums.MailDomainRoleChoices.OWNER
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# New invitation to the same domain with the exact same email address
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
|
||||
duplicated_invitation,
|
||||
{
|
||||
"email": existing_invitation.email,
|
||||
"role": "owner",
|
||||
},
|
||||
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__should_not_invite_when_user_already_exists():
|
||||
def test_api_domain_invitations__inviting_known_email_should_create_access():
|
||||
"""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/",
|
||||
invitation_values,
|
||||
{
|
||||
"email": existing_user.email,
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["email"] == [
|
||||
"This email is already associated to a registered user."
|
||||
]
|
||||
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
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
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()
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
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()
|
||||
+3
-3
@@ -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__unrelated_user_should_not_retrieve_invitations():
|
||||
def test_api_domain_invitations__no_access_forbidden_not_found():
|
||||
"""
|
||||
Authenticated unrelated users should not be able to retrieve invitations.
|
||||
"""
|
||||
@@ -40,8 +40,8 @@ def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations(
|
||||
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 0
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No MailDomain matches the given query."}
|
||||
|
||||
|
||||
def test_api_domain_invitations__domain_managers_should_list_invitations():
|
||||
|
||||
@@ -41,17 +41,14 @@ def test_api_mail_domains__create_name_unique():
|
||||
"""
|
||||
Creating domain should raise an error if already existing name.
|
||||
"""
|
||||
factories.MailDomainFactory(name="existing_domain.com")
|
||||
user = core_factories.UserFactory()
|
||||
existing_domain = factories.MailDomainFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(core_factories.UserFactory())
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/mail-domains/",
|
||||
{
|
||||
"name": "existing_domain.com",
|
||||
},
|
||||
{"name": existing_domain.name},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "fetch" action.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
@@ -82,7 +81,7 @@ def test_api_mail_domains__fetch_from_dimail__viewer():
|
||||
],
|
||||
)
|
||||
@responses.activate
|
||||
def test_api_mail_domains__fetch_from_dimail(role):
|
||||
def test_api_mail_domains__fetch_from_dimail_admin_successful(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():
|
||||
def test_api_mail_domains__retrieve_authenticated_related_successful():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a domain
|
||||
to which they have access.
|
||||
|
||||
+5
-5
@@ -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_authenticated_unrelated():
|
||||
def test_api_mail_domain__accesses_create_no_access_forbidden_not_found():
|
||||
"""
|
||||
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_authenticated_unrelated():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this domain."
|
||||
"detail": "No MailDomain matches the given query.",
|
||||
}
|
||||
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_create_authenticated_viewer():
|
||||
def test_api_mail_domain__accesses_create_viewer_forbidden():
|
||||
"""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_authenticated_viewer():
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this domain."
|
||||
"detail": "You do not have permission to perform this action.",
|
||||
}
|
||||
|
||||
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
+7
-7
@@ -13,7 +13,7 @@ from mailbox_manager import enums, factories, models
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_anonymous():
|
||||
def test_api_domain_accesses_delete__anonymous_forbidden():
|
||||
"""Anonymous users should not be allowed to destroy a mail domain access."""
|
||||
access = factories.MailDomainAccessFactory()
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_api_mail_domain__accesses_delete_anonymous():
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_authenticated():
|
||||
def test_api_domain_accesses_delete__unrelated_notfound():
|
||||
"""
|
||||
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_mail_domain__accesses_delete_authenticated():
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_viewer():
|
||||
def test_api_domain_accesses_delete__viewer_forbidden():
|
||||
"""
|
||||
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_mail_domain__accesses_delete_viewer():
|
||||
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_administrators():
|
||||
def test_api_domain_accesses_delete__administrators_successful():
|
||||
"""
|
||||
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
|
||||
"""
|
||||
@@ -89,7 +89,7 @@ def test_api_mail_domain__accesses_delete_administrators():
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_owners():
|
||||
def test_api_domain_accesses_delete__owners_successful():
|
||||
"""
|
||||
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_mail_domain__accesses_delete_owners():
|
||||
assert models.MailDomainAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_delete_owners_last_owner():
|
||||
def test_api_domain_accesses_delete__last_owner_forbidden():
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a mail domain
|
||||
"""
|
||||
|
||||
+9
-9
@@ -27,8 +27,7 @@ def test_api_mail_domain__accesses_list_anonymous():
|
||||
|
||||
def test_api_mail_domain__accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
|
||||
to which they are not related.
|
||||
User with no access should not be allowed to list domain accesses.
|
||||
"""
|
||||
user = core_factories.UserFactory()
|
||||
mail_domain = factories.MailDomainFactory()
|
||||
@@ -43,12 +42,9 @@ 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 == 200
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
"detail": "No MailDomain matches the given query.",
|
||||
}
|
||||
|
||||
|
||||
@@ -178,8 +174,12 @@ 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(3):
|
||||
with django_assert_num_queries(5):
|
||||
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(3):
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(
|
||||
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
|
||||
)
|
||||
|
||||
+2
-4
@@ -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 MailDomainAccess matches the given query."}
|
||||
assert response.json() == {"detail": "No MailDomain 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,9 +55,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"detail": "No MailDomainAccess matches the given query."
|
||||
}
|
||||
assert response.json() == {"detail": "No MailDomain matches the given query."}
|
||||
|
||||
|
||||
def test_api_mail_domain__accesses_retrieve_authenticated_related():
|
||||
|
||||
+2
-2
@@ -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_authenticated_unrelated():
|
||||
def test_api_mail_domain__accesses_update_no_access_forbidden_not_found():
|
||||
"""
|
||||
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_authenticated_unrelated():
|
||||
{"role": enums.MailDomainRoleChoices.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
access.refresh_from_db()
|
||||
assert access.role == enums.MailDomainRoleChoices.VIEWER
|
||||
|
||||
|
||||
+2
-4
@@ -30,9 +30,7 @@ def test_api_mail_domain__available_users_anonymous():
|
||||
|
||||
|
||||
def test_api_mail_domain__available_users_forbidden():
|
||||
"""Authenticated user without accesses on maildomain should not be able to see available
|
||||
users.
|
||||
"""
|
||||
"""Users with no access should not be able to list available users."""
|
||||
authenticated_user = core_factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(authenticated_user)
|
||||
@@ -40,7 +38,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_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@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_authenticated_failure():
|
||||
def test_api_mailboxes__disable_no_access_forbidden_not_found():
|
||||
"""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_authenticated_failure():
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/disable/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
|
||||
|
||||
|
||||
|
||||
@@ -26,9 +26,8 @@ def test_api_mailboxes__enable_anonymous_forbidden():
|
||||
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
|
||||
|
||||
|
||||
def test_api_mailboxes__enable_authenticated_failure():
|
||||
"""Authenticated users should not be able to enable mailbox
|
||||
without specific role on mail domain."""
|
||||
def test_api_mailboxes__enable_unrelated_not_found():
|
||||
"""Unrelated users should not be able to enable mailbox."""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -39,7 +38,7 @@ def test_api_mailboxes__enable_authenticated_failure():
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/enable/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
|
||||
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ def test_api_mailboxes__list_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_mailboxes__list_authenticated():
|
||||
"""Authenticated users should not be able to list mailboxes"""
|
||||
def test_api_mailboxes__list_unrelated_not_found():
|
||||
"""Unrelated users should not be able to list mailboxes"""
|
||||
user = core_factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -36,10 +36,8 @@ def test_api_mailboxes__list_authenticated():
|
||||
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_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No MailDomain matches the given query."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
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
|
||||
+7
-17
@@ -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_unrelated_forbidden():
|
||||
def test_api_mailboxes__reset_password_no_access_forbidden_not_found():
|
||||
"""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_unrelated_forbidden():
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
"detail": "No MailDomain matches the given query.",
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ Please add a valid secondary email before trying again."
|
||||
],
|
||||
)
|
||||
@responses.activate
|
||||
def test_api_mailboxes__reset_password_admin_successful(role):
|
||||
def test_api_mailboxes__reset_password_admin_successful(role, dimail_token_ok): # pylint: disable=W0613
|
||||
"""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,12 +124,7 @@ def test_api_mailboxes__reset_password_admin_successful(role):
|
||||
client.force_login(access.user)
|
||||
dimail_url = settings.MAIL_PROVISIONING_API_URL
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{dimail_url}/token/",
|
||||
body=dimail.TOKEN_OK,
|
||||
status=200,
|
||||
)
|
||||
# token response in fixtures
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
|
||||
@@ -161,7 +156,7 @@ def test_api_mailboxes__reset_password_non_existing():
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_mailboxes__reset_password_connexion_failed():
|
||||
def test_api_mailboxes__reset_password_connexion_failed(dimail_token_ok): # pylint: disable=W0613
|
||||
"""
|
||||
No mail is sent when password reset failed because of connexion error.
|
||||
"""
|
||||
@@ -175,12 +170,7 @@ def test_api_mailboxes__reset_password_connexion_failed():
|
||||
client.force_login(access.user)
|
||||
|
||||
dimail_url = settings.MAIL_PROVISIONING_API_URL
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{dimail_url}/token/",
|
||||
body=dimail.TOKEN_OK,
|
||||
status=200,
|
||||
)
|
||||
# token response in fixtures
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
+2
-12
@@ -3,9 +3,8 @@
|
||||
|
||||
import json
|
||||
|
||||
|
||||
## USERS
|
||||
|
||||
|
||||
def response_user_created(user_sub):
|
||||
"""mimic dimail response upon successful user creation."""
|
||||
return json.dumps(
|
||||
@@ -19,7 +18,6 @@ def response_user_created(user_sub):
|
||||
|
||||
|
||||
## DOMAINS
|
||||
|
||||
CHECK_DOMAIN_BROKEN = {
|
||||
"name": "example.fr",
|
||||
"state": "broken",
|
||||
@@ -289,13 +287,7 @@ 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."""
|
||||
@@ -303,8 +295,6 @@ 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, "password": "password"})
|
||||
return json.dumps({"email": email_address.lower(), "password": "password"})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
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,7 +19,6 @@ from .fixtures.dimail import (
|
||||
CHECK_DOMAIN_BROKEN,
|
||||
CHECK_DOMAIN_OK,
|
||||
DOMAIN_SPEC,
|
||||
TOKEN_OK,
|
||||
response_mailbox_created,
|
||||
)
|
||||
|
||||
@@ -101,7 +100,9 @@ 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):
|
||||
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(
|
||||
client, dimail_token_ok
|
||||
): # pylint: disable=W0613
|
||||
"""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)
|
||||
@@ -127,13 +128,7 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
|
||||
content_type="application/json",
|
||||
)
|
||||
# we need to get a token to create mailboxes
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body=TOKEN_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
# token response in fixtures
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
|
||||
@@ -208,7 +203,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):
|
||||
def test_send_pending_mailboxes(client, dimail_token_ok): # pylint: disable=W0613
|
||||
"""Test admin action to send pending mailboxes to dimail."""
|
||||
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
|
||||
client.force_login(admin)
|
||||
@@ -223,13 +218,7 @@ def test_send_pending_mailboxes(client):
|
||||
|
||||
url = reverse("admin:mailbox_manager_maildomain_changelist")
|
||||
for mailbox in mailboxes:
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body=TOKEN_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
# token response in fixtures
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
@@ -247,7 +236,7 @@ def test_send_pending_mailboxes(client):
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_send_pending_mailboxes__listing_failed_mailboxes(client):
|
||||
def test_send_pending_mailboxes__listing_failed_mailboxes(client, dimail_token_ok): # pylint: disable=W0613
|
||||
"""Test admin action to send pending mailboxes to dimail."""
|
||||
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
|
||||
client.force_login(admin)
|
||||
@@ -261,13 +250,7 @@ def test_send_pending_mailboxes__listing_failed_mailboxes(client):
|
||||
}
|
||||
|
||||
url = reverse("admin:mailbox_manager_maildomain_changelist")
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body=TOKEN_OK,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
# token response in fixtures
|
||||
responses.add(
|
||||
responses.POST,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
Unit tests for dimail client
|
||||
"""
|
||||
|
||||
import json
|
||||
# pylint: disable=W0613
|
||||
|
||||
import logging
|
||||
import re
|
||||
from email.errors import HeaderParseError, NonASCIILocalPartDefect
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
@@ -21,14 +19,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
|
||||
|
||||
|
||||
def test_dimail_synchronization__already_sync():
|
||||
@responses.activate
|
||||
def test_dimail_synchronization__already_sync(dimail_token_ok):
|
||||
"""
|
||||
No mailbox should be created when everything is already synced.
|
||||
"""
|
||||
@@ -39,35 +37,26 @@ def test_dimail_synchronization__already_sync():
|
||||
assert pre_sync_mailboxes.count() == 3
|
||||
|
||||
dimail_client = DimailAPIClient()
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "dimail_people_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
[
|
||||
{
|
||||
"type": "mailbox",
|
||||
"status": "broken",
|
||||
"email": f"{mailbox.local_part}@{domain.name}",
|
||||
"givenName": mailbox.first_name,
|
||||
"surName": mailbox.last_name,
|
||||
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
|
||||
}
|
||||
for mailbox in pre_sync_mailboxes
|
||||
]
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
imported_mailboxes = dimail_client.import_mailboxes(domain)
|
||||
|
||||
# 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)
|
||||
|
||||
post_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
|
||||
assert post_sync_mailboxes.count() == 3
|
||||
@@ -75,97 +64,159 @@ def test_dimail_synchronization__already_sync():
|
||||
assert set(models.Mailbox.objects.filter(domain=domain)) == set(pre_sync_mailboxes)
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "warning")
|
||||
def test_dimail_synchronization__synchronize_mailboxes(mock_warning):
|
||||
"""A mailbox existing solely on dimail should be synchronized
|
||||
upon calling sync function on its domain"""
|
||||
@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)
|
||||
|
||||
domain = factories.MailDomainEnabledFactory()
|
||||
assert not models.Mailbox.objects.exists()
|
||||
|
||||
existing_alias = factories.AliasFactory(domain=domain)
|
||||
|
||||
dimail_client = DimailAPIClient()
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response using "responses":
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(r".*/token/"),
|
||||
body='{"access_token": "dimail_people_token"}',
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
# 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",
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
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",
|
||||
)
|
||||
|
||||
rsps.add(
|
||||
rsps.GET,
|
||||
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
|
||||
body=str(
|
||||
[
|
||||
mailbox_valid,
|
||||
mailbox_with_wrong_domain,
|
||||
mailbox_with_invalid_domain,
|
||||
mailbox_with_invalid_local_part,
|
||||
]
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
imported_mailboxes = dimail_client.import_mailboxes(domain)
|
||||
|
||||
imported_mailboxes = dimail_client.import_mailboxes(domain)
|
||||
# 4 imports failed: oxadmin, wrong domain, HeaderParseError, NonASCIILocalPartDefect
|
||||
assert len(caplog.records) == 5
|
||||
log_messages = [record.message for record in caplog.records]
|
||||
|
||||
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
|
||||
assert mock_warning.call_count == 3
|
||||
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
|
||||
|
||||
# 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"],
|
||||
)
|
||||
assert models.Mailbox.objects.count() == 2
|
||||
assert imported_mailboxes == [
|
||||
mailbox_valid["email"],
|
||||
mailbox_existing_alias["email"],
|
||||
]
|
||||
|
||||
# then we try to import email with invalid domain
|
||||
invalid_mailbox_log = mock_warning.call_args_list[1][0]
|
||||
assert invalid_mailbox_log[1] == mailbox_with_invalid_domain["email"]
|
||||
assert isinstance(invalid_mailbox_log[2], HeaderParseError)
|
||||
|
||||
# finally we try to import email with non ascii local part
|
||||
non_ascii_mailbox_log = mock_warning.call_args_list[2][0]
|
||||
assert non_ascii_mailbox_log[1] == mailbox_with_invalid_local_part["email"]
|
||||
assert isinstance(non_ascii_mailbox_log[2], NonASCIILocalPartDefect)
|
||||
@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()
|
||||
|
||||
mailbox = models.Mailbox.objects.get()
|
||||
assert mailbox.local_part == "oxadmin"
|
||||
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
|
||||
assert imported_mailboxes == [mailbox_valid["email"]]
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -183,10 +234,9 @@ 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.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_content),
|
||||
json=body_content,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -221,10 +271,9 @@ 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.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_broken),
|
||||
json=body_domain_broken,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -238,10 +287,9 @@ 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.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_ok),
|
||||
json=body_domain_ok,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -267,18 +315,16 @@ 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.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_broken),
|
||||
json=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.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/fix/"),
|
||||
body=json.dumps(body_domain_broken),
|
||||
json=body_domain_broken,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -305,10 +351,9 @@ 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.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_broken),
|
||||
json=body_domain_broken,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -324,20 +369,18 @@ 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.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/check/"),
|
||||
body=json.dumps(body_domain_broken_internal),
|
||||
json=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.add(
|
||||
responses.GET,
|
||||
responses.get(
|
||||
re.compile(rf".*/domains/{domain.name}/fix/"),
|
||||
body=json.dumps(body_domain_ok),
|
||||
json=body_domain_ok,
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -348,7 +391,8 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
|
||||
assert domain.last_check_details == body_domain_ok
|
||||
|
||||
|
||||
def test_dimail__send_pending_mailboxes(caplog):
|
||||
@responses.activate
|
||||
def test_dimail__send_pending_mailboxes(caplog, dimail_token_ok):
|
||||
"""Status of pending mailboxes should switch to "enabled"
|
||||
when calling send_pending_mailboxes."""
|
||||
caplog.set_level(logging.INFO)
|
||||
@@ -365,22 +409,16 @@ def test_dimail__send_pending_mailboxes(caplog):
|
||||
)
|
||||
|
||||
dimail_client = DimailAPIClient()
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
mailbox1.refresh_from_db()
|
||||
mailbox2.refresh_from_db()
|
||||
|
||||
@@ -29,7 +29,11 @@ maildomain_related_router.register(
|
||||
viewsets.MailDomainInvitationViewset,
|
||||
basename="invitations",
|
||||
)
|
||||
|
||||
maildomain_related_router.register(
|
||||
"aliases",
|
||||
viewsets.AliasViewSet,
|
||||
basename="aliases",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
"""A minimalist client to synchronize with mailbox provisioning API."""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import smtplib
|
||||
from email.errors import HeaderParseError, NonASCIILocalPartDefect
|
||||
@@ -46,7 +45,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.
|
||||
@@ -83,7 +82,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."""
|
||||
@@ -118,7 +117,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."""
|
||||
@@ -131,7 +130,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(
|
||||
@@ -166,7 +165,36 @@ class DimailAPIClient:
|
||||
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
|
||||
)
|
||||
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
# 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)
|
||||
|
||||
def create_user(self, user_id):
|
||||
"""Send a request to dimail, to create a new user there. In dimail, user ids are subs."""
|
||||
@@ -203,7 +231,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."""
|
||||
@@ -245,9 +273,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(
|
||||
@@ -271,7 +299,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.
|
||||
@@ -331,7 +359,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,
|
||||
)
|
||||
@@ -344,43 +372,48 @@ class DimailAPIClient:
|
||||
raise error
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return self.raise_exception_for_unexpected_response(response)
|
||||
|
||||
dimail_mailboxes = ast.literal_eval(
|
||||
response.content.decode("utf-8")
|
||||
) # format output str to proper list
|
||||
return self._raise_exception_for_unexpected_response(response)
|
||||
|
||||
dimail_mailboxes = response.json()
|
||||
people_mailboxes = models.Mailbox.objects.filter(domain=domain)
|
||||
imported_mailboxes = []
|
||||
for dimail_mailbox in dimail_mailboxes:
|
||||
if not dimail_mailbox["email"] in [
|
||||
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 [
|
||||
str(people_mailbox) for people_mailbox in people_mailboxes
|
||||
]:
|
||||
try:
|
||||
# sometimes dimail api returns email from another domain,
|
||||
# so we decide to exclude this kind of email
|
||||
address = Address(addr_spec=dimail_mailbox["email"])
|
||||
if address.domain == domain.name:
|
||||
# creates a mailbox on our end
|
||||
mailbox = models.Mailbox.objects.create(
|
||||
first_name=dimail_mailbox["givenName"],
|
||||
last_name=dimail_mailbox["surName"],
|
||||
local_part=address.username,
|
||||
domain=domain,
|
||||
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:
|
||||
# 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:
|
||||
logger.warning(
|
||||
"Import of email %s failed with error %s",
|
||||
"Import of email %s failed because of a wrong domain",
|
||||
dimail_mailbox["email"],
|
||||
err,
|
||||
)
|
||||
return imported_mailboxes
|
||||
|
||||
@@ -389,7 +422,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,
|
||||
)
|
||||
@@ -401,7 +434,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"""
|
||||
@@ -413,7 +446,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,
|
||||
)
|
||||
@@ -425,7 +458,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."""
|
||||
@@ -474,7 +507,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.
|
||||
@@ -491,7 +524,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."""
|
||||
@@ -616,7 +649,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,
|
||||
)
|
||||
@@ -630,7 +663,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"],
|
||||
@@ -642,4 +675,183 @@ class DimailAPIClient:
|
||||
mailbox,
|
||||
)
|
||||
return response
|
||||
return self.raise_exception_for_unexpected_response(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
|
||||
|
||||
@@ -74,7 +74,7 @@ class Base(Configuration):
|
||||
You may also want to override default configuration by setting the following environment
|
||||
variables:
|
||||
|
||||
* DJANGO_SENTRY_DSN
|
||||
* SENTRY_DSN
|
||||
* DB_NAME
|
||||
* DB_HOST
|
||||
* DB_PASSWORD
|
||||
@@ -173,9 +173,6 @@ 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": [
|
||||
@@ -227,7 +224,6 @@ class Base(Configuration):
|
||||
|
||||
INSTALLED_APPS = [
|
||||
# People
|
||||
"admin.apps.PeopleAdminConfig", # replaces 'django.contrib.admin'
|
||||
"core",
|
||||
"demo",
|
||||
"mailbox_manager.apps.MailboxManagerConfig",
|
||||
@@ -246,6 +242,7 @@ class Base(Configuration):
|
||||
"parler",
|
||||
"rest_framework",
|
||||
"treebeard",
|
||||
"lasuite.admin", # must be before django.contrib.admin
|
||||
# Django
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
@@ -254,6 +251,7 @@ class Base(Configuration):
|
||||
"django.contrib.sites",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.admin",
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
]
|
||||
@@ -341,7 +339,7 @@ class Base(Configuration):
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
|
||||
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
@@ -581,26 +579,6 @@ class Base(Configuration):
|
||||
environ_name="MAIL_CHECK_DOMAIN_INTERVAL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DNS_PROVISIONING_TARGET_ZONE = values.Value(
|
||||
default=None,
|
||||
environ_name="DNS_PROVISIONING_TARGET_ZONE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
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="DNS_PROVISIONING_RESOURCE_ID",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DNS_PROVISIONING_API_CREDENTIALS = values.Value(
|
||||
default=None,
|
||||
environ_name="DNS_PROVISIONING_API_CREDENTIALS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MATRIX_BASE_HOME_SERVER = values.Value(
|
||||
default="https://matrix.agent.dinum.tchap.gouv.fr",
|
||||
environ_name="MATRIX_BASE_HOME_SERVER",
|
||||
@@ -1066,10 +1044,6 @@ class Production(Base):
|
||||
},
|
||||
},
|
||||
}
|
||||
SENTRY_DSN = values.Value(
|
||||
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
|
||||
environ_name="SENTRY_DSN",
|
||||
)
|
||||
|
||||
|
||||
class Feature(Production):
|
||||
|
||||
@@ -11,22 +11,9 @@ 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,25 +21,45 @@ 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."""
|
||||
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"
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
logger.warning("No organization name found for SIRET %s", siret)
|
||||
return None, org_metadata
|
||||
return None, metadata
|
||||
|
||||
|
||||
def get_organization_name_and_metadata_from_siret(organization):
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,231 +0,0 @@
|
||||
"""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,15 +26,6 @@ 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
|
||||
|
||||
+46
-45
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "people"
|
||||
version = "1.18.1"
|
||||
version = "1.23.1"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -17,50 +17,51 @@ classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
description = "An application to handle contacts and teams."
|
||||
keywords = ["Django", "Contacts", "Teams", "RBAC"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
requires-python = "~=3.14.2"
|
||||
dependencies = [
|
||||
"Brotli==1.1.0",
|
||||
"PyJWT==2.10.1",
|
||||
"boto3==1.38.45",
|
||||
"celery[redis]==5.5.3",
|
||||
"django-celery-beat==2.8.1",
|
||||
"Brotli==1.2.0",
|
||||
"PyJWT==2.12.1",
|
||||
"boto3==1.42.68",
|
||||
"celery[redis]==5.6.2",
|
||||
"django-celery-beat==2.9.0",
|
||||
"django-celery-results==2.6.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-extensions==4.1",
|
||||
"django-lasuite==0.0.10",
|
||||
"django-oauth-toolkit==3.0.1",
|
||||
"django-lasuite==0.0.25",
|
||||
"django-oauth-toolkit==3.2.0",
|
||||
"django-parler==2.3",
|
||||
"django-redis==5.4.0",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages==1.14.6",
|
||||
"django-timezone-field>=5.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",
|
||||
"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",
|
||||
"factory_boy==3.3.3",
|
||||
"flower==2.0.1",
|
||||
"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<=6.0.0",
|
||||
"requests==2.32.4",
|
||||
"sentry-sdk[django]==2.32.0",
|
||||
"whitenoise==6.9.0",
|
||||
"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.3.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk[django]==2.54.0",
|
||||
"whitenoise==6.12.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -71,22 +72,22 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"drf-spectacular-sidecar==2025.6.1",
|
||||
"drf-spectacular-sidecar==2026.3.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.3.0",
|
||||
"jq==1.9.1",
|
||||
"pyfakefs==5.9.1",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.7",
|
||||
"pytest-cov==6.2.1",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==8.4.1",
|
||||
"ipython==9.11.0",
|
||||
"jq==1.11.0",
|
||||
"pyfakefs==6.1.5",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint==4.0.5",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.7.0",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.11.13",
|
||||
"types-requests==2.32.4.20250611",
|
||||
"freezegun==1.5.2",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.26.0",
|
||||
"ruff==0.15.6",
|
||||
"types-requests==2.32.4.20260107",
|
||||
"freezegun==1.5.5",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
Generated
+1803
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,13 @@ 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 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
|
||||
@@ -1,5 +1,864 @@
|
||||
import { cunninghamConfig } from '@gouvfr-lasuite/ui-kit';
|
||||
import { getThemesFromGlobals } from '@gouvfr-lasuite/cunningham-tokens';
|
||||
|
||||
export default {
|
||||
...cunninghamConfig,
|
||||
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 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;
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
/// <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.
|
||||
|
||||
@@ -41,6 +41,7 @@ const nextConfig = {
|
||||
|
||||
return config;
|
||||
},
|
||||
turbopack: {},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-desk",
|
||||
"version": "1.18.1",
|
||||
"version": "1.23.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -15,54 +15,55 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gouvfr-lasuite/cunningham-react": "4.2.0",
|
||||
"@gouvfr-lasuite/cunningham-tokens": "^3.1.0",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "^0.5.0",
|
||||
"@gouvfr-lasuite/ui-kit": "0.19.10",
|
||||
"@hookform/resolvers": "4.0.0",
|
||||
"@openfun/cunningham-react": "3.1.0",
|
||||
"@tanstack/react-query": "5.76.1",
|
||||
"i18next": "25.1.3",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.6.1",
|
||||
"next": "15.3.2",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"i18next": "25.6.0",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"lodash": "4.17.23",
|
||||
"luxon": "3.7.2",
|
||||
"next": "15.5.14",
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-hook-form": "7.56.3",
|
||||
"react-i18next": "15.5.1",
|
||||
"react-select": "5.10.1",
|
||||
"sass": "1.89.0",
|
||||
"styled-components": "6.1.18",
|
||||
"zod": "3.24.4",
|
||||
"zustand": "5.0.4"
|
||||
"react-hook-form": "7.65.0",
|
||||
"react-i18next": "16.2.0",
|
||||
"react-select": "5.10.2",
|
||||
"sass": "1.93.2",
|
||||
"styled-components": "6.1.19",
|
||||
"zod": "4.1.12",
|
||||
"zustand": "5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/devtools": "4.4.0",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.76.1",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.16",
|
||||
"@types/luxon": "3.6.2",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "*",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "*",
|
||||
"dotenv": "16.5.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint-config-people": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jest": "30.2.0",
|
||||
"jest-environment-jsdom": "30.2.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "16.19.1",
|
||||
"stylelint-config-standard": "38.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"stylelint": "16.25.0",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*"
|
||||
},
|
||||
"resolutions": {
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('Page', () => {
|
||||
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
expect(mockedPush).toHaveBeenCalledWith('/teams/');
|
||||
expect(mockedPush).toHaveBeenCalledWith('/mail-domains/');
|
||||
});
|
||||
|
||||
it('checks Page rendering without team feature', () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4.04601 20.3381C3.04306 20.3381 2.28465 20.0812 1.77079 19.5673C1.25693 19.0597 1 18.3105 1 17.32V6.01815C1 5.02139 1.25693 4.26917 1.77079 3.7615C2.28465 3.25383 3.04306 3 4.04601 3H19.9447C20.9538 3 21.7154 3.25383 22.2292 3.7615C22.7431 4.26917 23 5.02139 23 6.01815V17.32C23 18.3105 22.7431 19.0597 22.2292 19.5673C21.7154 20.0812 20.9538 20.3381 19.9447 20.3381H4.04601ZM4.15745 18.5087H19.8425C20.2697 18.5087 20.5979 18.3972 20.8269 18.1743C21.056 17.9515 21.1705 17.614 21.1705 17.1621V6.16674C21.1705 5.72098 21.056 5.38666 20.8269 5.16378C20.5979 4.9409 20.2697 4.82946 19.8425 4.82946H4.15745C3.72407 4.82946 3.39285 4.9409 3.16378 5.16378C2.9409 5.38666 2.82946 5.72098 2.82946 6.16674V17.1621C2.82946 17.614 2.9409 17.9515 3.16378 18.1743C3.39285 18.3972 3.72407 18.5087 4.15745 18.5087ZM8.38286 18.8058V4.51372H10.1195V18.8058H8.38286ZM6.56268 8.22837H4.65893C4.49796 8.22837 4.35556 8.16646 4.23174 8.04263C4.11411 7.91881 4.0553 7.78261 4.0553 7.63402C4.0553 7.47305 4.11411 7.33376 4.23174 7.21613C4.35556 7.09849 4.49796 7.03968 4.65893 7.03968H6.56268C6.72984 7.03968 6.87224 7.09849 6.98987 7.21613C7.11369 7.33376 7.1756 7.47305 7.1756 7.63402C7.1756 7.78261 7.11369 7.91881 6.98987 8.04263C6.87224 8.16646 6.72984 8.22837 6.56268 8.22837ZM6.56268 10.7172H4.65893C4.49796 10.7172 4.35556 10.6584 4.23174 10.5407C4.11411 10.4169 4.0553 10.2745 4.0553 10.1136C4.0553 9.95877 4.11411 9.82257 4.23174 9.70494C4.35556 9.58731 4.49796 9.52849 4.65893 9.52849H6.56268C6.72984 9.52849 6.87224 9.58731 6.98987 9.70494C7.11369 9.82257 7.1756 9.95877 7.1756 10.1136C7.1756 10.2745 7.11369 10.4169 6.98987 10.5407C6.87224 10.6584 6.72984 10.7172 6.56268 10.7172ZM6.56268 13.1967H4.65893C4.49796 13.1967 4.35556 13.1379 4.23174 13.0203C4.11411 12.9026 4.0553 12.7664 4.0553 12.6117C4.0553 12.4507 4.11411 12.3114 4.23174 12.1938C4.35556 12.0761 4.49796 12.0173 4.65893 12.0173H6.56268C6.72984 12.0173 6.87224 12.0761 6.98987 12.1938C7.11369 12.3114 7.1756 12.4507 7.1756 12.6117C7.1756 12.7664 7.11369 12.9026 6.98987 13.0203C6.87224 13.1379 6.72984 13.1967 6.56268 13.1967Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user