Compare commits
156 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 | |||
| b78723ecce | |||
| df0cea225c | |||
| 3b73cca2f5 | |||
| 06d4d5c9e8 | |||
| 405e5fda90 | |||
| 8a434448dd | |||
| 5b5bd312b4 | |||
| 99bf301a9c | |||
| 3dc11c9b52 | |||
| 5327baacbd | |||
| 709628a26a | |||
| d2bf267160 | |||
| 10dcdfc8c2 | |||
| f30398fbc7 | |||
| cc39ed5298 | |||
| 7bebf13d88 | |||
| e64a34f167 | |||
| 3379d6d499 | |||
| 213656fc2e | |||
| 4dfd682cb6 |
@@ -0,0 +1,24 @@
|
||||
# Set the default behavior for all files
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary files (should not be modified)
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.flv binary
|
||||
*.fla binary
|
||||
*.swf binary
|
||||
*.gz binary
|
||||
*.zip binary
|
||||
*.7z binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
|
||||
@@ -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
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
name: Run trivy scan (frontend)
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target frontend-production -f Dockerfile'
|
||||
docker-build-args: '--target frontend-production -f src/frontend/Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
|
||||
|
||||
build-and-push-backend:
|
||||
@@ -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,12 +102,16 @@ 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
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -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/
|
||||
@@ -167,6 +171,8 @@ jobs:
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
run: |
|
||||
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make update-keycloak-realm-app
|
||||
make add-dev-rsa-private-key-to-env
|
||||
make run
|
||||
|
||||
- name: Apply DRF migrations
|
||||
@@ -177,10 +183,6 @@ jobs:
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Setup Dimail DB
|
||||
run: |
|
||||
make dimail-setup-db
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
@@ -218,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
|
||||
@@ -264,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
|
||||
+142
-1
@@ -8,6 +8,135 @@ 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
|
||||
|
||||
### Added
|
||||
|
||||
- 🐛(front) fix missing pagination mail domains
|
||||
- 🐛(front) fix button add mail domain
|
||||
- ✨(teams) add matrix webhook for teams #904
|
||||
- ✨(resource-server) add SCIM /Me endpoint #895
|
||||
- 🔧(git) set LF line endings for all text files #928
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧑💻(docker) split frontend to another file #924
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(webhook) handle user on different home server than room server
|
||||
|
||||
## [1.17.0] - 2025-06-11
|
||||
|
||||
### Added
|
||||
@@ -394,7 +523,19 @@ 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.17.0...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
|
||||
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
|
||||
[1.15.0]: https://github.com/suitenumerique/people/releases/v1.15.0
|
||||
|
||||
+37
-80
@@ -1,84 +1,39 @@
|
||||
# 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 && \
|
||||
apk upgrade
|
||||
|
||||
### ---- Front-end dependencies image ----
|
||||
FROM node:20 AS frontend-deps
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
COPY ./src/frontend/package.json ./package.json
|
||||
COPY ./src/frontend/yarn.lock ./yarn.lock
|
||||
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
|
||||
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
|
||||
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
|
||||
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
### ---- Front-end builder dev image ----
|
||||
FROM node:20 AS frontend-builder-dev
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
COPY --from=frontend-deps /deps/node_modules ./node_modules
|
||||
COPY ./src/frontend .
|
||||
|
||||
WORKDIR ./apps/desk
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-builder-dev AS frontend-builder
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY --from=frontend-builder \
|
||||
/builder/apps/desk/out \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
# ---- 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
|
||||
|
||||
@@ -97,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
|
||||
@@ -135,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.
|
||||
@@ -157,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
|
||||
|
||||
@@ -41,11 +41,9 @@ DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm --no-deps
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
@@ -78,24 +76,39 @@ create-env-files: \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
|
||||
add-dev-rsa-private-key-to-env: ## Add a generated RSA private key to the env file
|
||||
@echo "Generating RSA private key PEM for development..."
|
||||
@mkdir -p env.d/development/rsa
|
||||
@openssl genrsa -out env.d/development/rsa/private.pem 2048
|
||||
@echo -n "\nOAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=\"" >> env.d/development/common
|
||||
@openssl rsa -in env.d/development/rsa/private.pem -outform PEM >> env.d/development/common
|
||||
@echo "\"" >> env.d/development/common
|
||||
@rm -rf env.d/development/rsa
|
||||
.PHONY: add-dev-rsa-private-key-to-env
|
||||
|
||||
update-keycloak-realm-app: ## Create the Keycloak realm for the project
|
||||
@echo "$(BOLD)Creating Keycloak realm for 'app'$(RESET)"
|
||||
@sed -i 's|http://app-dev:8000|http://app:8000|g' ./docker/auth/realm.json
|
||||
.PHONY: update-keycloak-realm-app
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
|
||||
bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run \
|
||||
run-dev \
|
||||
migrate \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build \
|
||||
dimail-setup-db \
|
||||
install-front-desk
|
||||
dimail-setup-db
|
||||
.PHONY: 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
|
||||
@@ -106,19 +119,14 @@ logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@$(COMPOSE) up --force-recreate -d app-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-beat-dev
|
||||
@$(COMPOSE) up --force-recreate -d flower-dev
|
||||
@$(COMPOSE) up --force-recreate -d keycloak
|
||||
@$(COMPOSE) up -d dimail
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_KC_DB)
|
||||
@$(WAIT_DB)
|
||||
run: ## start the wsgi (production) and servers with production Docker images
|
||||
@$(COMPOSE) up --force-recreate --detach app frontend celery celery-beat nginx maildev
|
||||
.PHONY: run
|
||||
|
||||
run-dev: ## start the servers in development mode (watch) Docker images
|
||||
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
|
||||
.PHONY: run-dev
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
.PHONY: status
|
||||
@@ -187,22 +195,16 @@ test-coverage: ## compute, display and save test coverage
|
||||
|
||||
makemigrations: ## run django makemigrations for the people project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) makemigrations $(ARGS)
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the people project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) migrate $(ARGS)
|
||||
.PHONY: migrate
|
||||
|
||||
showmigrations: ## run django showmigrations for the people project.
|
||||
@echo "$(BOLD)Running showmigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) showmigrations $(ARGS)
|
||||
.PHONY: showmigrations
|
||||
|
||||
@@ -213,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
|
||||
@@ -239,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
|
||||
@@ -426,3 +428,13 @@ install-secret: ## install the kubernetes secrets from Vaultwarden
|
||||
fetch-domain-status:
|
||||
@$(MANAGE) fetch_domain_status
|
||||
.PHONY: fetch-domain-status
|
||||
|
||||
# -- Keycloak related
|
||||
create-new-client: ## run the add-keycloak-client.sh script for keycloak.
|
||||
@echo "$(BOLD)Running add-keycloak-client.sh$(RESET)"
|
||||
@$(COMPOSE_RUN) \
|
||||
-v ./scripts/keycloak/add-keycloak-client.sh:/tmp/add-keycloak-client.sh \
|
||||
--entrypoint="/tmp/add-keycloak-client.sh" \
|
||||
keycloak \
|
||||
$(filter-out $@,$(MAKECMDGOALS))
|
||||
.PHONY: create-new-client
|
||||
|
||||
@@ -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/).
|
||||
@@ -33,7 +33,7 @@ The easiest way to start working on the project is to use GNU Make:
|
||||
$ make bootstrap
|
||||
```
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs
|
||||
This command builds the `app-dev` container, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-related or migration-related issues.
|
||||
@@ -46,6 +46,12 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
$ make run
|
||||
```
|
||||
|
||||
or if you want to run them in development mode (with live reloading):
|
||||
|
||||
```bash
|
||||
$ make run-dev
|
||||
```
|
||||
|
||||
You can check all available Make rules using:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -64,6 +64,23 @@ function _dc_run() {
|
||||
_docker_compose run --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_run_no_deps: wrap docker compose run command without dependencies
|
||||
#
|
||||
# usage: _dc_run_no_deps [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose run command options
|
||||
# ARGS : docker compose run command arguments
|
||||
function _dc_run_no_deps() {
|
||||
_set_user
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose run --no-deps --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_exec: wrap docker compose exec command
|
||||
#
|
||||
# usage: _dc_exec [options] [ARGS...]
|
||||
|
||||
+1
-1
@@ -35,4 +35,4 @@ fi
|
||||
|
||||
# Fix docker vs local path when project sources are mounted as a volume
|
||||
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
|
||||
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
|
||||
_dc_run_no_deps app-dev pylint "${paths[@]}" "${args[@]}"
|
||||
|
||||
+125
-41
@@ -6,6 +6,11 @@ services:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "15432:5432"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
@@ -23,6 +28,7 @@ services:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
pull_policy: never
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
@@ -36,12 +42,69 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- dimail
|
||||
- postgresql
|
||||
- maildev
|
||||
- redis
|
||||
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
maildev:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-dev
|
||||
image: people:frontend-development
|
||||
pull_policy: never
|
||||
volumes:
|
||||
- ./src/frontend:/home/frontend
|
||||
- /home/frontend/node_modules
|
||||
- /home/frontend/apps/desk/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: backend-production
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/france
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "8071:8000"
|
||||
volumes:
|
||||
- ./data/media:/data/media
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
image: people:frontend-production
|
||||
pull_policy: build
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
@@ -55,8 +118,13 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
app-dev:
|
||||
condition: service_started
|
||||
|
||||
celery-beat-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -72,26 +140,9 @@ services:
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- app-dev
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: backend-production
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./data/media:/data/media
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -103,7 +154,29 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- app
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
app:
|
||||
condition: service_started
|
||||
|
||||
celery-beat:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
command: ["celery", "-A", "people.celery_app", "beat", "-l", "INFO"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
@@ -112,12 +185,9 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- app
|
||||
- keycloak
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
platform: linux/x86_64
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:4.6.1
|
||||
@@ -129,7 +199,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
@@ -137,11 +207,16 @@ services:
|
||||
- ".:/app"
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
@@ -158,6 +233,8 @@ services:
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
@@ -168,14 +245,21 @@ services:
|
||||
KC_DB_USERNAME: people
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready" ]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
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
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"oauth2DeviceCodeLifespan": 600,
|
||||
"oauth2DevicePollingInterval": 5,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
|
||||
+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! 🎉
|
||||
|
||||
@@ -3,6 +3,7 @@ DJANGO_ALLOWED_HOSTS=*
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=people.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
|
||||
OAUTH2_PROVIDER_OIDC_ENABLED = True
|
||||
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
|
||||
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD="siret"
|
||||
|
||||
OAUTH2_PROVIDER_OIDC_ENABLED=True
|
||||
OAUTH2_PROVIDER_VALIDATOR_CLASS="mailbox_oauth2.validators.ProConnectValidator"
|
||||
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
INSTALLED_PLUGINS=plugins.la_suite
|
||||
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to add a new client to Keycloak using the kcadm.sh CLI
|
||||
# Usage: ./add-keycloak-client.sh [client_id] [client_secret]
|
||||
|
||||
# Default values
|
||||
CLIENT_ID=${1:-"some-client-id"}
|
||||
CLIENT_SECRET=${2:-"ThisIsAnExampleKeyForDevPurposeOnly"}
|
||||
KEYCLOAK_URL=${KEYCLOAK_URL:-"http://keycloak:8080"}
|
||||
KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-"admin"}
|
||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-"admin"}
|
||||
REALM=${REALM:-"people"}
|
||||
|
||||
# Check for kcadm.sh in common locations
|
||||
KCADM_LOCATIONS=(
|
||||
"/opt/keycloak/bin/kcadm.sh"
|
||||
"/opt/jboss/keycloak/bin/kcadm.sh"
|
||||
"/usr/local/bin/kcadm.sh"
|
||||
"./bin/kcadm.sh"
|
||||
"$(which kcadm.sh 2>/dev/null)"
|
||||
)
|
||||
|
||||
KCADM=""
|
||||
for loc in "${KCADM_LOCATIONS[@]}"; do
|
||||
if [ -x "$loc" ]; then
|
||||
KCADM="$loc"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$KCADM" ]; then
|
||||
echo "Error: kcadm.sh not found. Please specify its location manually."
|
||||
echo "You can set the KCADM environment variable to the path of kcadm.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using Keycloak Admin CLI at: $KCADM"
|
||||
echo "Logging in to Keycloak at $KEYCLOAK_URL..."
|
||||
|
||||
# Login to Keycloak
|
||||
$KCADM config credentials \
|
||||
--server $KEYCLOAK_URL \
|
||||
--realm master \
|
||||
--user $KEYCLOAK_ADMIN \
|
||||
--password $KEYCLOAK_ADMIN_PASSWORD
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to login to Keycloak. Please check your credentials and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Successfully logged in to Keycloak."
|
||||
echo "Creating new client '$CLIENT_ID' in realm '$REALM'..."
|
||||
|
||||
# Create a temporary JSON file with client configuration
|
||||
CLIENT_JSON=$(mktemp)
|
||||
cat > "$CLIENT_JSON" << EOF
|
||||
{
|
||||
"clientId": "$CLIENT_ID",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
"adminUrl": "",
|
||||
"baseUrl": "",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "$CLIENT_SECRET",
|
||||
"redirectUris": [
|
||||
"http://localhost:8070/*",
|
||||
"http://localhost:8071/*",
|
||||
"http://localhost:3200/*",
|
||||
"http://localhost:8088/*",
|
||||
"http://localhost:3000/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3200",
|
||||
"http://localhost:8088",
|
||||
"http://localhost:8070",
|
||||
"http://localhost:3000"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "$(date +%s)",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:8070/*##http://localhost:3200/*##http://localhost:3000/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
# Create the client using kcadm.sh
|
||||
$KCADM create clients -r "$REALM" -f "$CLIENT_JSON"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to create client. Check the error message above."
|
||||
rm "$CLIENT_JSON"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Client '$CLIENT_ID' created successfully!"
|
||||
echo " Client ID: $CLIENT_ID"
|
||||
echo " Client Secret: $CLIENT_SECRET"
|
||||
|
||||
# Clean up temporary file
|
||||
rm "$CLIENT_JSON"
|
||||
|
||||
# Display the created client
|
||||
echo "Client details:"
|
||||
$KCADM get clients -r "$REALM" --query "clientId=$CLIENT_ID"
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""SCIM compliant API for resource server"""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Exceptions for SCIM API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from core.api.resource_server.scim.response import ScimJsonResponse
|
||||
|
||||
|
||||
def scim_exception_handler(exc, _context):
|
||||
"""Handle SCIM exceptions and return them in the correct format."""
|
||||
data = {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"status": str(exc.status_code),
|
||||
"detail": str(exc.detail) if settings.DEBUG else "",
|
||||
}
|
||||
|
||||
return ScimJsonResponse(data, status=exc.status_code)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SCIM API response classes."""
|
||||
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
class ScimJsonResponse(Response):
|
||||
"""
|
||||
Custom JSON response class for SCIM API.
|
||||
|
||||
This class sets the content type to "application/json+scim" for SCIM
|
||||
responses.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""JSON response with enforced SCIM content type."""
|
||||
super().__init__(*args, content_type="application/json+scim", **kwargs)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""SCIM serializers for resource server API."""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class SCIMUserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users in SCIM format."""
|
||||
|
||||
schemas = serializers.SerializerMethodField()
|
||||
userName = serializers.CharField(source="sub")
|
||||
displayName = serializers.CharField(source="name")
|
||||
emails = serializers.SerializerMethodField()
|
||||
meta = serializers.SerializerMethodField()
|
||||
groups = serializers.SerializerMethodField()
|
||||
active = serializers.BooleanField(source="is_active")
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"schemas",
|
||||
"active",
|
||||
"userName",
|
||||
"displayName",
|
||||
"emails",
|
||||
"groups",
|
||||
"meta",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"schemas",
|
||||
"active",
|
||||
"userName",
|
||||
"displayName",
|
||||
"emails",
|
||||
"groups",
|
||||
"meta",
|
||||
]
|
||||
|
||||
def get_schemas(self, _obj):
|
||||
"""Return the SCIM schemas for the user."""
|
||||
return ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
|
||||
def get_emails(self, obj):
|
||||
"""Return the user's email as a list of email objects."""
|
||||
if obj.email:
|
||||
return [
|
||||
{
|
||||
"value": obj.email,
|
||||
"primary": True,
|
||||
"type": "work",
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_groups(self, obj):
|
||||
"""
|
||||
Return the groups the user belongs to.
|
||||
|
||||
WARNING: you need to prefetch the team accesses in the
|
||||
viewset to avoid N+1 queries.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"value": str(team_access.team.external_id),
|
||||
"display": team_access.team.name,
|
||||
"type": "direct",
|
||||
}
|
||||
for team_access in obj.accesses.all()
|
||||
]
|
||||
|
||||
def get_meta(self, obj):
|
||||
"""Return metadata about the user."""
|
||||
request = self.context.get("request")
|
||||
location = (
|
||||
f"{request.build_absolute_uri('/').rstrip('/')}{reverse('scim-me-list')}"
|
||||
if request
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"resourceType": "User",
|
||||
"created": obj.created_at.isoformat(),
|
||||
"lastModified": obj.updated_at.isoformat(),
|
||||
"location": location,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Resource server SCIM API endpoints"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Prefetch, Q
|
||||
|
||||
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
|
||||
from rest_framework import (
|
||||
viewsets,
|
||||
)
|
||||
|
||||
from core.api import permissions
|
||||
from core.models import TeamAccess
|
||||
|
||||
from . import serializers
|
||||
from .exceptions import scim_exception_handler
|
||||
from .response import ScimJsonResponse
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class MeViewSet(ResourceServerMixin, viewsets.ViewSet):
|
||||
"""
|
||||
SCIM-compliant ViewSet for the /Me endpoint.
|
||||
|
||||
This endpoint provides information about the currently authenticated user
|
||||
in SCIM (System for Cross-domain Identity Management) format.
|
||||
|
||||
Features:
|
||||
- Returns user details in SCIM format.
|
||||
- Includes the user's teams, restricted to the audience.
|
||||
|
||||
Limitations:
|
||||
- Does not currently support managing Team hierarchies.
|
||||
|
||||
Endpoint:
|
||||
GET /resource-server/v1.0/scim/Me/
|
||||
Retrieves the authenticated user's details and associated teams.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = serializers.SCIMUserSerializer
|
||||
|
||||
def get_exception_handler(self):
|
||||
"""Override the default exception handler to use SCIM-specific handling."""
|
||||
return scim_exception_handler
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Return the current user's details in SCIM format."""
|
||||
service_provider_audience = self._get_service_provider_audience()
|
||||
|
||||
user = User.objects.prefetch_related(
|
||||
Prefetch(
|
||||
"accesses",
|
||||
queryset=TeamAccess.objects.select_related("team").filter(
|
||||
Q(team__service_providers__audience_id=service_provider_audience)
|
||||
| Q(team__is_visible_all_services=True)
|
||||
),
|
||||
)
|
||||
).get(pk=request.user.pk)
|
||||
|
||||
serializer = self.serializer_class(user, context={"request": request})
|
||||
return ScimJsonResponse(serializer.data)
|
||||
@@ -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,
|
||||
|
||||
@@ -24,3 +24,10 @@ class WebhookStatusChoices(models.TextChoices):
|
||||
FAILURE = "failure", _("Failure")
|
||||
PENDING = "pending", _("Pending")
|
||||
SUCCESS = "success", _("Success")
|
||||
|
||||
|
||||
class WebhookProtocolChoices(models.TextChoices):
|
||||
"""Defines the possible protocols of webhook."""
|
||||
|
||||
SCIM = "scim"
|
||||
MATRIX = "matrix"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2 on 2025-06-12 09:58
|
||||
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def gen_team_uuid(apps, schema_editor):
|
||||
Team = apps.get_model("core", "Team")
|
||||
for row in Team.objects.all():
|
||||
row.external_id = uuid.uuid4()
|
||||
# Don't need to batch update here, as the table is likely small or empty
|
||||
row.save(update_fields=["external_id"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_alter_accountservice_api_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='external_id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=False, verbose_name='external_id'),
|
||||
),
|
||||
migrations.RunPython(gen_team_uuid, reverse_code=migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='external_id',
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=True, verbose_name='external_id'),
|
||||
),
|
||||
#
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='users',
|
||||
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', through_fields=('team', 'user'), to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.3 on 2025-06-17 14:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_team_external_id_alter_team_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='teamwebhook',
|
||||
name='protocol',
|
||||
field=models.CharField(choices=[('scim', 'Scim'), ('matrix', 'Matrix')], default='scim'),
|
||||
),
|
||||
]
|
||||
+44
-25
@@ -21,7 +21,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext, override
|
||||
@@ -31,9 +31,10 @@ import jsonschema
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from core.enums import WebhookStatusChoices
|
||||
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 scim_synchronizer
|
||||
from core.utils.webhooks import webhooks_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -742,6 +743,11 @@ class Team(MP_Node, BaseModel):
|
||||
can see it.
|
||||
When a team is created from a Service Provider this one is automatically set in the
|
||||
Team `service_providers`.
|
||||
|
||||
The team `external_id` is used to synchronize the team with external systems, this
|
||||
is the equivalent of the User `sub` field but for teams (note: `sub` is highly related
|
||||
to the OIDC standard, while `external_id` is not). The `external_id` is NOT the same as
|
||||
the `externalId` field in the SCIM standard when importing teams from SCIM.
|
||||
"""
|
||||
|
||||
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
|
||||
@@ -752,6 +758,14 @@ class Team(MP_Node, BaseModel):
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
external_id = models.UUIDField(
|
||||
verbose_name=_("external_id"),
|
||||
help_text=_("Team external UUID for synchronization with external systems"),
|
||||
unique=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
through="TeamAccess",
|
||||
@@ -800,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]
|
||||
@@ -851,16 +865,12 @@ class TeamAccess(BaseModel):
|
||||
Override save function to fire webhooks on any addition or update
|
||||
to a team access.
|
||||
"""
|
||||
|
||||
if self._state.adding:
|
||||
if self._state.adding and self.team.webhooks.exists():
|
||||
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
with transaction.atomic():
|
||||
instance = super().save(*args, **kwargs)
|
||||
scim_synchronizer.add_user_to_group(self.team, self.user)
|
||||
else:
|
||||
instance = super().save(*args, **kwargs)
|
||||
# try to synchronize all webhooks
|
||||
webhooks_synchronizer.add_user_to_group(self.team, self.user)
|
||||
|
||||
return instance
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
@@ -868,11 +878,12 @@ class TeamAccess(BaseModel):
|
||||
Don't allow deleting a team access until it is successfully synchronized with all
|
||||
its webhooks.
|
||||
"""
|
||||
self.team.webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
with transaction.atomic():
|
||||
arguments = self.team, self.user
|
||||
super().delete(*args, **kwargs)
|
||||
scim_synchronizer.remove_user_from_group(*arguments)
|
||||
if webhooks := self.team.webhooks.all():
|
||||
webhooks.update(status=WebhookStatusChoices.PENDING)
|
||||
# try to synchronize all webhooks
|
||||
webhooks_synchronizer.remove_user_from_group(self.team, self.user)
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
@@ -890,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]
|
||||
@@ -930,6 +941,11 @@ class TeamWebhook(BaseModel):
|
||||
team = models.ForeignKey(Team, related_name="webhooks", on_delete=models.CASCADE)
|
||||
url = models.URLField(_("url"))
|
||||
secret = models.CharField(_("secret"), max_length=255, null=True, blank=True)
|
||||
protocol = models.CharField(
|
||||
max_length=None,
|
||||
default=WebhookProtocolChoices.SCIM,
|
||||
choices=WebhookProtocolChoices.choices,
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
default=WebhookStatusChoices.PENDING,
|
||||
@@ -981,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."""
|
||||
@@ -1081,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
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
"""Test fixtures."""
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"""Define here some fake responses from Matrix API, useful to mock responses in tests."""
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
# SEARCH
|
||||
def mock_search_empty():
|
||||
"""Mock response when no Matrix user has been found through search."""
|
||||
return {
|
||||
"message": {"limited": "false", "results": []},
|
||||
"status_code": status.HTTP_200_OK,
|
||||
}
|
||||
|
||||
|
||||
def mock_search_successful(user):
|
||||
"""Mock response when exactly one user has been found through search."""
|
||||
return {
|
||||
"message": {
|
||||
"limited": "false",
|
||||
"results": [
|
||||
{
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
|
||||
"display_name": f"@{user.name} [Fake]",
|
||||
"avatar_url": "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
"status_code": status.HTTP_200_OK,
|
||||
}
|
||||
|
||||
|
||||
def mock_search_successful_multiple(user):
|
||||
"""Mock response when more than one user has been found through search."""
|
||||
return {
|
||||
"message": {
|
||||
"limited": "false",
|
||||
"results": [
|
||||
{
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server1.com",
|
||||
"display_name": f"@{user.name} [Fake]",
|
||||
"avatar_url": "null",
|
||||
},
|
||||
{
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server2.com",
|
||||
"display_name": f"@{user.name} [Other Fake]",
|
||||
"avatar_url": "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
"status_code": status.HTTP_200_OK,
|
||||
}
|
||||
|
||||
|
||||
# JOIN ROOMS
|
||||
def mock_join_room_successful(room_id):
|
||||
"""Mock response when succesfully joining room. Same response if already in room."""
|
||||
return {"message": {"room_id": str(room_id)}, "status_code": status.HTTP_200_OK}
|
||||
|
||||
|
||||
def mock_join_room_no_known_servers():
|
||||
"""Mock response when room to join cannot be found."""
|
||||
return {
|
||||
"message": {"errcode": "M_UNKNOWN", "error": "No known servers"},
|
||||
"status_code": status.HTTP_404_NOT_FOUND,
|
||||
}
|
||||
|
||||
|
||||
def mock_join_room_forbidden():
|
||||
"""Mock response when room cannot be joined."""
|
||||
return {
|
||||
"message": {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "You do not belong to any of the required rooms/spaces to join this room.",
|
||||
},
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
}
|
||||
|
||||
|
||||
# INVITE USER
|
||||
def mock_invite_successful():
|
||||
"""Mock response when invite request was succesful. Does not check the user exists."""
|
||||
return {"message": {}, "status_code": status.HTTP_200_OK}
|
||||
|
||||
|
||||
def mock_invite_user_already_in_room(user):
|
||||
"""Mock response when invitation forbidden for People user."""
|
||||
return {
|
||||
"message": {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": f"{user.email.replace('@', '-')}:home_server.fr is already in the room.",
|
||||
},
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
}
|
||||
|
||||
|
||||
# KICK USER
|
||||
def mock_kick_successful():
|
||||
"""Mock response when succesfully joining room."""
|
||||
return {"message": {}, "status_code": status.HTTP_200_OK}
|
||||
|
||||
|
||||
def mock_kick_user_forbidden(user):
|
||||
"""Mock response when kick request is forbidden (i.e. wrong permission or user is room admin."""
|
||||
return {
|
||||
"message": {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": f"You cannot kick user @{user.email.replace('@', '-')}.",
|
||||
},
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
}
|
||||
|
||||
|
||||
def mock_kick_user_not_in_room():
|
||||
"""
|
||||
Mock response when trying to kick a user who isn't in the room. Don't check the user exists.
|
||||
"""
|
||||
return {
|
||||
"message": {
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "The target user is not in the room",
|
||||
},
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server SCIM API endpoints."""
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Tests for the SCIM Me API endpoint in People's core app
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
from core import factories
|
||||
from core.models import RoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_me_anonymous(client):
|
||||
"""Anonymous users should not be allowed to access the Me endpoint."""
|
||||
response = client.get("/resource-server/v1.0/scim/Me/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.headers["Content-Type"] == "application/json+scim"
|
||||
|
||||
# Check the full response with the expected structure
|
||||
assert response.json() == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"status": "401",
|
||||
"detail": "",
|
||||
}
|
||||
|
||||
|
||||
def test_api_me_authenticated(
|
||||
client, force_login_via_resource_server, django_assert_num_queries
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to access their own information
|
||||
in SCIM format.
|
||||
"""
|
||||
user = factories.UserFactory(name="Test User", email="test@example.com")
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/scim/Me/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.headers["Content-Type"] == "application/json+scim"
|
||||
|
||||
# Check the full response with the expected structure
|
||||
assert response.json() == {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": str(user.pk),
|
||||
"active": True,
|
||||
"userName": user.sub,
|
||||
"displayName": user.name,
|
||||
"emails": [
|
||||
{
|
||||
"value": user.email,
|
||||
"primary": True,
|
||||
"type": "work",
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
"created": user.created_at.isoformat(),
|
||||
"lastModified": user.updated_at.isoformat(),
|
||||
"location": "http://testserver/resource-server/v1.0/scim/Me/",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_api_me_authenticated_with_team_access(
|
||||
client, force_login_via_resource_server, django_assert_num_queries
|
||||
):
|
||||
"""
|
||||
Authenticated users with TeamAccess should see their team information
|
||||
in SCIM format, but only for teams visible to the service provider.
|
||||
"""
|
||||
user = factories.UserFactory(name="Test User", email="test@example.com")
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
# Create teams with different visibility settings
|
||||
team_visible = factories.TeamFactory(name="Visible Team")
|
||||
team_visible.service_providers.add(service_provider)
|
||||
|
||||
team_all_services = factories.TeamFactory(
|
||||
name="All Services Team", is_visible_all_services=True
|
||||
)
|
||||
|
||||
team_not_visible = factories.TeamFactory(name="Not Visible Team")
|
||||
# This team is not associated with the service provider
|
||||
|
||||
# Add user to all teams
|
||||
factories.TeamAccessFactory(user=user, team=team_visible, role=RoleChoices.MEMBER)
|
||||
factories.TeamAccessFactory(
|
||||
user=user, team=team_all_services, role=RoleChoices.ADMIN
|
||||
)
|
||||
factories.TeamAccessFactory(
|
||||
user=user, team=team_not_visible, role=RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
with django_assert_num_queries(
|
||||
2
|
||||
): # User + TeamAccess (with select_related teams)
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/scim/Me/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.headers["Content-Type"] == "application/json+scim"
|
||||
# Check the full response with the expected structure
|
||||
assert response.json() == {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": str(user.pk),
|
||||
"active": True,
|
||||
"userName": user.sub,
|
||||
"displayName": user.name,
|
||||
"emails": [
|
||||
{
|
||||
"value": user.email,
|
||||
"primary": True,
|
||||
"type": "work",
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"value": str(team_visible.external_id),
|
||||
"display": team_visible.name,
|
||||
"type": "direct",
|
||||
},
|
||||
{
|
||||
"value": str(team_all_services.external_id),
|
||||
"display": team_all_services.name,
|
||||
"type": "direct",
|
||||
},
|
||||
],
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
"created": user.created_at.isoformat(),
|
||||
"lastModified": user.updated_at.isoformat(),
|
||||
"location": "http://testserver/resource-server/v1.0/scim/Me/",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"http_method",
|
||||
["post", "put", "patch", "delete"],
|
||||
ids=["POST", "PUT", "PATCH", "DELETE"],
|
||||
)
|
||||
def test_api_me_method_not_allowed(
|
||||
client, force_login_via_resource_server, http_method
|
||||
):
|
||||
"""Test that methods other than GET are not allowed for the Me endpoint."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
client_method = getattr(client, http_method)
|
||||
response = client_method(
|
||||
"/resource-server/v1.0/scim/Me/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.headers["Content-Type"] == "application/json+scim"
|
||||
|
||||
# Check the full response with the expected structure
|
||||
assert response.json() == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"status": str(HTTP_405_METHOD_NOT_ALLOWED),
|
||||
"detail": "",
|
||||
}
|
||||
@@ -3,14 +3,17 @@ Test for team accesses API endpoints in People's core app : create
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core import enums, factories, models
|
||||
from core.tests.fixtures import matrix
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -171,14 +174,17 @@ def test_api_team_accesses_create_authenticated_owner():
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_webhook():
|
||||
def test_api_team_accesses_create__with_scim_webhook():
|
||||
"""
|
||||
When the team has a webhook, creating a team access should fire a call.
|
||||
If a team has a SCIM webhook, creating a team access should fire a call
|
||||
with the expected payload.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
team=team, protocol=enums.WebhookProtocolChoices.SCIM
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
@@ -226,3 +232,207 @@ def test_api_team_accesses_create_webhook():
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_team_accesses_create__with_matrix_webhook():
|
||||
"""
|
||||
If a team has a Matrix webhook, creating a team access should fire a call
|
||||
with the expected payload.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
team=team,
|
||||
url="https://server.fr/#/room/room_id:room_server.fr",
|
||||
secret="some-secret-you-should-not-store-on-a-postit",
|
||||
protocol=enums.WebhookProtocolChoices.MATRIX,
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock successful responses by matrix server
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=matrix.mock_join_room_successful("room_id")["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(other_user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_successful()["message"]),
|
||||
status=matrix.mock_invite_successful()["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
assert len(responses.calls) == 3
|
||||
assert (
|
||||
responses.calls[0].request.url
|
||||
== "https://room_server.fr/_matrix/client/v3/rooms/room_id:room_server.fr/join"
|
||||
)
|
||||
|
||||
# Payload sent to matrix server
|
||||
assert webhook.secret in responses.calls[0].request.headers["Authorization"]
|
||||
assert json.loads(responses.calls[2].request.body) == {
|
||||
"user_id": f"@{other_user.email.replace('@', '-')}:user_server.com",
|
||||
"reason": f"User added to team {webhook.team} on People",
|
||||
}
|
||||
|
||||
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_team_accesses_create__multiple_webhooks_success(caplog):
|
||||
"""
|
||||
When the team has multiple webhooks, creating a team access should fire all the expected calls.
|
||||
If all responses are positive, proceeds to add the user to the team.
|
||||
"""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook_scim = factories.TeamWebhookFactory(
|
||||
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
|
||||
)
|
||||
webhook_matrix = factories.TeamWebhookFactory(
|
||||
team=team,
|
||||
url="https://www.webhookserver.fr/#/room/room_id:home_server.fr/",
|
||||
protocol=enums.WebhookProtocolChoices.MATRIX,
|
||||
secret="yo",
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Ensure successful response by scim provider using "responses":
|
||||
responses.patch(
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_successful()["message"]),
|
||||
status=matrix.mock_invite_successful()["status_code"],
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
for webhook in [webhook_scim, webhook_matrix]:
|
||||
assert (
|
||||
f"add_user_to_group synchronization succeeded with {webhook.url}"
|
||||
in log_messages
|
||||
)
|
||||
|
||||
# Status
|
||||
for webhook in [webhook_scim, webhook_matrix]:
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_team_accesses_create__multiple_webhooks_failure(caplog):
|
||||
"""When a webhook fails, user should still be added to the team."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook_scim = factories.TeamWebhookFactory(
|
||||
team=team, protocol=enums.WebhookProtocolChoices.SCIM, secret="wesh"
|
||||
)
|
||||
webhook_matrix = factories.TeamWebhookFactory(
|
||||
team=team,
|
||||
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
|
||||
protocol=enums.WebhookProtocolChoices.MATRIX,
|
||||
secret="secret",
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
responses.patch(
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=200,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_forbidden()["message"]),
|
||||
status=str(matrix.mock_join_room_forbidden()["status_code"]),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
assert (
|
||||
f"add_user_to_group synchronization succeeded with {webhook_scim.url}"
|
||||
in log_messages
|
||||
)
|
||||
assert (
|
||||
f"add_user_to_group synchronization failed with {webhook_matrix.url}"
|
||||
in log_messages
|
||||
)
|
||||
|
||||
# Status
|
||||
webhook_scim.status = "success"
|
||||
webhook_matrix.status = "failure"
|
||||
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Test Team synchronization webhooks : focus on matrix client."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories
|
||||
from core.enums import WebhookProtocolChoices
|
||||
from core.tests.fixtures import matrix
|
||||
from core.utils.matrix import MatrixAPIClient
|
||||
from core.utils.webhooks import webhooks_synchronizer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
## SEARCH
|
||||
@responses.activate
|
||||
def test_matrix_webhook__search_user_unknown(caplog):
|
||||
"""When searching for a user in the Matrix federation but cannot find any,
|
||||
we invite a (future ?) user using user's email and room's server."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:room_server.au",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
client = MatrixAPIClient()
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_empty()["message"]),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.get_user_id(user=user, webhook=webhook)
|
||||
assert response == f"@{user.email.replace('@', '-')}:room_server.au"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__search_multiple_ids(caplog):
|
||||
"""When searching for a user in Matrix federation,
|
||||
if user directory returns multiple ids, invite the first one."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:room_server.au",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
client = MatrixAPIClient()
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful_multiple(user)["message"]),
|
||||
status=status.HTTP_200_OK,
|
||||
content_type="application/json",
|
||||
)
|
||||
response = client.get_user_id(user=user, webhook=webhook)
|
||||
assert response == f"@{user.email.replace('@', '-')}:user_server1.com"
|
||||
|
||||
|
||||
## INVITE
|
||||
@responses.activate
|
||||
def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
|
||||
"""Cannot invite when Matrix returns forbidden. This might mean the user is an admin."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
error = matrix.mock_kick_user_forbidden(user)
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(error["message"]),
|
||||
status=error["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
|
||||
"""If user is already in room, webhooks should be set to success."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=matrix.mock_join_room_successful("room_id")["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_user_already_in_room(user)["message"]),
|
||||
status=matrix.mock_invite_user_already_in_room(user)["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
expected_messages = (
|
||||
f"add_user_to_group synchronization succeeded with {webhook.url}"
|
||||
)
|
||||
assert expected_messages in log_messages
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__invite_user_to_room_success(caplog):
|
||||
"""The user passed to the function should get invited."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=matrix.mock_join_room_successful("room_id")["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_successful()["message"]),
|
||||
status=matrix.mock_invite_successful()["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
|
||||
|
||||
# Check headers
|
||||
headers = responses.calls[0].request.headers
|
||||
assert webhook.secret in headers["Authorization"]
|
||||
|
||||
# Check payloads sent to Matrix API
|
||||
assert json.loads(responses.calls[2].request.body) == {
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
|
||||
"reason": f"User added to team {webhook.team} on People",
|
||||
}
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
expected_messages = (
|
||||
f"add_user_to_group synchronization succeeded with {webhook.url}"
|
||||
)
|
||||
assert expected_messages in log_messages
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
|
||||
|
||||
@responses.activate
|
||||
@override_settings(MATRIX_BOT_ACCESS_TOKEN="TCHAP_TOKEN")
|
||||
def test_matrix_webhook__override_secret_for_tchap():
|
||||
"""The user passed to the function should get invited."""
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.tchap.gouv.fr/#/room/room_id:home_server",
|
||||
secret=None,
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful("room_id")["message"]),
|
||||
status=matrix.mock_join_room_successful("room_id")["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/invite"),
|
||||
body=str(matrix.mock_invite_successful()["message"]),
|
||||
status=matrix.mock_invite_successful()["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.add_user_to_group(team=webhook.team, user=user)
|
||||
|
||||
# Check headers
|
||||
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
|
||||
def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
|
||||
"""Webhook should report a success when user was already not in room."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/kick"),
|
||||
body=str(matrix.mock_kick_user_not_in_room()["message"]),
|
||||
status=matrix.mock_kick_user_not_in_room()["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
assert (
|
||||
f"remove_user_from_group synchronization succeeded with {webhook.url}"
|
||||
in log_messages
|
||||
)
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__kick_user_from_room_success(caplog):
|
||||
"""The user passed to the function should get removed."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:room_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/kick"),
|
||||
body=str(matrix.mock_kick_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
|
||||
|
||||
# Check payloads sent to Matrix API
|
||||
assert json.loads(responses.calls[2].request.body) == {
|
||||
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
|
||||
"reason": f"User removed from team {webhook.team} on People",
|
||||
}
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
expected_messages = (
|
||||
f"remove_user_from_group synchronization succeeded with {webhook.url}"
|
||||
)
|
||||
assert expected_messages in log_messages
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "success"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
|
||||
"""Cannot kick an admin."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
user = factories.UserFactory()
|
||||
webhook = factories.TeamWebhookFactory(
|
||||
protocol=WebhookProtocolChoices.MATRIX,
|
||||
url="https://www.matrix.org/#/room/room_id:home_server",
|
||||
secret="secret-access-token",
|
||||
)
|
||||
|
||||
# Mock successful responses
|
||||
error = matrix.mock_kick_user_forbidden(user)
|
||||
responses.post(
|
||||
re.compile(r".*/join"),
|
||||
body=str(matrix.mock_join_room_successful),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/search"),
|
||||
body=json.dumps(matrix.mock_search_successful(user)["message"]),
|
||||
status=matrix.mock_search_successful(user)["status_code"],
|
||||
)
|
||||
responses.post(
|
||||
re.compile(r".*/kick"),
|
||||
body=str(error["message"]),
|
||||
status=error["status_code"],
|
||||
)
|
||||
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
|
||||
|
||||
# Logger
|
||||
log_messages = [msg.message for msg in caplog.records]
|
||||
assert (
|
||||
f"remove_user_from_group synchronization failed with {webhook.url}"
|
||||
in log_messages
|
||||
)
|
||||
|
||||
# Status
|
||||
webhook.refresh_from_db()
|
||||
assert webhook.status == "failure"
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Test Team synchronization webhooks."""
|
||||
"""Test Team synchronization webhooks : focus on scim client"""
|
||||
|
||||
import json
|
||||
import random
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
import responses
|
||||
|
||||
from core import factories
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.utils.webhooks import webhooks_synchronizer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_utils_webhooks_add_user_to_group_no_webhooks():
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
with responses.RequestsMock():
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_utils_webhooks_add_user_to_group_success(mock_info):
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
for i, webhook in enumerate(webhooks):
|
||||
assert rsps.calls[i].request.url == webhook.url
|
||||
@@ -107,7 +107,7 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.remove_user_from_group(access.team, access.user)
|
||||
webhooks_synchronizer.remove_user_from_group(access.team, access.user)
|
||||
|
||||
for i, webhook in enumerate(webhooks):
|
||||
assert rsps.calls[i].request.url == webhook.url
|
||||
@@ -163,11 +163,11 @@ def test_utils_webhooks_add_user_to_group_failure(mock_error):
|
||||
rsps.PATCH,
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=random.choice([404, 301, 302]),
|
||||
status=404,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
for i, webhook in enumerate(webhooks):
|
||||
assert rsps.calls[i].request.url == webhook.url
|
||||
@@ -228,7 +228,7 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
|
||||
rsps.add(rsps.PATCH, url, status=200, content_type="application/json"),
|
||||
]
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
for i in range(4):
|
||||
assert all_rsps[i].call_count == 1
|
||||
@@ -285,7 +285,7 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_error):
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
|
||||
assert rsp.call_count == 5
|
||||
assert rsps.calls[0].request.url == webhook.url
|
||||
@@ -339,7 +339,7 @@ def test_utils_webhooks_add_user_to_group_authorization():
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
scim_synchronizer.add_user_to_group(access.team, access.user)
|
||||
webhooks_synchronizer.add_user_to_group(access.team, access.user)
|
||||
assert rsps.calls[0].request.url == webhook.url
|
||||
|
||||
# Check headers
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Matrix client for interoperability to synchronize with remote service providers."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
)
|
||||
from urllib3.util import Retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
max_retries=Retry(
|
||||
total=4,
|
||||
backoff_factor=0.1,
|
||||
status_forcelist=[500, 502],
|
||||
allowed_methods=["POST"],
|
||||
)
|
||||
)
|
||||
|
||||
session = requests.Session()
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
|
||||
class MatrixAPIClient:
|
||||
"""A client to interact with Matrix API"""
|
||||
|
||||
def get_headers(self, webhook):
|
||||
"""Build header dict from webhook object."""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if "tchap.gouv.fr" in webhook.url:
|
||||
token = settings.MATRIX_BOT_ACCESS_TOKEN
|
||||
elif webhook.secret:
|
||||
token = webhook.secret
|
||||
else:
|
||||
raise ValueError("Please configure this webhook's secret access token.")
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
def _get_room_url(self, webhook_url):
|
||||
"""Returns room id from webhook url."""
|
||||
room_id = webhook_url.split("/room/")[1]
|
||||
base_url = room_id.split(":")[1]
|
||||
if "tchap.gouv.fr" in webhook_url and "i.tchap.gouv.fr" not in webhook_url:
|
||||
base_url = f"matrix.{base_url}"
|
||||
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
|
||||
|
||||
def get_user_id(self, user, webhook):
|
||||
"""Returns user id from email."""
|
||||
if user.email is None:
|
||||
raise ValueError("You must first set an email for the user.")
|
||||
|
||||
if settings.MATRIX_BASE_HOME_SERVER:
|
||||
home_server = settings.MATRIX_BASE_HOME_SERVER
|
||||
search = session.post(
|
||||
f"{home_server}/_matrix/client/v3/user_directory/search",
|
||||
json={"search_term": f"@{user.email.replace('@', '-')}"},
|
||||
headers=self.get_headers(webhook),
|
||||
verify=True,
|
||||
timeout=3,
|
||||
)
|
||||
results = search.json()["results"]
|
||||
if len(results) > 0:
|
||||
return results[0]["user_id"]
|
||||
|
||||
# try and invite unknown user using room home server
|
||||
room_home_server = webhook.url.split(":")[2]
|
||||
return f"@{user.email.replace('@', '-')}:{room_home_server}"
|
||||
|
||||
def join_room(self, webhook):
|
||||
"""Accept invitation to the room. As of today, it is a mandatory step
|
||||
to make sure our account will be able to invite/remove users."""
|
||||
return session.post(
|
||||
f"{self._get_room_url(webhook.url)}/join",
|
||||
json={},
|
||||
headers=self.get_headers(webhook),
|
||||
verify=True,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
def add_user_to_group(self, webhook, user):
|
||||
"""Send request to invite an user to a room or space upon adding them to group.."""
|
||||
join_response = self.join_room(webhook)
|
||||
if join_response.status_code != HTTP_200_OK:
|
||||
logger.error(
|
||||
"Synchronization failed (cannot join room) %s",
|
||||
webhook.url,
|
||||
)
|
||||
return join_response, False
|
||||
logger.info(
|
||||
"Succesfully joined room",
|
||||
)
|
||||
|
||||
user_id = self.get_user_id(user, webhook)
|
||||
response = session.post(
|
||||
f"{self._get_room_url(webhook.url)}/invite",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"reason": f"User added to team {webhook.team} on People",
|
||||
},
|
||||
headers=self.get_headers(webhook),
|
||||
verify=True,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
# Checks for false negative
|
||||
# (i.e. trying to invite user already in room)
|
||||
webhook_succeeded = False
|
||||
if (
|
||||
response.status_code == HTTP_200_OK
|
||||
or b"is already in the room." in response.content
|
||||
):
|
||||
webhook_succeeded = True
|
||||
|
||||
return response, webhook_succeeded
|
||||
|
||||
def remove_user_from_group(self, webhook, user):
|
||||
"""Send request to kick an user from a room or space upon removing them from group."""
|
||||
join_response = self.join_room(webhook)
|
||||
if join_response.status_code != HTTP_200_OK:
|
||||
logger.error(
|
||||
"Synchronization failed (cannot join room) %s",
|
||||
webhook.url,
|
||||
)
|
||||
return join_response, False
|
||||
|
||||
user_id = self.get_user_id(user, webhook)
|
||||
response = session.post(
|
||||
f"{self._get_room_url(webhook.url)}/kick",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"reason": f"User removed from team {webhook.team} on People",
|
||||
},
|
||||
headers=self.get_headers(webhook),
|
||||
verify=True,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
# Checks for false negative
|
||||
# (i.e. trying to remove user who already left the room)
|
||||
webhook_succeeded = False
|
||||
if (
|
||||
response.status_code == HTTP_200_OK
|
||||
or b"The target user is not in the room" in response.content
|
||||
):
|
||||
webhook_succeeded = True
|
||||
|
||||
return response, webhook_succeeded
|
||||
@@ -39,7 +39,7 @@ class SCIMClient:
|
||||
],
|
||||
}
|
||||
|
||||
return session.patch(
|
||||
response = session.patch(
|
||||
webhook.url,
|
||||
json=payload,
|
||||
headers=webhook.get_headers(),
|
||||
@@ -47,6 +47,8 @@ class SCIMClient:
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
return response, response.ok
|
||||
|
||||
def remove_user_from_group(self, webhook, user):
|
||||
"""Remove a user from a group by its ID or email."""
|
||||
payload = {
|
||||
@@ -61,10 +63,12 @@ class SCIMClient:
|
||||
}
|
||||
],
|
||||
}
|
||||
return session.patch(
|
||||
response = session.patch(
|
||||
webhook.url,
|
||||
json=payload,
|
||||
headers=webhook.get_headers(),
|
||||
verify=False,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
return response, response.ok
|
||||
|
||||
@@ -4,14 +4,16 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
from core import enums
|
||||
from core.enums import WebhookStatusChoices
|
||||
|
||||
from .matrix import MatrixAPIClient
|
||||
from .scim import SCIMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookSCIMClient:
|
||||
class WebhookClient:
|
||||
"""Wraps the SCIM client to record call results on webhooks."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
@@ -26,31 +28,14 @@ class WebhookSCIMClient:
|
||||
if not webhook.url:
|
||||
continue
|
||||
|
||||
client = SCIMClient()
|
||||
status = WebhookStatusChoices.FAILURE
|
||||
try:
|
||||
response = getattr(client, name)(webhook, user)
|
||||
|
||||
except requests.exceptions.RetryError as exc:
|
||||
logger.error(
|
||||
"%s synchronization failed due to max retries exceeded with url %s",
|
||||
name,
|
||||
webhook.url,
|
||||
exc_info=exc,
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.error(
|
||||
"%s synchronization failed with %s.",
|
||||
name,
|
||||
webhook.url,
|
||||
exc_info=exc,
|
||||
)
|
||||
else:
|
||||
extra = {
|
||||
"response": response.content,
|
||||
}
|
||||
response, webhook_succeeded = self._get_response_and_status(
|
||||
name, webhook, user
|
||||
)
|
||||
if response is not None:
|
||||
extra = {"response": response.content}
|
||||
# pylint: disable=no-member
|
||||
if response.status_code == requests.codes.ok:
|
||||
if webhook_succeeded:
|
||||
logger.info(
|
||||
"%s synchronization succeeded with %s",
|
||||
name,
|
||||
@@ -71,5 +56,37 @@ class WebhookSCIMClient:
|
||||
|
||||
return wrapper
|
||||
|
||||
def _get_client(self, webhook):
|
||||
"""Get client depending on the protocol."""
|
||||
if webhook.protocol == enums.WebhookProtocolChoices.MATRIX:
|
||||
return MatrixAPIClient()
|
||||
|
||||
scim_synchronizer = WebhookSCIMClient()
|
||||
return SCIMClient()
|
||||
|
||||
def _get_response_and_status(self, name, webhook, user):
|
||||
"""Get response from webhook outside party."""
|
||||
client = self._get_client(webhook)
|
||||
|
||||
try:
|
||||
response, webhook_succeeded = getattr(client, name)(webhook, user)
|
||||
except requests.exceptions.RetryError as exc:
|
||||
logger.error(
|
||||
"%s synchronization failed due to max retries exceeded with url %s",
|
||||
name,
|
||||
webhook.url,
|
||||
exc_info=exc,
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.error(
|
||||
"%s synchronization failed with %s.",
|
||||
name,
|
||||
webhook.url,
|
||||
exc_info=exc,
|
||||
)
|
||||
else:
|
||||
return response, webhook_succeeded
|
||||
|
||||
return None, False
|
||||
|
||||
|
||||
webhooks_synchronizer = WebhookClient()
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
Binary file not shown.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
|
||||
"PO-Revision-Date: 2025-06-11 09:29\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,559 +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:1106
|
||||
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
|
||||
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
|
||||
#: 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:958 core/models.py:509
|
||||
#: core/models.py:958
|
||||
#: 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:774 core/models.py:774
|
||||
#: core/models.py:762
|
||||
msgid "external_id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:763
|
||||
msgid "Team external UUID for synchronization with external systems"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:788
|
||||
msgid "is visible for all SP"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:776 core/models.py:776
|
||||
#: core/models.py:790
|
||||
msgid "Whether this team is visible to all service providers."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
#: core/models.py:798
|
||||
msgid "Team"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:785 core/models.py:785
|
||||
#: core/models.py:799
|
||||
msgid "Teams"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:836 core/models.py:836
|
||||
#: core/models.py:850
|
||||
msgid "Team/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:837 core/models.py:837
|
||||
#: core/models.py:851
|
||||
msgid "Team/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:842 core/models.py:842
|
||||
#: core/models.py:856
|
||||
msgid "This user is already in this team."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:931 core/models.py:931
|
||||
#: core/models.py:942
|
||||
msgid "url"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: core/models.py:943
|
||||
msgid "secret"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:941 core/models.py:941
|
||||
#: core/models.py:957
|
||||
msgid "Team webhook"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:942 core/models.py:942
|
||||
#: core/models.py:958
|
||||
msgid "Team webhooks"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:986 core/models.py:986
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: core/models.py:1019
|
||||
msgid "Invitation to join La Régie!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
#: core/models.py:1065
|
||||
msgid "Team invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
#: core/models.py:1066
|
||||
msgid "Team invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1060 core/models.py:1060
|
||||
#: 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:1108 core/models.py:1108
|
||||
#: core/models.py:1127
|
||||
msgid "api key"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1113 core/models.py:1113
|
||||
#: core/models.py:1132
|
||||
msgid "allowed scopes"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1114 core/models.py:1114
|
||||
#: core/models.py:1133
|
||||
msgid "Allowed scopes for this service"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1119 core/models.py:1119
|
||||
#: core/models.py:1138
|
||||
msgid "Account service"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1120 core/models.py:1120
|
||||
#: 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"
|
||||
@@ -861,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 ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
|
||||
"PO-Revision-Date: 2025-06-11 09:29\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,559 +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:1106
|
||||
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
|
||||
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
|
||||
#: 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:958 core/models.py:509
|
||||
#: core/models.py:958
|
||||
#: 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:774 core/models.py:774
|
||||
#: core/models.py:762
|
||||
msgid "external_id"
|
||||
msgstr "ID externe"
|
||||
|
||||
#: core/models.py:763
|
||||
msgid "Team external UUID for synchronization with external systems"
|
||||
msgstr "UUID externe de l'équipe pour la synchronisation avec des systèmes externes"
|
||||
|
||||
#: core/models.py:788
|
||||
msgid "is visible for all SP"
|
||||
msgstr "est visible pour tous les fournisseurs de service"
|
||||
|
||||
#: build/lib/core/models.py:776 core/models.py:776
|
||||
#: 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:784 core/models.py:784
|
||||
#: core/models.py:798
|
||||
msgid "Team"
|
||||
msgstr "Équipe"
|
||||
|
||||
#: build/lib/core/models.py:785 core/models.py:785
|
||||
#: core/models.py:799
|
||||
msgid "Teams"
|
||||
msgstr "Équipes"
|
||||
|
||||
#: build/lib/core/models.py:836 core/models.py:836
|
||||
#: core/models.py:850
|
||||
msgid "Team/user relation"
|
||||
msgstr "Relation équipe/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:837 core/models.py:837
|
||||
#: core/models.py:851
|
||||
msgid "Team/user relations"
|
||||
msgstr "Relations équipe/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:842 core/models.py:842
|
||||
#: 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:931 core/models.py:931
|
||||
#: core/models.py:942
|
||||
msgid "url"
|
||||
msgstr "url"
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: core/models.py:943
|
||||
msgid "secret"
|
||||
msgstr "secret"
|
||||
|
||||
#: build/lib/core/models.py:941 core/models.py:941
|
||||
#: core/models.py:957
|
||||
msgid "Team webhook"
|
||||
msgstr "Webhook d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:942 core/models.py:942
|
||||
#: core/models.py:958
|
||||
msgid "Team webhooks"
|
||||
msgstr "Webhooks d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:986 core/models.py:986
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: core/models.py:1019
|
||||
msgid "Invitation to join La Régie!"
|
||||
msgstr "Invitation à rejoindre La Régie !"
|
||||
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
#: core/models.py:1065
|
||||
msgid "Team invitation"
|
||||
msgstr "Invitation d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
#: core/models.py:1066
|
||||
msgid "Team invitations"
|
||||
msgstr "Invitations d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:1060 core/models.py:1060
|
||||
#: 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:1108 core/models.py:1108
|
||||
#: core/models.py:1127
|
||||
msgid "api key"
|
||||
msgstr "Clé d'API"
|
||||
|
||||
#: build/lib/core/models.py:1113 core/models.py:1113
|
||||
#: core/models.py:1132
|
||||
msgid "allowed scopes"
|
||||
msgstr "périmètres autorisés"
|
||||
|
||||
#: build/lib/core/models.py:1114 core/models.py:1114
|
||||
#: 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:1119 core/models.py:1119
|
||||
#: core/models.py:1138
|
||||
msgid "Account service"
|
||||
msgstr "Compte de service"
|
||||
|
||||
#: build/lib/core/models.py:1120 core/models.py:1120
|
||||
#: 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"
|
||||
@@ -652,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
|
||||
@@ -861,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,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
|
||||
Convert valid domain invitations to domain accesses for a given user.
|
||||
Expired invitations are ignored.
|
||||
"""
|
||||
logger.info("Convert domain invitations for user %s", instance)
|
||||
if created:
|
||||
valid_domain_invitations = MailDomainInvitation.objects.filter(
|
||||
email=instance.email,
|
||||
@@ -36,6 +35,11 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
|
||||
if not valid_domain_invitations.exists():
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Converting %s domain invitations for new user %s",
|
||||
len(valid_domain_invitations),
|
||||
instance,
|
||||
)
|
||||
MailDomainAccess.objects.bulk_create(
|
||||
[
|
||||
MailDomainAccess(
|
||||
@@ -46,4 +50,3 @@ def convert_domain_invitations(sender, created, instance, **kwargs): # pylint:
|
||||
)
|
||||
|
||||
valid_domain_invitations.delete()
|
||||
logger.info("Invitations converted to domain accesses for user %s", instance)
|
||||
|
||||
@@ -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
|
||||
+90
-36
@@ -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,19 +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__inviting_known_email_should_create_access():
|
||||
"""Already existing users should not be invited but given access directly."""
|
||||
existing_user = core_factories.UserFactory()
|
||||
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(access.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
|
||||
{
|
||||
"email": existing_user.email,
|
||||
"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."
|
||||
)
|
||||
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user