Compare commits

..

1 Commits

Author SHA1 Message Date
Eléonore Voisin 7522569ce0 Try to fix test 2025-02-07 10:05:03 +01:00
216 changed files with 1809 additions and 11200 deletions
+2 -2
View File
@@ -9,9 +9,9 @@ We primarily use GitHub as an issue tracker. If however you're encountering an i
---
Please make sure you have read our [main Readme](https://github.com/suitenumerique/people).
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people).
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/people/issues).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
-77
View File
@@ -1,77 +0,0 @@
name: Download translations from Crowdin
on:
workflow_dispatch:
push:
branches:
- 'release/**'
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
mkdir -p src/frontend/packages/i18n/locales/desk/
touch src/frontend/packages/i18n/locales/desk/translations-crowdin.json
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
push_sources: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate translations files
working-directory: src/frontend
run: yarn i18n:deploy
# Create a new PR
- name: Create a new Pull Request with new translated strings
uses: peter-evans/create-pull-request@v7
with:
commit-message: |
🌐(i18n) update translated strings
Update translated files with new translations
title: 🌐(i18n) update translated strings
body: |
## Purpose
update translated strings
## Proposal
- [x] update translated strings
branch: i18n/update-translations
labels: i18n
-76
View File
@@ -1,76 +0,0 @@
name: Update crowdin sources
on:
workflow_dispatch:
push:
branches:
- main
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
with-front-dependencies-installation: true
with-build_mails: true
synchronize-with-crowdin:
needs: install-dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.11"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Install gettext
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
- name: generate pot files
working-directory: src/backend
run: |
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate source translation file
working-directory: src/frontend
run: yarn i18n:extract
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"
-85
View File
@@ -1,85 +0,0 @@
name: Dependency reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '18.x'
type: string
with-front-dependencies-installation:
type: boolean
default: false
with-build_mails:
type: boolean
default: false
jobs:
front-dependencies-installation:
if: ${{ inputs.with-front-dependencies-installation == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
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
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-mails:
if: ${{ inputs.with-build_mails == true }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Setup Node.js
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
+73 -28
View File
@@ -9,14 +9,6 @@ on:
- '*'
jobs:
# Call the reusable workflow to install dependencies
dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
with-front-dependencies-installation: true
with-build_mails: true
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
@@ -29,7 +21,7 @@ jobs:
run: git log
- name: Enforce absence of print statements in code
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep -E "(\bprint\(|\bpprint\()"
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep "print("
- name: Check absence of fixup commits
run: |
! git log | grep 'fixup!'
@@ -64,9 +56,39 @@ jobs:
exit 1
fi
install-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
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
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: dependencies
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -89,7 +111,7 @@ jobs:
test-front:
runs-on: ubuntu-latest
needs: dependencies
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -106,7 +128,7 @@ jobs:
lint-front:
runs-on: ubuntu-latest
needs: dependencies
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -123,7 +145,7 @@ jobs:
test-e2e:
runs-on: ubuntu-latest
needs: [dependencies, build-front]
needs: [build-mails, build-front]
timeout-minutes: 10
strategy:
fail-fast: false
@@ -139,13 +161,11 @@ jobs:
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
name: mails-templates
path: src/backend/core/templates/mail
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -181,6 +201,9 @@ jobs:
run: |
make dimail-setup-db
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
@@ -211,6 +234,30 @@ jobs:
if: ${{ contains(needs.*.result, 'failure') }}
run: exit 1
build-mails:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install yarn
run: npm install -g yarn
- name: Install node dependencies
run: yarn install --frozen-lockfile
- name: Build mails
run: yarn build
- name: Persist mails' templates
uses: actions/upload-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
lint-back:
runs-on: ubuntu-latest
defaults:
@@ -222,7 +269,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.10'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -234,7 +281,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: dependencies
needs: build-mails
defaults:
run:
working-directory: src/backend
@@ -269,17 +316,15 @@ jobs:
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
name: mails-templates
path: src/backend/core/templates/mail
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.10'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
+1 -4
View File
@@ -20,10 +20,7 @@ jobs:
fetch-depth: 0
- name: Cleanup
run: |
rm -rf ./src/helm/extra
rm -rf ./src/helm/dimail
rm -rf ./src/helm/maildev
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
-3
View File
@@ -58,9 +58,6 @@ src/frontend/tsclient
# Logs
*.log
# Celery beat
src/backend/celerybeat-schedule
# Test & lint
.coverage
coverage.json
+3
View File
@@ -0,0 +1,3 @@
[submodule "secrets"]
path = secrets
url = ../secrets
+21 -90
View File
@@ -8,68 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.14.0] - 2025-03-17
### Added
- ✨(domains) enhance required action modal content
- ✨(domains) add periodic tasks to fetch domain status
- 🧑‍💻(docker) add celery beat to manage periodic tasks
- ✨(organization) add metadata field #790
- ⬆️(nginx) bump nginx-unprivileged to 1.27 #797
- ✨(teams) allow broadly available teams #796
- ✨(teams) update and enhance team invitation email
- ✨(api) define dimail timeout as a setting
- ✨(frontend) feature modal add new access role to domain
- ✨(api) allow invitations for domain management #708
### Fixed
- 🐛(oauth2) force JWT signed for /userinfo #804
- 🐛(oauth2) add ProConnect scopes #802
- 🐛(domains) use a dedicated mail to invite user to manage domain
- 🐛(mailbox) fix mailbox creation email language
## [1.13.1] - 2025-03-04
### Fixed
- 🐛(mailbox) fix migration to fill dn_email field
## [1.13.0] - 2025-03-04
### Added
- ✨(oidc) people as an identity provider #638
### Fixed
- 💄(domains) improve user experience and avoid repeat fix_domain operations
- 👽️(dimail) increase timeout value for check domain API call
- 🧱(helm) add resource-server ingress path #743
- 🌐(backend) synchronize translations with crowdin again
## [1.12.1] - 2025-02-20
### Fixed
- 👽️(dimail) increase timeout value for API calls
## [1.12.0] - 2025-02-18
### Added
- ✨(domains) allow user to re-run all fetch domain data from dimail
- ✨(domains) display DNS config expected for domain with required actions
- ✨(domains) check status after creation
- ✨(domains) display required actions to do on domain
- ✨(plugin) add CommuneCreation plugin with domain provisioning #658
- ✨(frontend) display action required status on domain
- ✨(domains) store last health check details on MailDomain
- ✨(domains) add support email field on domain
## [1.11.0] - 2025-02-07
### Added
- ✨(api) add count mailboxes to MailDomain serializer
@@ -79,7 +17,6 @@ and this project adheres to
### Fixed
- ✨(auth) fix empty names from ProConnect #687
- 🚑️(teams) do not display add button when disallowed #676
- 🚑️(plugins) fix name from SIRET specific case #674
- 🐛(api) restrict mailbox sync to enabled domains
@@ -339,30 +276,24 @@ 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.14.0...main
[1.14.0]: https://github.com/suitenumerique/people/releases/v1.14.0
[1.13.1]: https://github.com/suitenumerique/people/releases/v1.13.1
[1.13.0]: https://github.com/suitenumerique/people/releases/v1.13.0
[1.12.1]: https://github.com/suitenumerique/people/releases/v1.12.1
[1.12.0]: https://github.com/suitenumerique/people/releases/v1.12.0
[1.11.0]: https://github.com/suitenumerique/people/releases/v1.11.0
[1.10.1]: https://github.com/suitenumerique/people/releases/v1.10.1
[1.10.0]: https://github.com/suitenumerique/people/releases/v1.10.0
[1.9.1]: https://github.com/suitenumerique/people/releases/v1.9.1
[1.9.0]: https://github.com/suitenumerique/people/releases/v1.9.0
[1.8.0]: https://github.com/suitenumerique/people/releases/v1.8.0
[1.7.1]: https://github.com/suitenumerique/people/releases/v1.7.1
[1.7.0]: https://github.com/suitenumerique/people/releases/v1.7.0
[1.6.1]: https://github.com/suitenumerique/people/releases/v1.6.1
[1.6.0]: https://github.com/suitenumerique/people/releases/v1.6.0
[1.5.0]: https://github.com/suitenumerique/people/releases/v1.5.0
[1.4.1]: https://github.com/suitenumerique/people/releases/v1.4.1
[1.4.0]: https://github.com/suitenumerique/people/releases/v1.4.0
[1.3.1]: https://github.com/suitenumerique/people/releases/v1.3.1
[1.3.0]: https://github.com/suitenumerique/people/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/people/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/people/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/people/releases/v1.1.0
[1.0.2]: https://github.com/suitenumerique/people/releases/v1.0.2
[1.0.1]: https://github.com/suitenumerique/people/releases/v1.0.1
[1.0.0]: https://github.com/suitenumerique/people/releases/v1.0.0
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.10.1...main
[1.10.1]: https://github.com/numerique-gouv/people/releases/v1.10.1
[1.10.0]: https://github.com/numerique-gouv/people/releases/v1.10.0
[1.9.1]: https://github.com/numerique-gouv/people/releases/v1.9.1
[1.9.0]: https://github.com/numerique-gouv/people/releases/v1.9.0
[1.8.0]: https://github.com/numerique-gouv/people/releases/v1.8.0
[1.7.1]: https://github.com/numerique-gouv/people/releases/v1.7.1
[1.7.0]: https://github.com/numerique-gouv/people/releases/v1.7.0
[1.6.1]: https://github.com/numerique-gouv/people/releases/v1.6.1
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
[1.3.0]: https://github.com/numerique-gouv/people/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/people/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/people/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/people/releases/v1.1.0
[1.0.2]: https://github.com/numerique-gouv/people/releases/v1.0.2
[1.0.1]: https://github.com/numerique-gouv/people/releases/v1.0.1
[1.0.0]: https://github.com/numerique-gouv/people/releases/v1.0.0
-109
View File
@@ -1,109 +0,0 @@
# Contributing to the Project
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Help us with translations
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-people).
Your language is not there? Request it on our Crowdin page 😊.
## Creating an Issue
When creating an issue, please provide the following details:
1. **Title**: A concise and descriptive title for the issue.
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
## Selecting an issue
We use a [GitHub Project](https://github.com/orgs/suitenumerique/projects/1) in order to prioritize our workload.
Please check in priority the issues that are in the **TODO cette semaine** column.
## Commit Message Format
All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
### Example Commit Message
```
✨(frontend) add user authentication logic
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
### Example Changelog Message
```
## [Unreleased]
## Added
- ✨(frontend) add AI to the project #321
```
## Pull Requests
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
- squash your commits
- rebase your branch on the latest `main` branch before pushing your changes `git pull --rebase origin main`
### Process to have a nice commit history
In the life time of your PR, you may need to add commits to fix things or add new features.
Commit after commit, your PR will be full of commits but you have to clean it up with the following commands before merging on `main`:
Gradually you can use `--fixup` to add commits to some of previous commit ( for example 1234567890).
```
git commit --fixup=1234567890
```
Then, you can squash your commits with the following command:
```
git rebase --autosquash -i -r HEAD~<number-of-commits>
```
Or you can use:
```
git rebase -i HEAD~<number-of-commits>
```
and move, squash and/or rename your commits manually. You can squash them with previous commit replacing the `pick` by `f`. You can rename them with replacing the `pick` by `r`.
Tada! You have a clean commit history.
Once all the required tests have passed, you can request a review from the project maintainers.
## Code Style
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
## Tests
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
## Asking for Help
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍
+1 -7
View File
@@ -39,13 +39,7 @@ 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
USER nginx
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
+2 -20
View File
@@ -72,7 +72,6 @@ data/static:
create-env-files: ## Copy the dist env files to env files
create-env-files: \
env.d/development/common \
env.d/development/france \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql
@@ -110,8 +109,6 @@ 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..."
@@ -158,10 +155,6 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
bin/pylint --diff-only=origin/main
.PHONY: lint-pylint
lint-front:
cd $(PATH_FRONT) && yarn lint
.PHONY: lint-front
test: ## run project tests
@$(MAKE) test-back-parallel
.PHONY: test
@@ -212,7 +205,6 @@ back-i18n-compile: ## compile the gettext files
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
back-i18n-generate: mails-build crowdin-download-sources
@$(MANAGE) makemessages -a --keep-pot
.PHONY: back-i18n-generate
@@ -236,9 +228,6 @@ resetdb: ## flush database and create a superuser "admin"
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
env.d/development/france:
cp -n env.d/development/france.dist env.d/development/france
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
@@ -270,7 +259,6 @@ i18n-compile: \
i18n-generate: ## create the .pot files and extract frontend messages
i18n-generate: \
crowdin-download-sources \
back-i18n-generate \
frontend-i18n-generate
.PHONY: i18n-generate
@@ -301,13 +289,7 @@ dimail-setup-db:
# -- Mail generator
mails-clean-templates: ## Clean the generated mail templates directory
@echo "$(BOLD)Cleaning mail templates directory$(RESET)"
@rm -rf ./src/backend/core/templates/mail
@mkdir -p ./src/backend/core/templates/mail
.PHONY: mails-clean-templates
mails-build: mails-clean-templates ## Convert mjml files to html and text
mails-build: ## Convert mjml files to html and text
@$(MAIL_YARN) build
.PHONY: mails-build
@@ -346,7 +328,7 @@ help:
# Front
install-front-desk: ## Install the frontend dependencies of app Desk
cd $(PATH_FRONT_DESK) && yarn && yarn playwright install chromium
cd $(PATH_FRONT_DESK) && yarn
.PHONY: install-front-desk
run-front-desk: ## Start app Desk
-29
View File
@@ -59,35 +59,6 @@ cmd_button('Migrate db',
text='Run database migration',
)
# Command to reset DB
reset_db = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py flush --no-input
kubectl -n desk exec "$POD_NAME" -- python manage.py createsuperuser --username admin@example.com --password admin
'''
cmd_button('Reset DB',
argv=['sh', '-c', reset_db],
resource='desk-backend',
icon_name='developer_board',
text='Reset DB',
)
# Command to create demo data
populate_people_with_demo_data = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py create_demo --force
'''
cmd_button('Populate with demo data',
argv=['sh', '-c', populate_people_with_demo_data],
resource='desk-backend',
icon_name='developer_board',
text='Populate with demo data',
)
# Command to created domain/users/access from people to dimail
populate_dimail_from_people = '''
set -eu
+4 -40
View File
@@ -9,8 +9,8 @@ services:
redis:
image: redis:5
maildev:
image: maildev/maildev:latest
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
@@ -27,7 +27,6 @@ services:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
@@ -38,7 +37,7 @@ services:
depends_on:
- dimail
- postgresql
- maildev
- mailcatcher
- redis
celery-dev:
@@ -57,22 +56,6 @@ services:
depends_on:
- app-dev
celery-beat-dev:
user: ${DOCKER_USER:-1000}
image: people:backend-development
command: ["celery", "-A", "people.celery_app", "beat", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
app:
build:
context: .
@@ -118,7 +101,7 @@ services:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:4.6.1
image: crowdin/cli:3.16.0
volumes:
- ".:/app"
env_file:
@@ -145,8 +128,6 @@ services:
image: quay.io/keycloak/keycloak:20.0.1
volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
extra_hosts:
- "host.docker.internal:host-gateway"
command:
- start-dev
- --features=preview
@@ -180,20 +161,3 @@ services:
DIMAIL_JWT_SECRET: fake_jwt_secret
ports:
- "8001:8000"
flower-dev:
user: ${DOCKER_USER:-1000}
image: people:backend-development
command: ["celery", "-A", "people.celery_app", "flower", "--port=5555"]
environment:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/postgresql
ports:
- "5555:5555"
volumes:
- ./src/backend:/app
depends_on:
- celery-dev
- redis
+4 -126
View File
@@ -65,7 +65,7 @@
"lastName": "Delamairie",
"enabled": true,
"attributes": {
"siret": "21510339100011"
"siret": "21580304000017"
},
"credentials": [
{
@@ -1652,130 +1652,8 @@
"enabledEventTypes": [],
"adminEventsEnabled": false,
"adminEventsDetailsEnabled": false,
"identityProviders": [
{
"alias": "oidc-people-local",
"displayName": "People OIDC (local)",
"internalId": "47aa6d7c-8ac5-4178-934e-66f78e510ee4",
"providerId": "oidc",
"enabled": true,
"updateProfileFirstLoginMode": "on",
"trustEmail": false,
"storeToken": false,
"addReadTokenRoleOnCreate": false,
"authenticateByDefault": false,
"linkOnly": false,
"firstBrokerLoginFlowAlias": "first broker login",
"config": {
"hideOnLoginPage": "false",
"userInfoUrl": "http://app-dev:8000/o/userinfo/",
"validateSignature": "true",
"acceptsPromptNoneForwardFromClient": "false",
"clientId": "people-idp",
"tokenUrl": "http://app-dev:8000/o/token/",
"uiLocales": "false",
"jwksUrl": "http://app-dev:8000/o/.well-known/jwks.json",
"backchannelSupported": "false",
"issuer": "http://app-dev:8000/o",
"useJwksUrl": "true",
"loginHint": "true",
"pkceEnabled": "true",
"pkceMethod": "S256",
"authorizationUrl": "http://localhost:8071/o/authorize/",
"clientAuthMethod": "client_secret_post",
"disableUserInfo": "false",
"syncMode": "IMPORT",
"clientSecret": "local-tests-only",
"passMaxAge": "false",
"defaultScope": "openid given_name usual_name email siret",
"allowedClockSkew": "0"
}
}
],
"identityProviderMappers": [
{
"id": "e55dc88c-7bb5-46fb-95ad-1df701a96282",
"name": "Sub",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-username-idp-mapper",
"config": {
"template": "${CLAIM.sub}",
"are.claim.values.regex": "false",
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
"syncMode": "FORCE",
"attributes": "[]",
"target": "BROKER_ID"
}
},
{
"id": "7e489676-8cba-49e4-aa1e-dcd1462d33f7",
"name": "given_name",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "hardcoded-attribute-idp-mapper",
"config": {
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
"syncMode": "FORCE",
"are.claim.values.regex": "false",
"attributes": "[]",
"attribute": "firstName"
}
},
{
"id": "30b6b3bc-5738-4936-bf88-c540b8805998",
"name": "usual_name",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"template": "${ALIAS}.${CLAIM.preferred_username}",
"are.claim.values.regex": "false",
"claims": "[{\"key\":\"profile\",\"value\":\"lastName\"}]",
"syncMode": "FORCE",
"claim": "profile",
"user.attribute": "lastName",
"attributes": "[]",
"target": "LOCAL"
}
},
{
"id": "b67caa26-4571-4cfe-9c15-68e022645fc5",
"name": "Username",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-username-idp-mapper",
"config": {
"template": "${CLAIM.email | lowercase}",
"are.claim.values.regex": "false",
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
"syncMode": "FORCE",
"attributes": "[]",
"target": "BROKER_USERNAME"
}
},
{
"id": "4eef21ce-b5f7-4753-bd58-4e50eb2b5f31",
"name": "Email",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"are.claim.values.regex": "false",
"claims": "[{\"key\":\"\",\"value\":\"\"}]",
"syncMode": "FORCE",
"claim": "email",
"user.attribute": "email",
"attributes": "[]"
}
},
{
"id": "084cdd0e-0794-4388-8474-84c9a7c1b9c8",
"name": "siret",
"identityProviderAlias": "oidc-people-local",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"syncMode": "FORCE",
"claim": "siret",
"user.attribute": "siret"
}
}
],
"identityProviders": [],
"identityProviderMappers": [],
"components": {
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
{
@@ -2317,7 +2195,7 @@
"authenticatorConfig": "review profile config",
"authenticator": "idp-review-profile",
"authenticatorFlow": false,
"requirement": "DISABLED",
"requirement": "REQUIRED",
"priority": 10,
"autheticatorFlow": false,
"userSetupAllowed": false
-63
View File
@@ -1,63 +0,0 @@
# Internationalization (i18n)
The backend and the frontend of the application are internationalized.
This means that the application can be translated into different languages.
The application is currently available in English and French.
## Local setup
To be able to upload/retrieve translation files to/from Crowdin you need to
setup your local environment with the following environment variables:
```bash
cp ./env.d/development/crowdin.dist ./env.d/development/crowdin
```
Then fill "CROWDIN_API_TOKEN" with your token and "CROWDIN_PROJECT_ID" with the project id
in the `./env.d/development/crowdin` file.
NB: If you don't have a personal Crowdin API token, go to Crowdin interface to generate it:
Settings -> API -> Personal Access Tokens -> New token
This configuration file will be loaded by the crowdin docker instance.
## How to update the translations
### First way (safe way)
1/ Generate the translation files for both frontend and backend
```bash
make i18n-generate
```
Check locally everything is ok before uploading the files to the translation platform.
2/ Upload the files to the translation platform (crowdin):
```bash
make crowdin-upload
```
3/ Fill the missing translations on the translation platform:
=> [https://crowdin.com/project/lasuite-people](https://crowdin.com/project/lasuite-people)
4/ Download the translated files from the translation platform and compile them:
```bash
make i18n-download-and-compile
```
### Second way (faster way)
1/ Generate the translation files for both frontend and backend and upload them to the translation platform (crowdin):
```bash
make i18n-generate-and-upload
```
2/ Fill the missing translations on the translation platform:
=> [https://crowdin.com/project/lasuite-people](https://crowdin.com/project/lasuite-people)
3/ Download the translated files from the translation platform and compile them:
```bash
make i18n-download-and-compile
```
-100
View File
@@ -1,100 +0,0 @@
# People as an Identity Provider
The people project can be configured to act as an Identity Provider for an OIDC federation.
The model containing the "identity" in `people` is the `Mailbox` model.
The connection workflow looks like:
- The user tries to reach a service provider.
- The service provider redirects the user to OIDC federation (in dev we use Keycloak).
- The user asks (or is redirected) to the Identity Provider (people).
- The user enter their email and password.
- If successful, the user is redirected to the OIDC federation with a token.
- The federation make the same Authorization Flow as usual to authenticate on the Service Provider.
## Technical aspects
### The `Mailbox` model
The `Mailbox` model can behave like a usual Django user:
- It has a `password` field (and `last_login`)
- It is considered as `authenticated`.
- To be `active` it must have an `enabled` status.
- To be able to log in, it must also have a `MailboxDomain` with an `enabled` status and an associated `Organization`.
### The `MailboxModelBackend` authentication backend
This authentication backend only allow authentication with `email` and `password` with a `Mailbox` user.
This backend is combined with the `one_time_email_authenticated_session` middleware to restrict
the session to the OIDC login process.
A user connecting with this backend will only be able to access the `/o/authorize/` URL.
### The OIDC validators
There are two validators available:
- `BaseValidator`: This validator retrieves simple claims
- `ProConnectValidator`: This validator is a custom validator to allow ProConnect specific requirements.
## Configuration
The keycloak configuration is not described here, but you may find information in the code base.
### Django settings
The following settings are required to enable the OIDC Identity Provider feature:
- OAUTH2_PROVIDER_OIDC_ENABLED: True
- OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY: ...
Optional:
- OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
If the `OAUTH2_PROVIDER_VALIDATOR_CLASS` is set to the `ProConnectValidator`, the claims will be
automatically defined to match the ProConnect requirements.
### Django OIDC application
To enable an OIDC client to use people, you must declare it.
```python
from oauth2_provider.models import Application
application = Application(
client_id="people-idp",
client_secret="local-tests-only",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
name="People Identity Provider",
algorithm=Application.RS256_ALGORITHM,
redirect_uris="http://localhost:8083/realms/people/broker/oidc-people-local/endpoint",
skip_authorization=True,
)
application.clean()
application.save()
```
## Security concerns
### Failed login cooldown
To prevent brute force attacks, 5 failed login will trigger a cooldown of 5 minutes before being able to log in again.
### Password strength
There is currently no constraint on the password strength as it can only be defined by Django administrators,
and later we will generate it.
If at some point the user is allowed to set their password, we will need to enforce a password strength policy.
## What is missing (next steps)
- `Mailbox` password can only be set through Django Admin panel (or shell), the next step will be to generate
a secure password and send it or display it to the end user.
- `MailboxDomain` organization can only be set through Django Admin panel (or shell), we need to provide a
way to auto-select it or allow an administrator to do so.
+76 -20
View File
@@ -5,8 +5,6 @@ Tilt is a tool that helps you develop applications for Kubernetes.
It watches your files for changes, rebuilds your containers, and restarts your pods.
It's like having a conversation with your cluster.
This is particularly useful when working on integrations or to test your helm charts.
Otherwise, you can use your good old docker configuration as described in README.md.
## Prerequisites
@@ -15,55 +13,113 @@ This guide assumes you have the following tools installed:
- [Docker](https://docs.docker.com/get-docker/)
- [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/)
- [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/)
* [mkcert](https://github.com/FiloSottile/mkcert)
* [mkcert](https://github.com/FiloSottile/mkcert)
- [ctlptl](https://github.com/tilt-dev/ctlptl)
- [Tilt](https://docs.tilt.dev/install.html)
* [helm](https://helm.sh/docs/intro/install/)
* [helmfile](https://github.com/helmfile/helmfile)
* [secrets](https://github.com/jkroepke/helm-secrets/wiki/Installation)
* [sops](https://github.com/getsops/sops)
### SOPS configuration
**Generate a SOPS key**
For this specific step you need to have the `age-keygen` tool installed.
See https://github.com/FiloSottile/age.
Then generate a key:
```bash
age-keygen -o my-age.key
```
**Install the SOPS key**
Read the SOPS documentation on how to install the key in your environment.
https://github.com/getsops/sops?tab=readme-ov-file#22encrypting-using-age
On Ubuntu it's like:
```bash
mkdir -p ~/.config/sops/age/
cp my-age.key ~/.config/sops/age/keys.txt
chmod 400 ~/.config/sops/age/keys.txt
```
**Add the SOPS key to the repository**
Update the [.sops.yaml](../.sops.yaml) file with the **public** key id you generated.
[Install_prereq script](https://github.com/numerique-gouv/dk8s/blob/main/scripts/install-prereq.sh) (not tested).
### Helmfile in Docker
If you use helmfile in Docker, you may need an additional configuration to make
it work with you age key.
You need to mount `-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/"`
```bash
#!/bin/sh
docker run --rm --net=host \
-v "${HOME}/.kube:/root/.kube" \
-v "${HOME}/.config/helm:/root/.config/helm" \
-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/" \
-v "${HOME}/.minikube:/${HOME}/.minikube" \
-v "${PWD}:/wd" \
-e KUBECONFIG=/root/.kube/config \
--workdir /wd ghcr.io/helmfile/helmfile:v0.150.0 helmfile "$@"
```
## Create the kubernetes cluster
## Getting started
### Create the kubernetes cluster
Run the following command to create a kubernetes cluster using kind:
```bash
make start-kind
./bin/start-kind.sh
```
# import your secrets from credentials manager
# ! don't forget "https" before your url
**or** run the equivalent using the makefile
```bash
make start-kind
```
### [Optional] Install the secret
You don't need to do this if you are running the stack with keycloak.
```bash
make install-external-secrets
```
### Deploy the application
```bash
# Pro Connect environment
tilt up -f ./bin/Tiltfile
# Standalone environment with keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
```
**or** run the equivalent using the makefile
```bash
# Pro Connect environment
make tilt-up
# Standalone environment with keycloak
make tilt-up-keycloak
```
That's it! You should now have a local development environment for Kubernetes.
## Start the application
```bash
# You can either start :
# ProConnect stack (but secrets must be set on your local cluster)
make tilt-up
# or standalone environment with keycloak
make start-tilt-keycloak
```
Access your application at https://desk.127.0.0.1.nip.io
You can access the application at https://desk.127.0.0.1.nip.io
## Management
-64
View File
@@ -1,64 +0,0 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
```bash
git checkout main
git pull
```
2. The next steps are automated in the `scripts/release.py`, you can run it using:
```bash
make release
```
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`.
2. 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
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) release version 4.18.1
```
3. Open a pull request ask you to wait for an approval from your peers and merge it.
4. Ask you to 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.
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.
7. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
+1 -1
View File
@@ -10,7 +10,7 @@ PYTHONPATH=/app
# People settings
# Mail
DJANGO_EMAIL_HOST="maildev"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_PORT=1025
# Backend url
-3
View File
@@ -1,6 +1,3 @@
# For the CI job test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
OAUTH2_PROVIDER_OIDC_ENABLED = True
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
-1
View File
@@ -1 +0,0 @@
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
+1 -1
View File
@@ -13,7 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint", "@hookform/resolvers"]
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
}
]
}
+6 -6
View File
@@ -76,7 +76,7 @@ def update_changelog(path, version):
file.writelines(lines)
def deployment_part(version):
def deployment_part(version, kind):
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
"""
# Move to lasuite-deploiement repository
@@ -95,7 +95,7 @@ def deployment_part(version):
path = f"manifests/regie/env.d/preprod/values.desk.yaml.gotmpl"
update_helm_files(path)
run_command(f"git add {path}", shell=True)
message = f"""🔖(regie) bump preprod version to {version}"""
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}"""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"""\033[0;32m
### DEPLOYMENT ###
@@ -107,7 +107,7 @@ Continue ? (y,n)
run_command(f"git push origin {deployment_branch}", shell=True)
sys.stdout.write(f"""\033[1;34m
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR {deployment_branch} and merge code to main [https://github.com/numerique-gouv/lasuite-deploiement]
--> Please submit PR {deployment_branch} and merge code to main
\x1b[0m""")
@@ -125,7 +125,7 @@ def project_part(version, kind):
run_command("git add src/", shell=True)
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"""\033[0;32m
### RELEASE ###
@@ -137,7 +137,7 @@ Continue ? (y,n)
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR {branch_to_release} and merge code to main [https://github.com/suitenumerique/people/]
--> Please submit PR {branch_to_release} and merge code to main
--> Then do:
>> git checkout main
>> git pull
@@ -158,4 +158,4 @@ if __name__ == "__main__":
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
if "--only-deployment-part" not in sys.argv:
project_part(version, kind)
deployment_part(version)
deployment_part(version, kind)
Submodule
+1
Submodule secrets added at b7ab5f1411
+1 -11
View File
@@ -60,17 +60,7 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(
_("Personal info"),
{
"fields": (
"name",
"email",
"language",
"timezone",
)
},
),
(_("Personal info"), {"fields": ("name", "email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -251,7 +251,6 @@ class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
abilities = serializers.SerializerMethodField(read_only=True)
is_visible_all_services = serializers.BooleanField(required=False, default=True)
service_providers = serializers.PrimaryKeyRelatedField(
queryset=ServiceProvider.objects.all(), many=True, required=False
)
@@ -264,7 +263,6 @@ class TeamSerializer(serializers.ModelSerializer):
"accesses",
"created_at",
"depth",
"is_visible_all_services",
"name",
"numchild",
"path",
+1 -2
View File
@@ -7,7 +7,6 @@ from functools import reduce
from django.conf import settings
from django.db.models import OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
@@ -606,7 +605,7 @@ class StatView(views.APIView):
context = {
"total_users": models.User.objects.count(),
"mau": models.User.objects.filter(
last_login__gte=timezone.now() - datetime.timedelta(30)
last_login__gte=datetime.datetime.now() - datetime.timedelta(30)
).count(),
"teams": models.Team.objects.count(),
"domains": domains_models.MailDomain.objects.count(),
-27
View File
@@ -4,8 +4,6 @@ from django.core import exceptions
from rest_framework import permissions
from core import models
class IsAuthenticated(permissions.BasePermission):
"""
@@ -70,28 +68,3 @@ class TeamPermission(IsAuthenticated):
abilities = request.user.get_abilities()
return abilities["teams"]["can_create"]
class TeamInvitationCreationPermission(IsAuthenticated):
"""Permission class that allows only team owners and admins to perform actions."""
def has_permission(self, request, view):
"""Check if user is authenticated and has required role for the team."""
if not super().has_permission(request, view):
return False
# Only check roles for edition operations
if request.method in permissions.SAFE_METHODS:
return True
team_id = view.kwargs.get("team_id")
if not team_id:
return False
team_access = models.TeamAccess.objects.filter(
team_id=team_id,
user=request.user,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists()
return team_access
@@ -14,7 +14,6 @@ class TeamSerializer(serializers.ModelSerializer):
"id",
"created_at",
"depth",
"is_visible_all_services",
"name",
"numchild",
"path",
@@ -51,28 +50,3 @@ class TeamSerializer(serializers.ModelSerializer):
"service_providers": [service_provider],
},
)
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
class Meta:
model = models.Invitation
fields = ["id", "created_at", "email", "team", "role", "issuer", "is_expired"]
read_only_fields = ["id", "created_at", "team", "issuer", "is_expired"]
def validate(self, attrs):
"""Fill team and issuer from request."""
is_team_available_for_service_provider = models.Team.objects.filter(
id=self.context["team_id"],
service_providers__audience_id=self.context[
"from_service_provider_audience"
],
).exists()
if not is_team_available_for_service_provider:
raise serializers.ValidationError({"team": "Team not found."})
attrs["team_id"] = self.context["team_id"]
attrs["issuer"] = self.context["request"].user # User is authenticated
return attrs
@@ -103,8 +103,7 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
for d in depth_path
),
),
Q(service_providers__audience_id=service_provider_audience)
| Q(is_visible_all_services=True),
service_providers__audience_id=service_provider_audience,
)
# Abilities are computed based on logged-in user's role for the team
# and if the user does not have access, it's ok to consider them as a member
@@ -124,79 +123,3 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
user=self.request.user,
role=models.RoleChoices.OWNER,
)
class InvitationViewset( # pylint: disable=too-many-ancestors
ResourceServerMixin,
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to team via resource server.
GET /resource-server/v1.0/teams/<team_id>/invitations/:<invitation_id>/
Return list of invitations related to that team or one
team access if an id is provided.
POST /resource-server/v1.0/teams/<team_id>/invitations/ with expected data:
- email: str
- role: str [owner|admin|member]
- issuer : User, automatically added from user making query, if allowed
- team : Team, automatically added from requested URI
Return newly created invitation
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /resource-server/v1.0/teams/<team_id>/invitations/<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.InvitationSerializer
def get_permissions(self):
"""Set specific permissions based on the action."""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
elif self.action == "create":
permission_classes = [permissions.TeamInvitationCreationPermission]
else:
permission_classes = [permissions.AccessPermission]
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["team_id"] = self.kwargs["team_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
service_provider_audience = self._get_service_provider_audience()
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
queryset = (
models.Invitation.objects.select_related("team")
.filter(
team=self.kwargs["team_id"],
# The logged-in user should be part of a team to see its accesses
team__accesses__user=self.request.user,
# The team should be accessible by the service provider audience
team__service_providers__audience_id=service_provider_audience,
)
.annotate(user_role=Subquery(user_role_query))
.order_by("-created_at")
.distinct()
)
return queryset
+3 -2
View File
@@ -95,12 +95,13 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
email = user_info.get("email")
claims = {
"sub": sub,
"email": email,
"name": self.compute_full_name(user_info),
"name": full_name,
}
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
@@ -119,7 +120,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
# Data cleaning, to be removed when user organization is null=False
# or all users have an organization.
# See https://github.com/suitenumerique/people/issues/504
# See https://github.com/numerique-gouv/people/issues/504
if not user.organization_id:
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
+2 -2
View File
@@ -2,7 +2,7 @@
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
@@ -14,5 +14,5 @@ urlpatterns = [
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozilla_oidc_urls,
*mozzila_oidc_urls,
]
-1
View File
@@ -1,4 +1,3 @@
# pylint: disable=too-many-ancestors
"""
Core application enums declaration
"""
+1 -1
View File
@@ -240,7 +240,7 @@ class TeamWebhookFactory(factory.django.DjangoModelFactory):
model = models.TeamWebhook
team = factory.SubFactory(TeamFactory)
url = factory.Sequence(lambda n: f"https://nosuchdomain.xyz/Groups/{n!s}")
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
class InvitationFactory(factory.django.DjangoModelFactory):
@@ -1,15 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Organization Metadata",
"properties": {
"is_public_service": {
"type": "boolean",
"title": "Is public service"
},
"is_commune": {
"type": "boolean",
"title": "Is commune"
}
}
}
-1
View File
@@ -1 +0,0 @@
"""Management commands for core app."""
@@ -1 +0,0 @@
"""Management commands for core app."""
@@ -1,34 +0,0 @@
"""
Management command for filling organization metadata with default values.
"""
from django.core.management.base import BaseCommand
from core import models
from core.utils.json_schema import generate_default_from_schema
class Command(BaseCommand):
"""Management command to fill organization metadata with default values."""
help = "Fill organization metadata with default values"
def handle(self, *args, **options):
"""Fill organizations metadata missing values with default values."""
organization_metadata_schema = models.get_organization_metadata_schema()
if not organization_metadata_schema:
message = "No organization metadata schema defined."
self.stdout.write(self.style.ERROR(message))
return
default_metadata = generate_default_from_schema(organization_metadata_schema)
for organization in models.Organization.objects.all():
organization.metadata = {**default_metadata, **organization.metadata}
# Save the organization with the updated metadata
# We don't use bulk update because we want to trigger the clean method
organization.save(update_fields=["metadata", "updated_at"])
message = "Organization metadata filled with default values."
self.stdout.write(self.style.SUCCESS(message))
@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-11 13:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_team_depth_team_numchild_team_path_and_more'),
]
operations = [
migrations.AddField(
model_name='team',
name='is_visible_all_services',
field=models.BooleanField(default=False, help_text='Whether this team is visible to all service providers.', verbose_name='is visible for all SP'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-10 14:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_team_is_visible_all_services'),
]
operations = [
migrations.AddField(
model_name='organization',
name='metadata',
field=models.JSONField(blank=True, default=dict, help_text='A JSON object containing the organization metadata', verbose_name='metadata'),
),
]
+50 -164
View File
@@ -1,7 +1,6 @@
"""
Declare and configure the models for the People core application
"""
# pylint: disable=too-many-lines import-outside-toplevel
import json
import os
@@ -9,9 +8,8 @@ import smtplib
import uuid
from contextlib import suppress
from datetime import timedelta
from functools import lru_cache
from logging import getLogger
from typing import Optional, Tuple
from typing import Tuple
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -31,47 +29,18 @@ from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookStatusChoices
from core.plugins.loader import (
organization_plugins_run_after_create,
organization_plugins_run_after_grant_access,
)
from core.plugins.loader import organization_plugins_run_after_create
from core.utils.webhooks import scim_synchronizer
from core.validators import get_field_validators_from_setting
logger = getLogger(__name__)
current_dir = os.path.dirname(os.path.abspath(__file__))
contact_schema_path = os.path.join(current_dir, "jsonschema", "contact_data.json")
with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
contact_schema = json.load(contact_schema_file)
@lru_cache(maxsize=None)
def get_organization_metadata_schema() -> Optional[dict]:
"""Load the organization metadata schema from the settings."""
if not settings.ORGANIZATION_METADATA_SCHEMA:
logger.info("No organization metadata schema specified")
return None
organization_metadata_schema_path = os.path.join(
current_dir,
"jsonschema",
settings.ORGANIZATION_METADATA_SCHEMA,
)
with open(
organization_metadata_schema_path,
"r",
encoding="utf-8",
) as organization_metadata_schema_file:
organization_metadata_schema = json.load(organization_metadata_schema_file)
logger.info(
"Loaded organization metadata schema from %s", organization_metadata_schema_path
)
return organization_metadata_schema
class RoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
"""Defines the possible roles a user can have in a team."""
@@ -329,22 +298,6 @@ class OrganizationManager(models.Manager):
return instance
class OrganizationAccessManager(models.Manager):
"""
Custom manager for the OrganizationAccess model, to manage complexity/automation.
"""
def create(self, **kwargs):
"""
Create an organization access with the given kwargs.
This method is overridden to call the Organization plugins.
"""
instance = super().create(**kwargs)
organization_plugins_run_after_grant_access(instance)
return instance
class Organization(BaseModel):
"""
Organization model used to regroup Teams.
@@ -384,13 +337,6 @@ class Organization(BaseModel):
# list overlap validation is done in the validate_unique method
)
metadata = models.JSONField(
_("metadata"),
help_text=_("A JSON object containing the organization metadata"),
blank=True,
default=dict,
)
service_providers = models.ManyToManyField(
ServiceProvider,
related_name="organizations",
@@ -421,22 +367,6 @@ class Organization(BaseModel):
def __str__(self):
return f"{self.name} (# {self.pk})"
def clean(self):
"""Validate fields."""
super().clean()
organization_metadata_schema = get_organization_metadata_schema()
if not organization_metadata_schema:
return
try:
jsonschema.validate(self.metadata, organization_metadata_schema)
except jsonschema.ValidationError as e:
# Specify the property in the data in which the error occurred
field_path = ".".join(map(str, e.path))
error_message = f"Validation error in '{field_path:s}': {e.message}"
raise exceptions.ValidationError({"metadata": [error_message]}) from e
def validate_unique(self, exclude=None):
"""
Validate Registration/Domain values in an array field are unique
@@ -563,13 +493,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
def save(self, *args, **kwargs):
"""
If it's a new user, give them access to the relevant teams.
If it's a new user, give her access to the relevant teams.
"""
if self._state.adding:
self._convert_valid_team_invitations()
# a post_save signal to convert domain invitations to domain accesses
# is triggered by the mailbox_manager app
self._convert_valid_invitations()
super().save(*args, **kwargs)
@@ -579,7 +507,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
if self.email:
self.email = User.objects.normalize_email(self.email)
def _convert_valid_team_invitations(self):
def _convert_valid_invitations(self):
"""
Convert valid invitations to team accesses.
Expired invitations are ignored.
@@ -690,8 +618,6 @@ class OrganizationAccess(BaseModel):
default=OrganizationRoleChoices.ADMIN,
)
objects = OrganizationAccessManager()
class Meta:
db_table = "people_organization_access"
verbose_name = _("Organization/user relation")
@@ -727,7 +653,7 @@ class TeamManager(MP_NodeManager):
return self.model.add_root(**kwargs)
# Retrieve parent object, because django-treebeard uses raw queries for most
# write operations, and raw queries don't update the django objects of the db
# write operations, and raw queries dont update the django objects of the db
# entries they modify. See caveats in the django-treebeard documentation.
# This might be changed later if we never do any operation on the parent object
# before creating the child.
@@ -771,11 +697,6 @@ class Team(MP_Node, BaseModel):
related_name="teams",
blank=True,
)
is_visible_all_services = models.BooleanField(
_("is visible for all SP"),
default=False,
help_text=_("Whether this team is visible to all service providers."),
)
objects = TeamManager()
@@ -953,21 +874,36 @@ class TeamWebhook(BaseModel):
return headers
class BaseInvitation(BaseModel):
"""Abstract base invitation model, surcharged for teams or domains."""
class Invitation(BaseModel):
"""User invitation to teams."""
email = models.EmailField(_("email address"), null=False, blank=False)
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="invitations",
)
MAIL_TEMPLATE_HTML = None
MAIL_TEMPLATE_TXT = None
class Meta:
abstract = True
db_table = "people_invitation"
verbose_name = _("Team invitation")
verbose_name_plural = _("Team invitations")
constraints = [
models.UniqueConstraint(
fields=["email", "team"], name="email_and_team_unique_together"
)
]
def __str__(self):
return f"{self.email} invited to {self.team}"
def save(self, *args, **kwargs):
"""Make invitations read-only."""
@@ -996,79 +932,6 @@ class BaseInvitation(BaseModel):
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
def _get_mail_subject(self):
"""Get the subject of the invitation."""
return gettext("Invitation to join La Régie!")
def _get_mail_context(self):
"""Get the template variables for the invitation."""
return {
"site": Site.objects.get_current(),
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
subject = self._get_mail_subject()
context = self._get_mail_context()
msg_html = render_to_string(self.MAIL_TEMPLATE_HTML, context)
msg_plain = render_to_string(self.MAIL_TEMPLATE_TXT, context)
mail.send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
[self.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
class Invitation(BaseInvitation):
"""User invitation to teams."""
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
MAIL_TEMPLATE_HTML = "mail/html/team_invitation.html"
MAIL_TEMPLATE_TXT = "mail/text/team_invitation.txt"
class Meta:
db_table = "people_invitation"
verbose_name = _("Team invitation")
verbose_name_plural = _("Team invitations")
constraints = [
models.UniqueConstraint(
fields=["email", "team"], name="email_and_team_unique_together"
)
]
def __str__(self):
return f"{self.email} invited to {self.team}"
def _get_mail_subject(self):
"""Get the subject of the team invitation."""
return gettext(
"[La Suite] You have been invited to become a %(role)s of a group"
) % {"role": self.get_role_display().lower()}
def _get_mail_context(self):
"""Get the template variables for the invitation."""
return {
**super()._get_mail_context(),
"team": self.team.name,
"role": self.get_role_display().lower(),
}
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
@@ -1093,3 +956,26 @@ class Invitation(BaseInvitation):
"patch": False,
"put": False,
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
subject = gettext("Invitation to join La Régie!")
template_vars = {
"title": subject,
"site": Site.objects.get_current(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
[self.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
-6
View File
@@ -11,9 +11,3 @@ class BaseOrganizationPlugin:
def run_after_create(self, organization) -> None:
"""Method called after creating an organization."""
raise NotImplementedError("Plugins must implement the run_after_create method")
def run_after_grant_access(self, organization_access) -> None:
"""Method called after creating an organization."""
raise NotImplementedError(
"Plugins must implement the run_after_grant_access method"
)
-12
View File
@@ -30,15 +30,3 @@ def organization_plugins_run_after_create(organization):
"""
for plugin_instance in get_organization_plugins():
plugin_instance.run_after_create(organization)
def organization_plugins_run_after_grant_access(organization_access):
"""
Run the after grant access method for all organization plugins.
Each plugin will be called in the order they are listed in the settings.
Each plugin is responsible to save changes if needed, this is not optimized
but this could be easily improved later if needed.
"""
for plugin_instance in get_organization_plugins():
plugin_instance.run_after_grant_access(organization_access)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

@@ -1 +0,0 @@
"""Test module for the JSON schema validation."""
@@ -1,53 +0,0 @@
"""Test module for the JSON schema validation."""
import json
import os
import pytest
def test_all_json_schemas_load_correctly(settings):
"""Test that all JSON schema files in the jsonschema directory load correctly."""
# Get the base directory for jsonschema files
schema_dir = os.path.join(settings.BASE_DIR, "core", "jsonschema")
# List to store any errors encountered
errors = []
loaded_schemas = 0
# Walk through the jsonschema directory and its subdirectories
for root, _, files in os.walk(schema_dir):
for file in files:
if file.endswith(".json"):
schema_path = os.path.join(root, file)
rel_path = os.path.relpath(schema_path, schema_dir)
try:
# Try to load the schema
with open(schema_path, "r", encoding="utf-8") as schema_file:
schema = json.load(schema_file)
# Verify it's a dictionary (basic schema validation)
assert isinstance(schema, dict), (
f"Schema in {rel_path} is not a dictionary"
)
# Check for common schema properties
if "$schema" not in schema:
errors.append(
f"Warning: {rel_path} does not contain a $schema property"
)
loaded_schemas += 1
except json.JSONDecodeError as e:
errors.append(f"Failed to decode {rel_path}: {e}")
except Exception as e: # noqa: BLE001 pylint: disable=broad-except
errors.append(f"Error loading {rel_path}: {e}")
# Ensure we found and loaded at least one schema
assert loaded_schemas > 0, "No JSON schema files were found"
# If any errors were encountered, fail the test
if errors:
pytest.fail("\n".join(errors))
@@ -1 +0,0 @@
"""Test for management commands for core app."""
@@ -1,94 +0,0 @@
"""Tests for the fill_organization_metadata management command."""
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
import pytest
from core import factories
pytestmark = pytest.mark.django_db
@pytest.fixture(name="command_output")
def command_output_fixture():
"""Capture command output."""
out = StringIO()
return out
@pytest.mark.django_db
def test_fill_organization_metadata_no_schema(command_output):
"""Test command behavior when no schema is available."""
organization_1 = factories.OrganizationFactory(
name="Org with empty metadata",
metadata={},
with_registration_id=True,
)
organization_2 = factories.OrganizationFactory(
name="Org with partial metadata",
metadata={"existing_key": "existing_value"},
with_registration_id=True,
)
# Mock the schema function to return None (no schema)
with patch("core.models.get_organization_metadata_schema") as mock_get_schema:
mock_get_schema.return_value = None
# Call the command
call_command("fill_organization_metadata", stdout=command_output)
# Check the command output
assert "No organization metadata schema defined" in command_output.getvalue()
organization_1.refresh_from_db()
assert organization_1.metadata == {}
organization_2.refresh_from_db()
assert organization_2.metadata == {"existing_key": "existing_value"}
@pytest.mark.django_db
@pytest.mark.parametrize(
"existing_metadata,expected_result",
[
({}, {"field1": "default_value"}), # Empty metadata gets defaults
({"field1": "custom"}, {"field1": "custom"}), # Existing values preserved
(
{"other_field": "value"},
{"other_field": "value", "field1": "default_value"},
), # Mixed case
],
)
def test_metadata_merging_scenarios(existing_metadata, expected_result):
"""Test various metadata merging scenarios."""
# Create a simple schema with one field
simple_schema = {
"type": "object",
"properties": {
"field1": {"type": "string", "default": "default_value"},
},
}
# Create an organization with the specified metadata
organization = factories.OrganizationFactory(
name="Test organization",
metadata=existing_metadata,
with_registration_id=True,
)
# Mock the schema function to return our simple schema
with patch("core.models.get_organization_metadata_schema") as mock_get_schema:
mock_get_schema.return_value = simple_schema
# Call the command
call_command("fill_organization_metadata")
# Refresh from DB and check
organization.refresh_from_db()
# Check that the metadata has been merged correctly
for key, value in expected_result.items():
assert organization.metadata[key] == value
@@ -59,7 +59,6 @@ def test_api_teams_create_authenticated_new_service_provider(
assert response.json() == {
"created_at": team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"id": str(team.pk),
"is_visible_all_services": False,
"depth": team.depth,
"name": "my team",
"numchild": team.numchild,
@@ -165,46 +164,3 @@ def test_api_teams_create_cannot_override_service_provider(
team = Team.objects.get()
assert team.name == "my team"
assert team.service_providers.get().audience_id == service_provider.audience_id
def test_api_teams_create_is_visible_all_services_team(
client, force_login_via_resource_server
):
"""
Authenticated users should be able to create teams visible in all service providers.
"""
user = UserFactory()
service_provider = ServiceProviderFactory()
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.post(
"/resource-server/v1.0/teams/",
{
"name": "my team",
"is_visible_all_services": True,
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.is_visible_all_services is True
def test_api_teams_create_restricted_team(client, force_login_via_resource_server):
"""Authenticated users should be able to create teams restricted to some service providers."""
user = UserFactory()
service_provider = ServiceProviderFactory()
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.post(
"/resource-server/v1.0/teams/",
{
"name": "my team",
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.is_visible_all_services is False
@@ -82,7 +82,6 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
"created_at": team_1.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_1.depth,
"id": str(team_1.pk),
"is_visible_all_services": False,
"name": team_1.name,
"numchild": team_1.numchild,
"path": team_1.path,
@@ -92,7 +91,6 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
"created_at": team_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_2.depth,
"id": str(team_2.pk),
"is_visible_all_services": False,
"name": team_2.name,
"numchild": team_2.numchild,
"path": team_2.path,
@@ -102,7 +100,6 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
"created_at": team_3.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_3.depth,
"id": str(team_3.pk),
"is_visible_all_services": False,
"name": team_3.name,
"numchild": team_3.numchild,
"path": team_3.path,
@@ -112,7 +109,6 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
"created_at": team_4.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_4.depth,
"id": str(team_4.pk),
"is_visible_all_services": False,
"name": team_4.name,
"numchild": team_4.numchild,
"path": team_4.path,
@@ -277,48 +273,3 @@ def test_api_teams_list_with_parent_teams_other_organization(
team_ids = [team["id"] for team in response_data["results"]]
assert len(team_ids) == 1
assert set(team_ids) == {str(second_team.id)}
def test_api_teams_list_is_visible_all_services_teams(
client, force_login_via_resource_server
):
"""
Authenticated users should be able to see teams
if they are associated with another requesting service provider.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
other_service_provider = factories.ServiceProviderFactory()
# Create a public team visible to the requesting service provider
service_team = factories.TeamFactory(
is_visible_all_services=False,
service_providers=[service_provider],
)
factories.TeamAccessFactory(user=user, team=service_team, role="member")
# Create a public team visible to another service provider (should be listed)
other_public_team = factories.TeamFactory(
is_visible_all_services=True,
service_providers=[other_service_provider],
)
factories.TeamAccessFactory(user=user, team=other_public_team, role="member")
# Create a public team visible to the requesting service provider (should not be listed)
private_team = factories.TeamFactory(
is_visible_all_services=False,
service_providers=[other_service_provider],
)
factories.TeamAccessFactory(user=user, team=private_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get("/resource-server/v1.0/teams/")
assert response.status_code == HTTP_200_OK
response_data = response.json()
assert response_data["count"] == 2
team_ids = [team["id"] for team in response_data["results"]]
assert len(team_ids) == 2
assert str(service_team.id) in team_ids
assert str(other_public_team.id) in team_ids
@@ -69,7 +69,6 @@ def test_api_teams_retrieve_authenticated_related(
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 1,
"id": str(team.id),
"is_visible_all_services": False,
"name": team.name,
"numchild": 0,
"path": team.path,
@@ -144,7 +143,6 @@ def test_api_teams_retrieve_authenticated_related_parent_same_organization(
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 2,
"id": str(first_team.pk),
"is_visible_all_services": False,
"name": first_team.name,
"numchild": 1,
"path": first_team.path,
@@ -231,27 +229,3 @@ def test_api_teams_retrieve_authenticated_related_child_same_organization(
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
def test_api_teams_retrieve_is_visible_all_services_team(
client, force_login_via_resource_server
):
"""
Authenticated users should be able to retrieve teams even
if associated with the another requesting service provider.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
other_service_provider = factories.ServiceProviderFactory()
public_team = factories.TeamFactory(
is_visible_all_services=True,
service_providers=[other_service_provider],
)
TeamAccessFactory(user=user, team=public_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(f"/resource-server/v1.0/teams/{public_team.id}/")
assert response.status_code == status.HTTP_200_OK
assert response.json()["id"] == str(public_team.id)
@@ -336,57 +336,3 @@ def test_api_teams_update_child_team(client, force_login_via_resource_server, ro
second_team.refresh_from_db()
assert second_team.name == "Second"
def test_api_teams_update_is_visible_all_services_status(
client, force_login_via_resource_server
):
"""Team administrators should be able to change the visibility status of their team."""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
team = factories.TeamFactory(
users=[(user, "administrator")],
service_providers=[service_provider],
is_visible_all_services=True,
)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.patch(
f"/resource-server/v1.0/teams/{team.id}/",
{
"is_visible_all_services": False,
},
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
team.refresh_from_db()
assert team.is_visible_all_services is False
def test_api_teams_update_public_status_as_member(
client, force_login_via_resource_server
):
"""Team members should not be able to change the visibility status of their team."""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
team = factories.TeamFactory(
users=[(user, "member")],
service_providers=[service_provider],
is_visible_all_services=True,
)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.patch(
f"/resource-server/v1.0/teams/{team.id}/",
{
"is_visible_all_services": False,
},
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_403_FORBIDDEN
team.refresh_from_db()
assert team.is_visible_all_services is True
@@ -1 +0,0 @@
"""Tests for the resource server Team Invitation API endpoints."""
@@ -1,180 +0,0 @@
"""
Tests for Teams API endpoint in People's core app: create
"""
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
from core.factories import (
ServiceProviderFactory,
TeamAccessFactory,
TeamFactory,
UserFactory,
)
from core.models import Invitation
pytestmark = pytest.mark.django_db
def test_api_teams_invitations_create_anonymous():
"""Anonymous users should not be allowed to create team invitation."""
response = APIClient().post(
"/resource-server/v1.0/teams/ca143ed4-f83d-11ef-a8c5-af2e53ad69fb/invitations/",
{
"email": "toto@example.com",
"role": "member",
},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert not Invitation.objects.exists()
def test_api_teams_invitations_create_authenticated_outsider(
client, force_login_via_resource_server
):
"""Users outside of team should not be permitted to invite to team."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.post(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
{
"email": "new@example.com",
"role": "member",
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert not Invitation.objects.exists()
def test_api_teams_invitations_create_authenticated_member(
client, force_login_via_resource_server
):
"""Team members should not be permitted to create invitations."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.post(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
{
"email": "new@example.com",
"role": "member",
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert not Invitation.objects.exists()
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_teams_invitations_create_authenticated_privileged(
client, force_login_via_resource_server, role
):
"""Owners and administrators should be able to create invitations."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role=role)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.post(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
{
"email": "new@example.com",
"role": "member",
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
invitation = Invitation.objects.get()
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"email": "new@example.com",
"team": str(team.id),
"role": "member",
"issuer": str(user.id),
"is_expired": False,
}
def test_api_teams_invitations_create_duplicate_email(
client, force_login_via_resource_server
):
"""Should not be able to invite the same email twice."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="administrator")
# Create first invitation
with force_login_via_resource_server(client, user, service_provider.audience_id):
client.post(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
{
"email": "new@example.com",
"role": "member",
},
format="json",
)
# Try to create duplicate invitation
response = client.post(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
{
"email": "new@example.com",
"role": "member",
},
format="json",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert Invitation.objects.count() == 1
def test_api_teams_invitations_create_wrong_service_provider(
client, force_login_via_resource_server
):
"""Should not create invitation when accessing with wrong service provider."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
wrong_service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="administrator")
with force_login_via_resource_server(
client, user, wrong_service_provider.audience_id
):
response = client.post(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
{
"email": "new@example.com",
"role": "member",
},
format="json",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json() == {"team": ["Team not found."]}
assert not Invitation.objects.exists()
@@ -1,138 +0,0 @@
"""
Tests for Teams Invitations API endpoint in People's core app: delete
"""
import pytest
from rest_framework.status import (
HTTP_204_NO_CONTENT,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
from rest_framework.test import APIClient
from core.factories import (
InvitationFactory,
ServiceProviderFactory,
TeamAccessFactory,
TeamFactory,
UserFactory,
)
from core.models import Invitation
pytestmark = pytest.mark.django_db
def test_api_teams_invitations_delete_anonymous():
"""Anonymous users should not be allowed to delete team invitations."""
invitation = InvitationFactory()
team = invitation.team
response = APIClient().delete(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert Invitation.objects.filter(id=invitation.id).exists()
def test_api_teams_invitations_delete_authenticated_outsider(
client, force_login_via_resource_server
):
"""Users outside of team should not be permitted to delete team invitations."""
user = UserFactory()
invitation = InvitationFactory()
team = invitation.team
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.delete(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Invitation matches the given query."}
assert Invitation.objects.filter(id=invitation.id).exists()
def test_api_teams_invitations_delete_authenticated_member(
client, force_login_via_resource_server
):
"""Team members should not be permitted to delete invitations."""
user = UserFactory()
invitation = InvitationFactory()
team = invitation.team
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.delete(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert Invitation.objects.filter(id=invitation.id).exists()
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_teams_invitations_delete_authenticated_privileged(
client, force_login_via_resource_server, role
):
"""Owners and administrators should be able to delete invitations."""
user = UserFactory()
invitation = InvitationFactory()
team = invitation.team
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role=role)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.delete(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_204_NO_CONTENT
assert not Invitation.objects.filter(id=invitation.id).exists()
def test_api_teams_invitations_delete_nonexistent(
client, force_login_via_resource_server
):
"""Should return 404 when trying to delete non-existent invitation."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="administrator")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.delete(
f"/resource-server/v1.0/teams/{team.id}/invitations/nonexistent-uuid/",
)
assert response.status_code == HTTP_404_NOT_FOUND
def test_api_teams_invitations_delete_wrong_service_provider(
client, force_login_via_resource_server
):
"""Should not delete invitation when accessing with wrong service provider."""
user = UserFactory()
invitation = InvitationFactory()
team = invitation.team
service_provider = ServiceProviderFactory()
wrong_service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="administrator")
with force_login_via_resource_server(
client, user, wrong_service_provider.audience_id
):
response = client.delete(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert Invitation.objects.filter(id=invitation.id).exists()
@@ -1,157 +0,0 @@
"""
Tests for Teams Invitations API endpoint in People's core app: list
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core.factories import (
InvitationFactory,
ServiceProviderFactory,
TeamAccessFactory,
TeamFactory,
UserFactory,
)
pytestmark = pytest.mark.django_db
def test_api_teams_invitations_list_anonymous():
"""Anonymous users should not be allowed to list team invitations."""
team = TeamFactory()
InvitationFactory.create_batch(size=3, team=team)
response = APIClient().get(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
def test_api_teams_invitations_list_authenticated_outsider(
client, force_login_via_resource_server
):
"""Users outside of team should not be permitted to list team invitations."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
InvitationFactory.create_batch(size=3, team=team)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("role", ["member", "administrator", "owner"])
def test_api_teams_invitations_list_authenticated_team_member(
client, force_login_via_resource_server, role
):
"""Team members should be able to list invitations."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role=role)
invitations = InvitationFactory.create_batch(size=3, team=team)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == HTTP_200_OK
data = response.json()
assert data["count"] == 3
assert len(data["results"]) == 3
# Check invitations are ordered by creation date (most recent first)
assert [item["id"] for item in data["results"]] == [
str(invitation.id) for invitation in reversed(invitations)
]
def test_api_teams_invitations_list_pagination(client, force_login_via_resource_server):
"""Should properly paginate results."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="member")
InvitationFactory.create_batch(size=15, team=team)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
{"page_size": 10},
)
assert response.status_code == HTTP_200_OK
data = response.json()
assert data["count"] == 15
assert len(data["results"]) == 10
assert data["next"] is not None
assert data["previous"] is None
def test_api_teams_invitations_list_empty(client, force_login_via_resource_server):
"""Should return empty list when no invitations exist."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == HTTP_200_OK
data = response.json()
assert data["count"] == 0
assert len(data["results"]) == 0
def test_api_teams_invitations_list_wrong_service_provider(
client, force_login_via_resource_server
):
"""Should not list invitations when accessing with wrong service provider."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
wrong_service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="member")
InvitationFactory.create_batch(size=3, team=team)
with force_login_via_resource_server(
client, user, wrong_service_provider.audience_id
):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@@ -1,140 +0,0 @@
"""
Tests for Teams Invitations API endpoint in People's core app: retrieve
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_404_NOT_FOUND,
)
from rest_framework.test import APIClient
from core.factories import (
InvitationFactory,
ServiceProviderFactory,
TeamAccessFactory,
TeamFactory,
UserFactory,
)
pytestmark = pytest.mark.django_db
def test_api_teams_invitations_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve team invitations."""
invitation = InvitationFactory()
team = invitation.team
response = APIClient().get(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
def test_api_teams_invitations_retrieve_authenticated_outsider(
client, force_login_via_resource_server
):
"""Users outside of team should not be permitted to retrieve team invitations."""
user = UserFactory()
invitation = InvitationFactory()
team = invitation.team
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Invitation matches the given query."}
@pytest.mark.parametrize("role", ["member", "administrator", "owner"])
def test_api_teams_invitations_retrieve_authenticated_team_member(
client, force_login_via_resource_server, role
):
"""Team members should be able to retrieve invitations."""
user = UserFactory()
invitation = InvitationFactory()
team = invitation.team
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role=role)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"email": invitation.email,
"team": str(team.id),
"role": invitation.role,
"issuer": str(invitation.issuer.id),
"is_expired": invitation.is_expired,
}
def test_api_teams_invitations_retrieve_nonexistent(
client, force_login_via_resource_server
):
"""Should return 404 when trying to retrieve non-existent invitation."""
user = UserFactory()
team = TeamFactory()
service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/nonexistent-uuid/",
)
assert response.status_code == HTTP_404_NOT_FOUND
def test_api_teams_invitations_retrieve_wrong_team(
client, force_login_via_resource_server
):
"""Should return 404 when trying to retrieve invitation from wrong team."""
user = UserFactory()
invitation = InvitationFactory()
wrong_team = TeamFactory()
service_provider = ServiceProviderFactory()
wrong_team.service_providers.add(service_provider)
TeamAccessFactory(team=wrong_team, user=user, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{wrong_team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
def test_api_teams_invitations_retrieve_wrong_service_provider(
client, force_login_via_resource_server
):
"""Should not retrieve invitation when accessing with wrong service provider."""
user = UserFactory()
invitation = InvitationFactory()
team = invitation.team
service_provider = ServiceProviderFactory()
wrong_service_provider = ServiceProviderFactory()
team.service_providers.add(service_provider)
TeamAccessFactory(team=team, user=user, role="member")
with force_login_via_resource_server(
client, user, wrong_service_provider.audience_id
):
response = client.get(
f"/resource-server/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
@@ -16,7 +16,6 @@ pytestmark = pytest.mark.django_db
def test_openapi_client_schema():
"""
Generated and served OpenAPI client schema should be correct.
The spectacular command reloads test env.
"""
# Start by generating the swagger.json file
output = StringIO()
@@ -61,7 +61,6 @@ def test_api_teams_create_authenticated(settings):
assert team.name == "my team"
assert team.organization == organization
assert team.accesses.filter(role="owner", user=user).exists()
assert team.is_visible_all_services is True
def test_api_teams_create_authenticated_feature_disabled(settings):
@@ -121,32 +120,3 @@ def test_api_teams_create_cannot_override_organization():
assert team.name == "my team"
assert team.organization == organization
assert team.accesses.filter(role="owner", user=user).exists()
assert team.is_visible_all_services is True
def test_api_teams_create_not_is_visible_all_services():
"""
Authenticated users should be able to create teams and
make is restricted to the services which can view it.
"""
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
{
"name": "my team",
"is_visible_all_services": False,
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.organization == organization
assert team.accesses.filter(role="owner", user=user).exists()
assert team.is_visible_all_services is False
@@ -192,7 +192,6 @@ def test_api_teams_list_authenticated_team_tree(client, role, local_team_abiliti
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": 3,
"id": str(second_team.pk),
"is_visible_all_services": False,
"name": "Second",
"numchild": 1,
"path": second_team.path,
@@ -212,7 +211,6 @@ def test_api_teams_list_authenticated_team_tree(client, role, local_team_abiliti
"created_at": first_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": 2,
"id": str(first_team.pk),
"is_visible_all_services": False,
"name": "First",
"numchild": 1,
"path": first_team.path,
@@ -232,7 +230,6 @@ def test_api_teams_list_authenticated_team_tree(client, role, local_team_abiliti
"created_at": root_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": 1,
"id": str(root_team.pk),
"is_visible_all_services": False,
"name": "Root",
"numchild": 1,
"path": root_team.path,
@@ -319,7 +316,6 @@ def test_api_teams_list_authenticated_team_different_organization(
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": 3,
"id": str(second_team.pk),
"is_visible_all_services": False,
"name": "Second",
"numchild": 1,
"path": second_team.path,
@@ -71,7 +71,6 @@ def test_api_teams_retrieve_authenticated_related():
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 1,
"id": str(team.id),
"is_visible_all_services": False,
"name": team.name,
"numchild": 0,
"path": team.path,
@@ -113,7 +112,6 @@ def test_api_teams_retrieve_authenticated_related_parent(client, role):
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 2,
"id": str(first_team.pk),
"is_visible_all_services": False,
"name": first_team.name,
"numchild": 1,
"path": first_team.path,
@@ -128,27 +128,6 @@ def test_api_teams_update_authenticated_administrators():
assert value == new_values[key]
def test_api_teams_update_is_visible_all_services():
"""Administrators of a team should be allowed to update the visibility to all services."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(
users=[(user, "administrator")], is_visible_all_services=False
)
response = client.patch(
f"/api/v1.0/teams/{team.id!s}/",
{"is_visible_all_services": True},
format="json",
)
assert response.status_code == HTTP_200_OK
team.refresh_from_db()
assert team.is_visible_all_services is True
def test_api_teams_update_authenticated_owners():
"""Administrators of a team should be allowed to update it,
apart from read-only fields."""
@@ -144,7 +144,7 @@ def test_models_invitation__new_user__filter_expired_invitations():
).exists()
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 5), (1, 8), (20, 8)])
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 4), (1, 7), (20, 7)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
@@ -241,13 +241,15 @@ def test_models_team_invitations_get_abilities_member():
def test_models_team_invitations_email():
"""Check email invitation during invitation creation."""
team = factories.TeamFactory()
member_access = factories.TeamAccessFactory(role="member")
team = member_access.team
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.TeamAccessFactory(team=team)
invitation = factories.InvitationFactory(
role="member", team=team, email="john@people.com", issuer__language="fr-fr"
team=team, email="john@people.com", issuer__language="fr-fr"
)
# pylint: disable-next=no-member
@@ -257,15 +259,10 @@ def test_models_team_invitations_email():
email = mail.outbox[0]
assert email.to == [invitation.email]
assert (
email.subject == "[La Suite] Vous avez été invité(e) à être membre d'un groupe"
)
assert email.subject == "Invitation à rejoindre La Régie!"
email_content = " ".join(email.body.split())
assert (
f"""Vous avez été invité(e) à être membre du groupe "{team.name}" au sein de la Suite."""
in email_content
)
assert "Invitation à rejoindre La Régie!" in email_content
assert "[//example.com]" in email_content
@@ -125,77 +125,3 @@ def test_models_organization_registration_id_validators():
name="hi",
registration_id_list=["a12345678912345"],
)
def test_models_organization_metadata_schema_valid(settings):
"""When a schema is provided, valid metadata should pass validation."""
settings.ORGANIZATION_METADATA_SCHEMA = "fr/organization_metadata.json"
# Clear the cache to reload the schema
models.get_organization_metadata_schema.cache_clear()
organization = models.Organization(
name="Valid Metadata Org",
registration_id_list=["12345678901234"],
metadata={"is_public_service": True, "is_commune": False},
)
# This should not raise any validation errors
organization.full_clean()
organization.save()
# Verify the metadata was saved correctly
org = models.Organization.objects.get(pk=organization.pk)
assert org.metadata["is_public_service"] is True
assert org.metadata["is_commune"] is False
settings.ORGANIZATION_METADATA_SCHEMA = None
# Clear the cache to reload the schema
models.get_organization_metadata_schema.cache_clear()
def test_models_organization_metadata_schema_invalid(settings):
"""When a schema is provided, invalid metadata should fail validation."""
settings.ORGANIZATION_METADATA_SCHEMA = "fr/organization_metadata.json"
# Clear the cache to reload the schema
models.get_organization_metadata_schema.cache_clear()
# Integer instead of boolean for is_public_service
organization = models.Organization(
name="Invalid Metadata Org",
registration_id_list=["12345678901234"],
metadata={"is_public_service": 1, "is_commune": False},
)
with pytest.raises(ValidationError) as excinfo:
organization.full_clean()
assert "metadata" in str(excinfo.value)
assert "is_public_service" in str(excinfo.value)
assert "is_commune" not in str(excinfo.value)
settings.ORGANIZATION_METADATA_SCHEMA = None
# Clear the cache to reload the schema
models.get_organization_metadata_schema.cache_clear()
def test_models_organization_no_metadata_schema(settings):
"""When no schema is provided, any metadata should be allowed."""
settings.ORGANIZATION_METADATA_SCHEMA = None
# Clear the cache to reload the schema
models.get_organization_metadata_schema.cache_clear()
# Random metadata that wouldn't match the schema
organization = models.Organization(
name="No Schema Org",
registration_id_list=["12345678901234"],
metadata={"random_field": "anything", "numeric_value": 123},
)
# This should not raise any validation errors
organization.full_clean()
organization.save()
# Verify the metadata was saved correctly
org = models.Organization.objects.get(pk=organization.pk)
assert org.metadata["random_field"] == "anything"
assert org.metadata["numeric_value"] == 123
@@ -73,16 +73,13 @@ def test_utils_webhooks_add_user_to_group_success(mock_info):
}
# Logger
for webhook in webhooks:
expected_messages = {
(
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
}
actual_messages = {args for args, _ in mock_info.call_args_list}
assert expected_messages.issubset(actual_messages)
assert mock_info.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_info.call_args_list[i][0] == (
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
# Status
for webhook in webhooks:
@@ -133,16 +130,13 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
}
# Logger
for webhook in webhooks:
expected_messages = {
(
"%s synchronization succeeded with %s",
"remove_user_from_group",
webhook.url,
)
}
actual_messages = {args for args, _ in mock_info.call_args_list}
assert expected_messages.issubset(actual_messages)
assert mock_info.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_info.call_args_list[i][0] == (
"%s synchronization succeeded with %s",
"remove_user_from_group",
webhook.url,
)
# Status
for webhook in webhooks:
@@ -151,7 +145,8 @@ def test_utils_webhooks_remove_user_from_group_success(mock_info):
@mock.patch.object(Logger, "error")
def test_utils_webhooks_add_user_to_group_failure(mock_error):
@mock.patch.object(Logger, "info")
def test_utils_webhooks_add_user_to_group_failure(mock_info, mock_error):
"""The logger should be called on webhook call failure."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
@@ -193,16 +188,14 @@ def test_utils_webhooks_add_user_to_group_failure(mock_error):
}
# Logger
for webhook in webhooks:
expected_messages = {
(
"%s synchronization failed with %s",
"add_user_to_group",
webhook.url,
)
}
actual_messages = {args for args, _ in mock_error.call_args_list}
assert expected_messages.issubset(actual_messages)
assert not mock_info.called
assert mock_error.call_count == 2
for i, webhook in enumerate(webhooks):
assert mock_error.call_args_list[i][0] == (
"%s synchronization failed with %s",
"add_user_to_group",
webhook.url,
)
# Status
for webhook in webhooks:
@@ -253,15 +246,12 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
# Logger
assert not mock_error.called
expected_messages = {
(
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
}
actual_messages = {args for args, _ in mock_info.call_args_list}
assert expected_messages.issubset(actual_messages)
assert mock_info.call_count == 1
assert mock_info.call_args_list[0][0] == (
"%s synchronization succeeded with %s",
"add_user_to_group",
webhook.url,
)
# Status
webhook.refresh_from_db()
@@ -269,7 +259,8 @@ def test_utils_webhooks_add_user_to_group_retries(mock_info, mock_error):
@mock.patch.object(Logger, "error")
def test_utils_synchronize_course_runs_max_retries_exceeded(mock_error):
@mock.patch.object(Logger, "info")
def test_utils_synchronize_course_runs_max_retries_exceeded(mock_info, mock_error):
"""Webhooks synchronization has exceeded max retries and should get logged."""
user = factories.UserFactory()
access = factories.TeamAccessFactory(user=user)
@@ -308,15 +299,13 @@ def test_utils_synchronize_course_runs_max_retries_exceeded(mock_error):
}
# Logger
expected_messages = {
(
"%s synchronization failed due to max retries exceeded with url %s",
"add_user_to_group",
webhook.url,
)
}
actual_messages = {args for args, _ in mock_error.call_args_list}
assert expected_messages.issubset(actual_messages)
assert not mock_info.called
assert mock_error.call_count == 1
assert mock_error.call_args_list[0][0] == (
"%s synchronization failed due to max retries exceeded with url %s",
"add_user_to_group",
webhook.url,
)
# Status
webhook.refresh_from_db()
-1
View File
@@ -1 +0,0 @@
"""Tests for the utils module."""
@@ -1,162 +0,0 @@
"""Tests for the JSON schema `generate_default_from_schema` utility functions."""
import pytest
from core.utils.json_schema import generate_default_from_schema
@pytest.mark.parametrize(
"schema,expected",
[
# Test empty schema
({}, {}),
# Test schema with no properties
({"type": "object"}, {}),
# Test basic property types
(
{
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"active": {"type": "boolean"},
"data": {"type": "null"},
}
},
{"name": "", "age": None, "active": False, "data": None},
),
# Test default values
(
{
"properties": {
"name": {"type": "string", "default": "John Doe"},
"age": {"type": "integer", "default": 30},
"active": {"type": "boolean", "default": True},
}
},
{
"name": "John Doe",
"age": 30,
"active": True,
},
),
# Test array type
(
{"properties": {"items": {"type": "array"}, "tags": {"type": "array"}}},
{"items": [], "tags": []},
),
# Test nested object
(
{
"properties": {
"user": {
"type": "object",
"properties": {
"name": {"type": "string"},
"details": {
"type": "object",
"properties": {
"age": {"type": "integer"},
"active": {"type": "boolean", "default": True},
},
},
},
}
}
},
{"user": {"name": "", "details": {"age": None, "active": True}}},
),
# Test complex schema with multiple types and nesting
(
{
"properties": {
"id": {"type": "string", "default": "user-123"},
"profile": {
"type": "object",
"properties": {
"firstName": {"type": "string"},
"lastName": {"type": "string"},
"age": {"type": "number", "default": 25},
},
},
"roles": {"type": "array"},
"settings": {
"type": "object",
"properties": {
"notifications": {"type": "boolean", "default": False},
"theme": {"type": "string", "default": "light"},
},
},
"unknown": {"type": "something-else"},
}
},
{
"id": "user-123",
"profile": {"firstName": "", "lastName": "", "age": 25},
"roles": [],
"settings": {"notifications": False, "theme": "light"},
"unknown": None,
},
),
],
)
def test_generate_default_from_schema(schema, expected):
"""Test the generate_default_from_schema function with various schema inputs."""
result = generate_default_from_schema(schema)
assert result == expected
def test_with_invalid_inputs():
"""Test the function with invalid inputs to ensure it handles them gracefully."""
# pylint: disable=use-implicit-booleaness-not-comparison
# None input
assert generate_default_from_schema(None) == {}
# Invalid schema type
assert generate_default_from_schema([]) == {}
assert generate_default_from_schema("not-a-schema") == {}
# Empty properties
assert generate_default_from_schema({"properties": {}}) == {}
def test_complex_nested_arrays():
"""Test handling of complex schemas with nested arrays and objects."""
schema = {
"properties": {
"users": {
"type": "array",
},
"config": {
"type": "object",
"properties": {
"features": {
"type": "object",
"properties": {
"enabledFlags": {"type": "array"},
"limits": {
"type": "object",
"properties": {
"maxUsers": {"type": "integer", "default": 10},
"maxStorage": {"type": "integer", "default": 5120},
},
},
},
}
},
},
}
}
expected = {
"users": [],
"config": {
"features": {
"enabledFlags": [],
"limits": {"maxUsers": 10, "maxStorage": 5120},
}
},
}
result = generate_default_from_schema(schema)
assert result == expected
-32
View File
@@ -1,32 +0,0 @@
"""Useful functions for working with JSON schemas"""
def generate_default_from_schema(schema: dict) -> dict:
"""
Generate default values based on a JSON schema
"""
if not schema or "properties" not in schema:
return {}
result = {}
for prop_name, prop_schema in schema.get("properties", {}).items():
prop_type = prop_schema.get("type")
match prop_type:
case "object" if "properties" in prop_schema:
result[prop_name] = generate_default_from_schema(prop_schema)
case "array":
result[prop_name] = []
case "string":
result[prop_name] = prop_schema.get("default", "")
case "number" | "integer":
result[prop_name] = prop_schema.get("default", None)
case "boolean":
result[prop_name] = prop_schema.get("default", False)
case "null":
result[prop_name] = None
case _:
result[prop_name] = None
return result
+8 -20
View File
@@ -3,37 +3,25 @@
from django.urls import path
from .views import (
DebugViewMaildomainInvitationHtml,
DebugViewMaildomainInvitationTxt,
DebugViewHtml,
DebugViewNewMailboxHtml,
DebugViewTeamInvitationHtml,
DebugViewTeamInvitationTxt,
DebugViewTxt,
)
urlpatterns = [
path(
"__debug__/mail/team_invitation_html",
DebugViewTeamInvitationHtml.as_view(),
name="debug.mail.team_invitation_html",
"__debug__/mail/invitation_html",
DebugViewHtml.as_view(),
name="debug.mail.invitation_html",
),
path(
"__debug__/mail/team_invitation_txt",
DebugViewTeamInvitationTxt.as_view(),
name="debug.mail.team_invitation_txt",
"__debug__/mail/invitation_txt",
DebugViewTxt.as_view(),
name="debug.mail.invitation_txt",
),
path(
"__debug__/mail/new_mailbox_html",
DebugViewNewMailboxHtml.as_view(),
name="debug.mail.new_mailbox_html",
),
path(
"__debug__/mail/maildomain_invitation_txt",
DebugViewMaildomainInvitationTxt.as_view(),
name="debug.mail.maildomain_invitation_txt",
),
path(
"__debug__/mail/maildomain_invitation_html",
DebugViewMaildomainInvitationHtml.as_view(),
name="debug.mail.maildomain_invitation_html",
),
]
+8 -43
View File
@@ -8,60 +8,25 @@ class DebugBaseView(TemplateView):
def get_context_data(self, **kwargs):
"""Generates sample datas to have a valid debug email"""
context = super().get_context_data(**kwargs)
context["title"] = "Development email preview"
return context
# TEAM INVITATION
class DebugViewTeamInvitationBase(DebugBaseView):
"""Debug view for team invitation base email layout"""
class DebugViewHtml(DebugBaseView):
"""Debug View for HTML Email Layout"""
def get_context_data(self, **kwargs):
"""Add some fake context data for team invitation email layout"""
context = super().get_context_data(**kwargs)
context["team"] = "example team"
context["role"] = "owner"
return context
template_name = "mail/html/invitation.html"
class DebugViewTeamInvitationHtml(DebugViewTeamInvitationBase):
"""Debug view for team invitation html email layout"""
class DebugViewTxt(DebugBaseView):
"""Debug View for Text Email Layout"""
template_name = "mail/html/team_invitation.html"
template_name = "mail/text/invitation.txt"
class DebugViewTeamInvitationTxt(DebugViewTeamInvitationBase):
"""Debug view for team invitation text email layout"""
template_name = "mail/text/team_invitation.txt"
# MAIL DOMAIN INVITATION
class DebugViewMaildomainInvitationBase(DebugBaseView):
"""Debug view for mail domain invitation base email layout"""
def get_context_data(self, **kwargs):
"""Add some fake context data for mail domain invitation email layout"""
context = super().get_context_data(**kwargs)
context["domain"] = "example.com"
context["role"] = "owner"
return context
class DebugViewMaildomainInvitationHtml(DebugViewMaildomainInvitationBase):
"""Debug view for mail domain invitation html email layout"""
template_name = "mail/html/maildomain_invitation.html"
class DebugViewMaildomainInvitationTxt(DebugViewMaildomainInvitationBase):
"""Debug view for mail domain invitation text email layout"""
template_name = "mail/text/maildomain_invitation.txt"
# NEW MAILBOX
class DebugViewNewMailboxHtml(DebugBaseView):
"""Debug view for new mailbox email layout"""
@@ -9,19 +9,17 @@ from uuid import uuid4
from django import db
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand, CommandError
from django.utils.text import slugify
from faker import Faker
from oauth2_provider.models import Application
from treebeard.mp_tree import MP_Node
from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from mailbox_manager.enums import MailboxStatusChoices, MailDomainStatusChoices
from mailbox_manager.enums import MailDomainStatusChoices
fake = Faker()
@@ -135,48 +133,6 @@ class Timeit:
return elapsed_time
def create_oidc_people_idp_client():
"""Configure the OIDC client for the People Identity Provider if missing."""
try:
Application.objects.get(client_id="people-idp")
except Application.DoesNotExist:
application = Application(
client_id="people-idp",
client_secret="local-tests-only",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
name="People Identity Provider",
algorithm=Application.RS256_ALGORITHM,
redirect_uris="http://localhost:8083/realms/people/broker/oidc-people-local/endpoint",
skip_authorization=True,
)
application.clean()
application.save()
def create_oidc_people_idp_client_user():
"""Provide a user for the People Identity Provider OIDC client."""
organization, _created = models.Organization.objects.get_or_create(
name="13002526500013",
registration_id_list=["13002526500013"],
)
mail_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="example.com",
organization=organization,
status=MailDomainStatusChoices.ENABLED,
support_email="support@example.com",
)
_mailbox, _created = mailbox_models.Mailbox.objects.get_or_create(
first_name="IdP User",
last_name="E2E",
domain=mail_domain,
local_part="user-e2e",
status=MailboxStatusChoices.ENABLED,
password=make_password("password-user-e2e"),
secondary_email="not-used@example.com",
)
def create_demo(stdout): # pylint: disable=too-many-locals
"""
Create a database with demo data for developers to work in a realistic environment.
@@ -359,12 +315,6 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
# OIDC configuration
if settings.OAUTH2_PROVIDER.get("OIDC_ENABLED", False):
stdout.write("Creating OIDC client for People Identity Provider")
create_oidc_people_idp_client()
create_oidc_people_idp_client_user()
class Command(BaseCommand):
"""A management command to create a demo database."""
@@ -3,6 +3,7 @@
from unittest import mock
from django.core.management import call_command
from django.test import override_settings
import pytest
@@ -10,7 +11,6 @@ from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
from people.settings import Base
pytestmark = pytest.mark.django_db
@@ -23,13 +23,10 @@ TEST_NB_OBJECTS = {
}
@override_settings(DEBUG=True)
@mock.patch.dict(defaults.NB_OBJECTS, TEST_NB_OBJECTS)
def test_commands_create_demo(settings):
def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
settings.DEBUG = True
settings.OAUTH2_PROVIDER["OIDC_ENABLED"] = True
settings.OAUTH2_PROVIDER["OIDC_RSA_PRIVATE_KEY"] = Base.generate_temporary_rsa_key()
call_command("create_demo")
# Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
@@ -40,7 +37,7 @@ def test_commands_create_demo(settings):
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights
Binary file not shown.
@@ -1,685 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-14 11:28+0000\n"
"PO-Revision-Date: 2025-03-14 11:29\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: build/lib/core/admin.py:64 core/admin.py:64
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:75 core/admin.py:75
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:87 core/admin.py:87
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:126 core/admin.py:126
msgid "User"
msgstr ""
#: build/lib/core/admin.py:228 core/admin.py:228
msgid "Run post creation plugins"
msgstr ""
#: build/lib/core/admin.py:236 core/admin.py:236
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
#: build/lib/core/authentication/backends.py:94
#: core/authentication/backends.py:94
msgid "User info contained no recognizable user identification"
msgstr ""
#: build/lib/core/authentication/backends.py:115
#: core/authentication/backends.py:115
msgid "User account is disabled"
msgstr ""
#: build/lib/core/authentication/backends.py:161
#: core/authentication/backends.py:161
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/core/authentication/backends.py:180
#: core/authentication/backends.py:180
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: build/lib/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
msgid "Pending"
msgstr ""
#: build/lib/core/enums.py:26 core/enums.py:26
msgid "Success"
msgstr ""
#: build/lib/core/models.py:78 core/models.py:78
msgid "Member"
msgstr ""
#: build/lib/core/models.py:79 build/lib/core/models.py:91
#: build/lib/mailbox_manager/enums.py:14 core/models.py:79 core/models.py:91
#: mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:80 build/lib/mailbox_manager/enums.py:15
#: core/models.py:80 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:103 core/models.py:103
msgid "id"
msgstr ""
#: build/lib/core/models.py:104 core/models.py:104
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:110 core/models.py:110
msgid "created at"
msgstr ""
#: build/lib/core/models.py:111 core/models.py:111
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:116 core/models.py:116
msgid "updated at"
msgstr ""
#: build/lib/core/models.py:117 core/models.py:117
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:156 core/models.py:156
msgid "full name"
msgstr ""
#: build/lib/core/models.py:157 core/models.py:157
msgid "short name"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
msgid "notes"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
msgid "contact information"
msgstr ""
#: build/lib/core/models.py:163 core/models.py:163
msgid "A JSON object containing the contact information"
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
msgid "contact"
msgstr ""
#: build/lib/core/models.py:178 core/models.py:178
msgid "contacts"
msgstr ""
#: build/lib/core/models.py:252 build/lib/core/models.py:366
#: build/lib/core/models.py:511 build/lib/mailbox_manager/models.py:26
#: core/models.py:252 core/models.py:366 core/models.py:511
#: mailbox_manager/models.py:26
msgid "name"
msgstr ""
#: build/lib/core/models.py:254 core/models.py:254
msgid "audience id"
msgstr ""
#: build/lib/core/models.py:259 core/models.py:259
msgid "service provider"
msgstr ""
#: build/lib/core/models.py:260 core/models.py:260
msgid "service providers"
msgstr ""
#: build/lib/core/models.py:374 core/models.py:374
msgid "registration ID list"
msgstr ""
#: build/lib/core/models.py:381 core/models.py:381
msgid "domain list"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "metadata"
msgstr ""
#: build/lib/core/models.py:389 core/models.py:389
msgid "A JSON object containing the organization metadata"
msgstr ""
#: build/lib/core/models.py:404 core/models.py:404
msgid "organization"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "organizations"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
#: build/lib/core/models.py:496 core/models.py:496
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: build/lib/core/models.py:502 core/models.py:502
msgid "sub"
msgstr ""
#: build/lib/core/models.py:504 core/models.py:504
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: build/lib/core/models.py:510 build/lib/core/models.py:959 core/models.py:510
#: core/models.py:959
msgid "email address"
msgstr ""
#: build/lib/core/models.py:516 core/models.py:516
msgid "language"
msgstr ""
#: build/lib/core/models.py:517 core/models.py:517
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:523 core/models.py:523
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:526 core/models.py:526
msgid "device"
msgstr ""
#: build/lib/core/models.py:528 core/models.py:528
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:531 core/models.py:531
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:533 core/models.py:533
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:536 core/models.py:536
msgid "active"
msgstr ""
#: build/lib/core/models.py:539 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:558 core/models.py:558
msgid "user"
msgstr ""
#: build/lib/core/models.py:559 core/models.py:559
msgid "users"
msgstr ""
#: build/lib/core/models.py:697 core/models.py:697
msgid "Organization/user relation"
msgstr ""
#: build/lib/core/models.py:698 core/models.py:698
msgid "Organization/user relations"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "This user is already in this organization."
msgstr ""
#: build/lib/core/models.py:775 core/models.py:775
msgid "is visible for all SP"
msgstr ""
#: build/lib/core/models.py:777 core/models.py:777
msgid "Whether this team is visible to all service providers."
msgstr ""
#: build/lib/core/models.py:785 core/models.py:785
msgid "Team"
msgstr ""
#: build/lib/core/models.py:786 core/models.py:786
msgid "Teams"
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
msgid "Team/user relation"
msgstr ""
#: build/lib/core/models.py:838 core/models.py:838
msgid "Team/user relations"
msgstr ""
#: build/lib/core/models.py:843 core/models.py:843
msgid "This user is already in this team."
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
msgid "url"
msgstr ""
#: build/lib/core/models.py:933 core/models.py:933
msgid "secret"
msgstr ""
#: build/lib/core/models.py:942 core/models.py:942
msgid "Team webhook"
msgstr ""
#: build/lib/core/models.py:943 core/models.py:943
msgid "Team webhooks"
msgstr ""
#: build/lib/core/models.py:987 core/models.py:987
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "Invitation to join La Régie!"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
msgid "Team invitation"
msgstr ""
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Team invitations"
msgstr ""
#: build/lib/core/models.py:1061 core/models.py:1061
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr ""
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
msgid "Synchronise 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/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:41 mailbox_manager/models.py:41
msgid "support email"
msgstr ""
#: build/lib/mailbox_manager/models.py:45 mailbox_manager/models.py:45
msgid "last check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:46 mailbox_manager/models.py:46
msgid "A JSON object containing the last health check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:51 mailbox_manager/models.py:51
msgid "expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:52 mailbox_manager/models.py:52
msgid "A JSON object containing the expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:57 mailbox_manager/models.py:57
msgid "Mail domain"
msgstr ""
#: build/lib/mailbox_manager/models.py:58 mailbox_manager/models.py:58
msgid "Mail domains"
msgstr ""
#: build/lib/mailbox_manager/models.py:133 mailbox_manager/models.py:133
msgid "User/mail domain relation"
msgstr ""
#: build/lib/mailbox_manager/models.py:134 mailbox_manager/models.py:134
msgid "User/mail domain relations"
msgstr ""
#: build/lib/mailbox_manager/models.py:207 mailbox_manager/models.py:207
msgid "local_part"
msgstr ""
#: build/lib/mailbox_manager/models.py:221 mailbox_manager/models.py:221
msgid "secondary email address"
msgstr ""
#: build/lib/mailbox_manager/models.py:232 mailbox_manager/models.py:232
msgid "email"
msgstr ""
#: build/lib/mailbox_manager/models.py:238 mailbox_manager/models.py:238
msgid "Mailbox"
msgstr ""
#: build/lib/mailbox_manager/models.py:239 mailbox_manager/models.py:239
msgid "Mailboxes"
msgstr ""
#: build/lib/mailbox_manager/models.py:265 mailbox_manager/models.py:265
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: build/lib/mailbox_manager/models.py:303 mailbox_manager/models.py:303
msgid "Mail domain invitation"
msgstr ""
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
msgid "Mail domain invitations"
msgstr ""
#: build/lib/mailbox_manager/models.py:316 mailbox_manager/models.py:316
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:270
#: mailbox_manager/utils/dimail.py:270
msgid "Your new mailbox information"
msgstr ""
#: build/lib/people/settings.py:155 people/settings.py:155
msgid "English"
msgstr ""
#: build/lib/people/settings.py:156 people/settings.py:156
msgid "French"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:195
#: core/templates/mail/html/team_invitation.html:195
#: core/templates/mail/text/maildomain_invitation.txt:5
#: core/templates/mail/text/team_invitation.txt:5
msgid "Welcome to La Régie!"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:201
#: core/templates/mail/html/team_invitation.html:201
#: core/templates/mail/text/maildomain_invitation.txt:6
#: core/templates/mail/text/team_invitation.txt:6
msgid "Hello,"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:206
#: core/templates/mail/text/maildomain_invitation.txt:7
#, python-format
msgid "You have been invited to join La Régie to be %(role)s of the domain %(domain)s."
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:211
#: core/templates/mail/html/team_invitation.html:211
#: core/templates/mail/text/maildomain_invitation.txt:8
#: core/templates/mail/text/team_invitation.txt:8
msgid "To do so, please log in La Régie via ProConnect, by following this link:"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:221
#: core/templates/mail/html/team_invitation.html:221
#: core/templates/mail/text/maildomain_invitation.txt:10
#: core/templates/mail/text/team_invitation.txt:10
msgid "Go to La Régie"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:231
#: core/templates/mail/html/team_invitation.html:231
#: core/templates/mail/text/maildomain_invitation.txt:12
#: core/templates/mail/text/team_invitation.txt:12
msgid "What is La Régie?"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:236
#: core/templates/mail/text/maildomain_invitation.txt:13
msgid "La Régie is the administration center of la Suite, where you can manage users, groups and domains. You will be able to:"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:237
#: core/templates/mail/text/maildomain_invitation.txt:14
msgid "create work groups,"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:238
#: core/templates/mail/text/maildomain_invitation.txt:15
msgid "invite new members,"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:239
#: core/templates/mail/text/maildomain_invitation.txt:16
msgid "manage mail domains,"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:240
#: core/templates/mail/text/maildomain_invitation.txt:17
msgid "create new mail accounts,"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:241
#: core/templates/mail/text/maildomain_invitation.txt:18
msgid "etc."
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:248
#: core/templates/mail/html/team_invitation.html:261
#: core/templates/mail/text/maildomain_invitation.txt:20
#: core/templates/mail/text/team_invitation.txt:18
msgid "Welcome aboard!"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:255
#: core/templates/mail/html/team_invitation.html:268
#: core/templates/mail/text/maildomain_invitation.txt:22
#: core/templates/mail/text/team_invitation.txt:20
msgid "Regards,"
msgstr ""
#: core/templates/mail/html/maildomain_invitation.html:256
#: core/templates/mail/html/new_mailbox.html:273
#: core/templates/mail/html/team_invitation.html:269
#: core/templates/mail/text/maildomain_invitation.txt:24
#: core/templates/mail/text/new_mailbox.txt:17
#: core/templates/mail/text/team_invitation.txt:22
msgid "La Suite Team"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:159
#: core/templates/mail/text/new_mailbox.txt:3
msgid "La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:188
#: core/templates/mail/text/new_mailbox.txt:5
msgid "Welcome to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:193
#: core/templates/mail/text/new_mailbox.txt:6
msgid "La Messagerie is the email solution of La Suite."
msgstr ""
#: core/templates/mail/html/new_mailbox.html:199
#: core/templates/mail/text/new_mailbox.txt:7
msgid "Your mailbox has been created."
msgstr ""
#: core/templates/mail/html/new_mailbox.html:204
#: core/templates/mail/text/new_mailbox.txt:8
msgid "Please find below your login info: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:228
#: core/templates/mail/text/new_mailbox.txt:10
msgid "Email address: "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:233
#: core/templates/mail/text/new_mailbox.txt:11
msgid "Temporary password (to be modify on first login): "
msgstr ""
#: core/templates/mail/html/new_mailbox.html:261
#: core/templates/mail/text/new_mailbox.txt:13
msgid "Go to La Messagerie"
msgstr ""
#: core/templates/mail/html/new_mailbox.html:272
#: core/templates/mail/text/new_mailbox.txt:15
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/team_invitation.html:206
#: core/templates/mail/text/team_invitation.txt:7
#, python-format
msgid "You have been invited to be a %(role)s of the group \"%(team)s\" within La Suite."
msgstr ""
#: core/templates/mail/html/team_invitation.html:236
#: core/templates/mail/text/team_invitation.txt:13
msgid "La Régie is the administration center of la Suite, where you can manage users, groups and domains."
msgstr ""
#: core/templates/mail/html/team_invitation.html:241
#: core/templates/mail/text/team_invitation.txt:14
msgid "What is La Suite?"
msgstr ""
#: core/templates/mail/html/team_invitation.html:246
#: core/templates/mail/text/team_invitation.txt:15
msgid "La Suite is an open-source work environment, designed for the public sector and open to commons."
msgstr ""
#: core/templates/mail/html/team_invitation.html:251
#: core/templates/mail/text/team_invitation.txt:16
msgid "With La Suite, you will be able to create, organise and collaborate online!"
msgstr ""
#: core/templates/mail/html/team_invitation.html:256
#, python-format
msgid "For more information: <a href=\"%(link)s\">Visit the website for La Suite numérique</a> to discover what tools we offer."
msgstr ""
#: core/templates/mail/text/team_invitation.txt:17
#, python-format
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
msgstr ""
Binary file not shown.
File diff suppressed because it is too large Load Diff
+18 -103
View File
@@ -1,8 +1,7 @@
"""Admin classes and registrations for People's mailbox manager app."""
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html_join, mark_safe
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from requests import exceptions
@@ -10,9 +9,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=_("Synchronise from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
@@ -32,22 +28,22 @@ def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disabl
except exceptions.HTTPError as err:
messages.error(
request,
_("Synchronisation failed for %(domain)s with message: %(err)s")
% {"domain": domain.name, "err": err},
_(f"Synchronisation failed for {domain.name} with message: [{err}]"),
)
else:
messages.success(
request,
_(
"Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
)
% {"domain": domain.name, "mailboxes": ", ".join(imported_mailboxes)},
f"Synchronisation succeed for {domain.name}. "
f"Imported mailboxes: {', '.join(imported_mailboxes)}"
),
)
if excluded_domains:
messages.warning(
request,
_("Sync require enabled domains. Excluded domains: %(domains)s")
% {"domains": ", ".join(excluded_domains)},
_(
f"Sync require enabled domains. Excluded domains: {', '.join(excluded_domains)}"
),
)
@@ -67,10 +63,7 @@ def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: d
try:
response = client.fetch_domain_status(domain)
except exceptions.HTTPError as err:
msg_error.append(
_("- %(domain)s with message: %(err)s")
% {"domain": domain.name, "err": err},
)
msg_error.append(_(f"""- <b>{domain.name}</b> with message: '{err}'"""))
else:
success = True
# temporary (or not?) display content of the dimail response to debug broken state
@@ -82,56 +75,20 @@ def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: d
if success:
msg_success = [
_("Check domains done with success."),
_("Domains updated: %(domains)s") % {"domains": ", ".join(domains_updated)}
_(f"Domains updated: {', '.join(domains_updated)}")
if domains_updated
else _("No domain updated."),
]
messages.success(
request,
format_html_join(mark_safe("<br> "), "{}", ([str(m)] for m in msg_success)),
)
messages.success(request, format_html("<br> ".join(map(str, msg_success))))
if msg_error:
msg_error.insert(0, _("Check domain failed for:"))
messages.error(
request,
format_html_join(mark_safe("<br> "), "{}", ([str(m)] for m in msg_error)),
)
messages.error(request, format_html("<br> ".join(map(str, msg_error))))
if excluded_domains:
messages.warning(
request,
_("Domains disabled are excluded from check: %(domains)s")
% {"domains": ", ".join(excluded_domains)},
)
@admin.action(description=_("Fetch domain expected config from dimail"))
def fetch_domain_expected_config_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to fetch domain expected config from dimail."""
client = DimailAPIClient()
excluded_domains = []
for domain in queryset:
# do not check disabled domains
if domain.status == enums.MailDomainStatusChoices.DISABLED:
excluded_domains.append(domain.name)
continue
response = client.fetch_domain_expected_config(domain)
if response:
messages.success(
request,
_("Domain expected config fetched with success for %(domain)s.")
% {"domain": domain.name},
)
else:
messages.error(
request,
_("Failed to fetch domain expected config for %(domain)s.")
% {"domain": domain.name},
)
if excluded_domains:
messages.warning(
request,
_("Domains disabled are excluded from fetch: %(domains)s")
% {"domains": ", ".join(excluded_domains)},
_(
f"Domains disabled are excluded from check: {', '.join(excluded_domains)}"
),
)
@@ -149,26 +106,20 @@ class MailDomainAdmin(admin.ModelAdmin):
list_display = (
"name",
"organization",
"created_at",
"updated_at",
"slug",
"status",
)
search_fields = ("name", "organization__name")
search_fields = ("name",)
readonly_fields = ["created_at", "slug"]
list_filter = ("status",)
inlines = (UserMailDomainAccessInline,)
actions = (
sync_mailboxes_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
)
autocomplete_fields = ["organization"]
actions = (sync_mailboxes_from_dimail, fetch_domain_status_from_dimail)
@admin.register(models.Mailbox)
class MailboxAdmin(UserAdmin):
class MailboxAdmin(admin.ModelAdmin):
"""Admin for mailbox model."""
list_display = ("__str__", "domain", "status", "updated_at")
@@ -176,29 +127,6 @@ class MailboxAdmin(UserAdmin):
search_fields = ("local_part", "domain__name")
readonly_fields = ["updated_at", "local_part", "domain"]
fieldsets = None
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": (
"first_name",
"last_name",
"local_part",
"domain",
"secondary_email",
"status",
"usable_password",
"password1",
"password2",
),
},
),
)
ordering = ("local_part", "domain")
filter_horizontal = ()
@admin.register(models.MailDomainAccess)
class MailDomainAccessAdmin(admin.ModelAdmin):
@@ -220,16 +148,3 @@ class MailDomainAccessInline(admin.TabularInline):
autocomplete_fields = ["user", "domain"]
model = models.MailDomainAccess
readonly_fields = ("created_at", "updated_at")
@admin.register(models.MailDomainInvitation)
class MailDomainInvitationAdmin(admin.ModelAdmin):
"""Admin for mail domain invitation model."""
list_display = ("email", "domain", "created_at", "updated_at", "is_expired")
search_fields = ("email", "domain__name")
readonly_fields = ("created_at", "updated_at", "is_expired")
def is_expired(self, obj):
"""Return the expiration date of the invitation."""
return obj.is_expired
@@ -2,8 +2,6 @@
from logging import getLogger
from django.contrib.auth.hashers import make_password
from requests.exceptions import HTTPError
from rest_framework import exceptions, serializers
@@ -35,16 +33,8 @@ class MailboxSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""
Override create function to fire a request on mailbox creation.
By default, we generate an unusable password for the mailbox, meaning that the mailbox
will not be able to be used as a login credential until the password is set.
"""
mailbox = super().create(
validated_data
| {
"password": make_password(None), # generate an unusable password
}
)
mailbox = super().create(validated_data)
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
client = DimailAPIClient()
# send new mailbox request to dimail
@@ -55,9 +45,7 @@ class MailboxSerializer(serializers.ModelSerializer):
# send confirmation email
client.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=response.json(),
issuer=self.context["request"].user,
recipient=mailbox.secondary_email, mailbox_data=response.json()
)
# actually save mailbox on our database
@@ -69,7 +57,6 @@ class MailDomainSerializer(serializers.ModelSerializer):
abilities = serializers.SerializerMethodField(read_only=True)
count_mailboxes = serializers.SerializerMethodField(read_only=True)
action_required_details = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomain
@@ -83,10 +70,6 @@ class MailDomainSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"count_mailboxes",
"support_email",
"last_check_details",
"action_required_details",
"expected_config",
]
read_only_fields = [
"id",
@@ -96,24 +79,8 @@ class MailDomainSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"count_mailboxes",
"last_check_details",
"action_required_details",
"expected_config",
]
def get_action_required_details(self, domain) -> dict:
"""Return last check details of the domain."""
details = {}
if domain.last_check_details:
for check, value in domain.last_check_details.items():
if (
isinstance(value, dict)
and value.get("ok") is False
and value.get("internal") is False
):
details[check] = value["errors"][0].get("detail")
return details
def get_abilities(self, domain) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
@@ -132,18 +99,9 @@ class MailDomainSerializer(serializers.ModelSerializer):
# send new domain request to dimail
client = DimailAPIClient()
client.create_domain(validated_data["name"], self.context["request"].user.sub)
domain = super().create(validated_data)
# check domain status and update it
try:
client.fetch_domain_status(domain)
client.fetch_domain_expected_config(domain)
except HTTPError as e:
logger.exception(
"[DIMAIL] domain status fetch after creation failed %s with error %s",
domain.name,
e,
)
return domain
# no exception raised ? Then actually save domain on our database
return super().create(validated_data)
class MailDomainAccessSerializer(serializers.ModelSerializer):
@@ -269,35 +227,3 @@ class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer):
"role",
"can_set_role_to",
]
class MailDomainInvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
class Meta:
model = models.MailDomainInvitation
fields = ["id", "created_at", "email", "domain", "role", "issuer", "is_expired"]
read_only_fields = ["id", "created_at", "domain", "issuer", "is_expired"]
def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""
request = self.context.get("request")
user = getattr(request, "user", None)
try:
domain_slug = self.context["domain_slug"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a domain slug in kwargs to create a new domain management invitation."
) 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
@@ -33,11 +33,7 @@ class MailDomainViewSet(
POST /api/<version>/mail-domains/ with expected data:
- name: str
- support_email: str
Return newly created domain
POST /api/<version>/mail-domains/<domain-slug>/fetch/
Fetch domain status and expected config from dimail.
"""
permission_classes = [permissions.AccessPermission]
@@ -49,7 +45,7 @@ class MailDomainViewSet(
queryset = models.MailDomain.objects.all()
def get_queryset(self):
"""Restrict results to the current user's domain."""
"""Restrict results to the current user's team."""
return self.queryset.filter(accesses__user=self.request.user)
def perform_create(self, serializer):
@@ -64,17 +60,6 @@ class MailDomainViewSet(
}
)
@action(detail=True, methods=["post"], url_path="fetch")
def fetch_from_dimail(self, request, *args, **kwargs):
"""Fetch domain status and expected config from dimail."""
domain = self.get_object()
client = DimailAPIClient()
client.fetch_domain_status(domain)
client.fetch_domain_expected_config(domain)
return Response(
serializers.MailDomainSerializer(domain, context={"request": request}).data
)
# pylint: disable=too-many-ancestors
class MailDomainAccessViewSet(
@@ -292,66 +277,3 @@ class MailBoxViewSet(
mailbox.status = enums.MailboxStatusChoices.ENABLED
mailbox.save()
return Response(serializers.MailboxSerializer(mailbox).data)
class MailDomainInvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to domain management.
GET /api/<version>/mail-domains/<domain_slug>/invitations/:<invitation_id>/
Return list of invitations related to that domain or one
domain access if an id is provided.
POST /api/<version>/mail-domains/<domain_slug>/invitations/ with expected data:
- email: str
- role: str [owner|admin|member]
- issuer : User, automatically added from user making query, if allowed
- domain : Domain, automatically added from requested URI
Return a newly created invitation
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
"""
lookup_field = "id"
permission_classes = [permissions.AccessPermission]
queryset = (
models.MailDomainInvitation.objects.all()
.select_related("domain")
.order_by("-created_at")
)
serializer_class = serializers.MailDomainInvitationSerializer
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 = (
# The logged-in user should be part of a domain to see its accesses
queryset.filter(
domain__accesses__user=self.request.user,
)
# 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
-17
View File
@@ -1,18 +1 @@
"""People additionnal application, to manage email adresses."""
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class MailboxManagerConfig(AppConfig):
"""Configuration class for the Mailbox manager app."""
name = "mailbox_manager"
verbose_name = _("Mailbox manager")
app_label = "mailbox_manager"
def ready(self):
"""
Import signals when the app is ready.
"""
import mailbox_manager.signals # pylint: disable=import-outside-toplevel, unused-import
+1 -18
View File
@@ -2,7 +2,6 @@
Mailbox manager application factories
"""
from django.contrib.auth.hashers import make_password
from django.utils.text import slugify
import factory.fuzzy
@@ -26,9 +25,8 @@ class MailDomainFactory(factory.django.DjangoModelFactory):
django_get_or_create = ("name",)
skip_postgeneration_save = True
name = factory.Sequence(lambda n: f"domain{n!s}.com")
name = factory.Faker("domain_name")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
support_email = factory.Faker("email")
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -77,24 +75,9 @@ class MailboxFactory(factory.django.DjangoModelFactory):
)
domain = factory.SubFactory(MailDomainEnabledFactory)
secondary_email = factory.Faker("email")
password = make_password("password")
class MailboxEnabledFactory(MailboxFactory):
"""A factory to create mailbox enabled."""
status = enums.MailboxStatusChoices.ENABLED
class MailDomainInvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
class Meta:
model = models.MailDomainInvitation
domain = factory.SubFactory(MailDomainEnabledFactory)
email = factory.Faker("email")
role = factory.fuzzy.FuzzyChoice(
[role[0] for role in enums.MailDomainRoleChoices.choices]
)
issuer = factory.SubFactory(core_factories.UserFactory)
@@ -48,7 +48,7 @@ class Command(BaseCommand):
# protected behind admin rights but dimail allows to create a first user
# when database is empty
self.create_user(
auth=("", ""),
auth=(None, None),
name=admin["username"],
password=admin["password"],
perms=[],
@@ -66,11 +66,7 @@ class Command(BaseCommand):
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
domain = MailDomain.objects.get_or_create(
name=domain_name,
defaults={
"status": enums.MailDomainStatusChoices.ENABLED,
"support_email": f"support@{domain_name}",
},
name=domain_name, defaults={"status": enums.MailDomainStatusChoices.ENABLED}
)[0]
self.create_domain(domain_name)
@@ -1,31 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-02 21:49
from django.db import migrations, models
def fill_support_email(apps, schema_editor):
MailDomain = apps.get_model('mailbox_manager', 'MailDomain')
for domain in MailDomain.objects.filter(support_email__isnull=True):
domain.support_email = "support-regie@numerique.gouv.fr"
domain.save()
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0017_alter_maildomain_status'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='support_email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='support email'),
),
migrations.RunPython(fill_support_email, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='maildomain',
name='support_email',
field=models.EmailField(max_length=254, verbose_name='support email'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-07 20:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0018_maildomain_support_email'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='last_check_details',
field=models.JSONField(blank=True, help_text='A JSON object containing the last health check details', null=True, verbose_name='last check details'),
),
]
@@ -1,25 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-14 17:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0019_maildomain_last_check_details'),
]
operations = [
migrations.AlterModelOptions(
name='mailbox',
options={'ordering': ['-created_at'], 'verbose_name': 'Mailbox', 'verbose_name_plural': 'Mailboxes'},
),
migrations.AlterModelOptions(
name='maildomain',
options={'ordering': ['-created_at'], 'verbose_name': 'Mail domain', 'verbose_name_plural': 'Mail domains'},
),
migrations.AlterModelOptions(
name='maildomainaccess',
options={'ordering': ['-created_at'], 'verbose_name': 'User/mail domain relation', 'verbose_name_plural': 'User/mail domain relations'},
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-14 17:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0020_alter_mailbox_options_alter_maildomain_options_and_more'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='expected_config',
field=models.JSONField(blank=True, help_text='A JSON object containing the expected config', null=True, verbose_name='expected config'),
),
]
@@ -1,20 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-10 11:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_team_depth_team_numchild_team_path_and_more'),
('mailbox_manager', '0021_maildomain_expected_config'),
]
operations = [
migrations.AddField(
model_name='maildomain',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='mail_domains', to='core.organization'),
),
]
@@ -1,52 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-07 16:13
import django.db.models.functions.text
from django.db import migrations, models
def fill_dn_email(apps, schema_editor):
"""Fill the dn_email field with the email field."""
Mailbox = apps.get_model("mailbox_manager", "Mailbox")
for mailbox in Mailbox.objects.select_related("domain").filter(
models.Q(dn_email="") | models.Q(dn_email__isnull=True)
):
# quite naive but we don't have many data
mailbox.dn_email = f"{mailbox.local_part}@{mailbox.domain.name}"
mailbox.save()
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0022_maildomain_organization'),
]
operations = [
migrations.AddField(
model_name='mailbox',
name='last_login',
field=models.DateTimeField(blank=True, null=True, verbose_name='last login'),
),
migrations.AddField(
model_name='mailbox',
name='password',
field=models.CharField(default='', max_length=128, verbose_name='password'),
preserve_default=False,
),
migrations.AddField(
model_name='mailbox',
name='dn_email',
field=models.EmailField(blank=True, editable=False, max_length=254, null=True, unique=True,
verbose_name='email'),
),
migrations.RunPython(
code=fill_dn_email,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='mailbox',
name='dn_email',
field=models.EmailField(blank=True, editable=False, max_length=254, unique=True, verbose_name='email'),
),
]
@@ -1,35 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-07 17:27
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0023_mailbox_email_mailbox_last_login_mailbox_password'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MailDomainInvitation',
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')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('viewer', 'Viewer'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='viewer', max_length=20)),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mail_domain_invitations', to='mailbox_manager.maildomain')),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mail_domain_invitations', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Mail domain invitation',
'verbose_name_plural': 'Mail domain invitations',
'db_table': 'people_mail_domain_invitation',
'constraints': [models.UniqueConstraint(fields=('email', 'domain'), name='email_and_domain_unique_together')],
},
),
]
+2 -128
View File
@@ -3,14 +3,12 @@ Declare and configure the models for the People additional application : mailbox
"""
from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import exceptions, validators
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from core.models import BaseInvitation, BaseModel, Organization, User
from core.models import BaseModel
from mailbox_manager.enums import (
MailboxStatusChoices,
@@ -25,38 +23,17 @@ class MailDomain(BaseModel):
name = models.CharField(
_("name"), max_length=150, null=False, blank=False, unique=True
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="mail_domains",
null=True,
blank=True,
)
slug = models.SlugField(null=False, blank=False, unique=True, max_length=80)
status = models.CharField(
max_length=20,
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
support_email = models.EmailField(_("support email"), null=False, blank=False)
last_check_details = models.JSONField(
null=True,
blank=True,
verbose_name=_("last check details"),
help_text=_("A JSON object containing the last health check details"),
)
expected_config = models.JSONField(
null=True,
blank=True,
verbose_name=_("expected config"),
help_text=_("A JSON object containing the expected config"),
)
class Meta:
db_table = "people_mail_domain"
verbose_name = _("Mail domain")
verbose_name_plural = _("Mail domains")
ordering = ["-created_at"]
def __str__(self):
return self.name
@@ -96,14 +73,6 @@ class MailDomain(BaseModel):
"manage_accesses": is_owner_or_admin,
}
def is_identity_provider_ready(self) -> bool:
"""
Check if the identity provider is ready to manage the domain.
"""
return (
bool(self.organization) and self.status == MailDomainStatusChoices.ENABLED
)
class MailDomainAccess(BaseModel):
"""Allow to manage users' accesses to mail domains."""
@@ -133,7 +102,6 @@ class MailDomainAccess(BaseModel):
verbose_name = _("User/mail domain relation")
verbose_name_plural = _("User/mail domain relations")
unique_together = ("user", "domain")
ordering = ["-created_at"]
def __str__(self):
return f"Access of user {self.user} on domain {self.domain}."
@@ -198,7 +166,7 @@ class MailDomainAccess(BaseModel):
}
class Mailbox(AbstractBaseUser, BaseModel):
class Mailbox(BaseModel):
"""Mailboxes for users from mail domain."""
first_name = models.CharField(max_length=200, blank=False)
@@ -226,19 +194,11 @@ class Mailbox(AbstractBaseUser, BaseModel):
default=MailboxStatusChoices.PENDING,
)
# Store the denormalized email address to allow Django admin to work (USERNAME_FIELD)
# This field *must* not be used for authentication (or anything sensitive),
# use the `local_part` and `domain__name` fields
dn_email = models.EmailField(_("email"), blank=True, unique=True, editable=False)
USERNAME_FIELD = "dn_email"
class Meta:
db_table = "people_mail_box"
verbose_name = _("Mailbox")
verbose_name_plural = _("Mailboxes")
unique_together = ("local_part", "domain")
ordering = ["-created_at"]
def __str__(self):
return f"{self.local_part!s}@{self.domain.name:s}"
@@ -258,95 +218,9 @@ class Mailbox(AbstractBaseUser, BaseModel):
Override save function to not allow to create or update mailbox of a disabled domain.
"""
self.full_clean()
self.dn_email = self.get_email()
if self.domain.status == MailDomainStatusChoices.DISABLED:
raise exceptions.ValidationError(
_("You can't create or update a mailbox for a disabled domain.")
)
return super().save(*args, **kwargs)
@property
def is_active(self):
"""Return True if the mailbox is enabled."""
return self.status == MailboxStatusChoices.ENABLED
def get_email(self):
"""Return the email address of the mailbox."""
return f"{self.local_part}@{self.domain.name}"
class MailDomainInvitation(BaseInvitation):
"""User invitation to mail domains."""
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="mail_domain_invitations",
)
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
related_name="mail_domain_invitations",
)
role = models.CharField(
max_length=20,
choices=MailDomainRoleChoices.choices,
default=MailDomainRoleChoices.VIEWER,
)
MAIL_TEMPLATE_HTML = "mail/html/maildomain_invitation.html"
MAIL_TEMPLATE_TXT = "mail/text/maildomain_invitation.txt"
class Meta:
db_table = "people_mail_domain_invitation"
verbose_name = _("Mail domain invitation")
verbose_name_plural = _("Mail domain invitations")
constraints = [
models.UniqueConstraint(
fields=["email", "domain"], name="email_and_domain_unique_together"
)
]
def __str__(self):
return f"{self.email} invited to {self.domain}"
def _get_mail_subject(self):
"""Get the subject of the invitation."""
return gettext("[La Suite] You have been invited to join La Régie")
def _get_mail_context(self):
"""Get the template variables for the invitation."""
return {
**super()._get_mail_context(),
"domain": self.domain.name,
"role": self.get_role_display(),
}
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
role = None
if user.is_authenticated:
try:
role = self.user_role
except AttributeError:
try:
role = self.domain.accesses.filter(user=user).values("role")[0][
"role"
]
except (self._meta.model.DoesNotExist, IndexError):
role = None
can_delete = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
return {
"delete": can_delete,
"get": bool(role),
"patch": False,
"put": False,
}
-67
View File
@@ -1,67 +0,0 @@
"""
Signals module for the mailbox_manager app.
"""
import logging
from datetime import timedelta
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from core.models import User
from mailbox_manager import enums
from mailbox_manager.models import MailDomainAccess, MailDomainInvitation
from mailbox_manager.utils.dimail import DimailAPIClient
logger = logging.getLogger(__name__)
@receiver(post_save, sender=User)
def convert_domain_invitations(sender, created, instance, **kwargs): # pylint: disable=unused-argument
"""
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,
created_at__gte=(
timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
),
)
if not valid_domain_invitations.exists():
return
MailDomainAccess.objects.bulk_create(
[
MailDomainAccess(
user=instance, domain=invitation.domain, role=invitation.role
)
for invitation in valid_domain_invitations
]
)
management_role = set(valid_domain_invitations.values_list("role", flat="True"))
if (
enums.MailDomainRoleChoices.OWNER in management_role
or enums.MailDomainRoleChoices.ADMIN in management_role
):
# Sync with dimail
dimail = DimailAPIClient()
dimail.create_user(instance.sub)
for invitation in valid_domain_invitations:
if invitation.role in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
dimail.create_allow(instance.sub, invitation.domain.name)
valid_domain_invitations.delete()
logger.info("Invitations converted to domain accesses for user %s", instance)
-65
View File
@@ -1,65 +0,0 @@
"""Mailbox manager tasks."""
import time
import requests
from celery import Celery
from celery.schedules import crontab
from celery.utils.log import get_task_logger
from mailbox_manager import enums
from mailbox_manager.models import MailDomain
from mailbox_manager.utils.dimail import DimailAPIClient
from people.celery_app import app as celery_app
logger = get_task_logger(__name__)
@celery_app.on_after_finalize.connect
def setup_periodic_tasks(sender: Celery, **kwargs):
"""Setup periodic tasks."""
sender.add_periodic_task(
crontab(hour="3", minute="45", day_of_week="1"),
fetch_domains_status_task.s(status=enums.MailDomainStatusChoices.ENABLED),
name="fetch_enabled_domains_every_monday_at_3_45",
serializer="json",
)
sender.add_periodic_task(
crontab(minute="0"), # Run at the start of every hour
fetch_domains_status_task.s(status=enums.MailDomainStatusChoices.PENDING),
name="fetch_pending_domains_every_hour",
serializer="json",
)
sender.add_periodic_task(
crontab(minute="30"), # Run at the 30th minute of every hour
fetch_domains_status_task.s(
status=enums.MailDomainStatusChoices.ACTION_REQUIRED
),
name="fetch_action_required_domains_every_hour",
serializer="json",
)
sender.add_periodic_task(
crontab(minute="45"), # Run at the 45th minute of every hour
fetch_domains_status_task.s(status=enums.MailDomainStatusChoices.FAILED),
name="fetch_failed_domains_every_hour",
serializer="json",
)
@celery_app.task
def fetch_domains_status_task(status: str):
"""Celery task to call dimail to check and update domains status."""
client = DimailAPIClient()
changed_domains = []
for domain in MailDomain.objects.filter(status=status):
old_status = domain.status
# wait 10 seconds between each domain treatment to avoid overloading dimail
time.sleep(10)
try:
client.fetch_domain_status(domain)
except requests.exceptions.HTTPError as err:
logger.error("Failed to fetch status for domain %s: %s", domain.name, err)
else:
if old_status != domain.status:
changed_domains.append(f"{domain.name} ({domain.status})")
return changed_domains
@@ -1,143 +0,0 @@
"""
Tests for MailDomainInvitations API endpoint in People's app mailbox_manager.
Focus on "create" action.
"""
from django.core import mail
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
from mailbox_manager.api.client import serializers
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,
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_domain_invitations__create__authenticated_outsider():
"""Users should not be permitted to send domain management invitations
for a domain they don't manage."""
user = core_factories.UserFactory()
domain = factories.MailDomainEnabledFactory()
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_domain_invitations__admin_should_create_invites(role):
"""Owners and administrators should be able to invite new domain managers."""
user = core_factories.UserFactory(language="fr-fr")
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,
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.subject == "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
"""
Domain viewers should not be able to invite new domain managers.
"""
user = core_factories.UserFactory()
domain = factories.MailDomainEnabledFactory()
factories.MailDomainAccessFactory(
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,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage invitations for this domain."
}
def test_api_domain_invitations__should_not_create_duplicate_invitations():
"""An email should not be invited multiple times to the same domain."""
existing_invitation = factories.MailDomainInvitationFactory()
domain = existing_invitation.domain
# Grant privileged role on the domain to the user
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
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
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
duplicated_invitation,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Mail domain invitation with this Email address and Domain already exists."
]
@@ -1,84 +0,0 @@
"""
Tests for MailDomainInvitations API endpoint in People's app mailbox_manager.
Focus on "list" action.
"""
import time
from django.conf import settings
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_domain_invitations__anonymous_user_should_not_list_invitations():
"""Anonymous users should not be able to list invitations."""
domain = factories.MailDomainEnabledFactory()
response = APIClient().get(f"/api/v1.0/mail-domains/{domain.slug}/invitations/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__domain_managers_should_list_invitations():
"""
Authenticated user should be able to list invitations
in domains they manage, including from other issuers.
"""
auth_user, other_user = core_factories.UserFactory.create_batch(2)
domain = factories.MailDomainEnabledFactory()
factories.MailDomainAccessFactory(
domain=domain, user=auth_user, role=enums.MailDomainRoleChoices.ADMIN
)
factories.MailDomainAccessFactory(
domain=domain, user=other_user, role=enums.MailDomainRoleChoices.OWNER
)
invitation = factories.MailDomainInvitationFactory(
domain=domain, role=enums.MailDomainRoleChoices.ADMIN, issuer=auth_user
)
other_invitations = factories.MailDomainInvitationFactory.create_batch(
2, domain=domain, role=enums.MailDomainRoleChoices.VIEWER, issuer=other_user
)
# expired invitations should be listed too
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.MailDomainInvitationFactory(
domain=domain, role=enums.MailDomainRoleChoices.VIEWER, issuer=auth_user
)
time.sleep(1)
# invitations from other teams should not be listed
other_domain = factories.MailDomainEnabledFactory()
factories.MailDomainInvitationFactory.create_batch(
2, domain=other_domain, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(auth_user)
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 4
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(i.id),
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
"email": str(i.email),
"domain": str(domain.id),
"role": str(i.role),
"issuer": str(i.issuer.id),
"is_expired": i.is_expired,
}
for i in [invitation, *other_invitations, expired_invitation]
],
key=lambda x: x["created_at"],
)
@@ -1,73 +0,0 @@
"""
Tests for MailDomainInvitations API endpoint. Focus on "retrieve" 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_domain_invitations__anonymous_user_should_not_retrieve_invitations():
"""
Anonymous user should not be able to retrieve invitations.
"""
invitation = factories.MailDomainInvitationFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
auth_user = core_factories.UserFactory()
invitation = factories.MailDomainInvitationFactory()
client = APIClient()
client.force_login(auth_user)
response = client.get(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 0
def test_api_domain_invitations__domain_managers_should_list_invitations():
"""
Authenticated domain managers should be able to retrieve invitations
whatever their role in the domain.
"""
auth_user = core_factories.UserFactory()
invitation = factories.MailDomainInvitationFactory()
factories.MailDomainAccessFactory(
domain=invitation.domain, user=auth_user, role=enums.MailDomainRoleChoices.ADMIN
)
client = APIClient()
client.force_login(auth_user)
response = client.get(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
"email": invitation.email,
"domain": str(invitation.domain.id),
"role": str(invitation.role),
"issuer": str(invitation.issuer.id),
"is_expired": False,
}
@@ -2,7 +2,6 @@
Tests for MailDomains API endpoint in People's app mailbox_manager. Focus on "create" action.
"""
import json
import logging
import re
@@ -15,11 +14,6 @@ from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
from mailbox_manager.tests.fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
)
pytestmark = pytest.mark.django_db
@@ -58,7 +52,6 @@ def test_api_mail_domains__create_name_unique():
assert response.json()["name"] == ["Mail domain with this name already exists."]
@responses.activate
def test_api_mail_domains__create_authenticated():
"""
Authenticated users should be able to create mail domains
@@ -71,64 +64,44 @@ def test_api_mail_domains__create_authenticated():
domain_name = "test.domain.fr"
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(body_content_domain1),
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"context": "null",
"features": ["webmail"],
"support_email": f"support@{domain_name}",
},
format="json",
)
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{"name": domain_name, "context": "null", "features": ["webmail"]},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
domain = models.MailDomain.objects.get()
@@ -137,43 +110,25 @@ def test_api_mail_domains__create_authenticated():
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": enums.MailDomainStatusChoices.ACTION_REQUIRED,
"status": enums.MailDomainStatusChoices.PENDING,
"created_at": domain.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 0,
"support_email": domain.support_email,
"last_check_details": body_content_domain1,
"action_required_details": {
"cname_imap": "Il faut un CNAME 'imap.example.fr' qui renvoie vers "
"'imap.ox.numerique.gouv.fr.'",
"cname_smtp": "Le CNAME pour 'smtp.example.fr' n'est pas bon, "
"il renvoie vers 'ns0.ovh.net.' et je veux 'smtp.ox.numerique.gouv.fr.'",
"cname_webmail": "Il faut un CNAME 'webmail.example.fr' qui "
"renvoie vers 'webmail.ox.numerique.gouv.fr.'",
"dkim": "Il faut un DKIM record, avec la bonne clef",
"mx": "Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr., "
"or je trouve example-fr.mail.protection.outlook.com.",
"spf": "Le SPF record ne contient pas include:_spf.ox.numerique.gouv.fr",
},
"expected_config": DOMAIN_SPEC,
}
# a new domain with status "action required" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain.last_check_details == body_content_domain1
# a new domain with status "pending" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.PENDING
assert domain.name == domain_name
assert domain.accesses.filter(role="owner", user=user).exists()
@responses.activate
def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
def test_api_mail_domains__create_authenticated__dimail_failure():
"""
Despite a dimail failure for user and/or allow creation,
an authenticated user should be able to create a mail domain
and should automatically be added as owner of the newly created domain.
"""
caplog.set_level(logging.ERROR)
user = core_factories.UserFactory()
client = APIClient()
@@ -181,60 +136,45 @@ def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
domain_name = "test.domain.fr"
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
dimail_error = {
"status_code": status.HTTP_401_UNAUTHORIZED,
"detail": "Not authorized",
}
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(dimail_error),
status=dimail_error["status_code"],
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"context": "null",
"features": ["webmail"],
"support_email": f"support@{domain_name}",
},
format="json",
)
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_403_FORBIDDEN,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{"name": domain_name, "context": "null", "features": ["webmail"]},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
domain = models.MailDomain.objects.get()
# response is as expected
@@ -247,22 +187,15 @@ def test_api_mail_domains__create_authenticated__dimail_failure(caplog):
"updated_at": domain.updated_at.isoformat().replace("+00:00", "Z"),
"abilities": domain.get_abilities(user),
"count_mailboxes": 0,
"support_email": domain.support_email,
"last_check_details": None,
"action_required_details": {},
"expected_config": None,
}
# a new domain with status "failed" is created and authenticated user is the owner
assert domain.status == enums.MailDomainStatusChoices.FAILED
assert domain.name == domain_name
assert domain.accesses.filter(role="owner", user=user).exists()
assert caplog.records[0].levelname == "ERROR"
assert "Not authorized" in caplog.records[0].message
## SYNC TO DIMAIL
@responses.activate
def test_api_mail_domains__create_dimail_domain(caplog):
"""
Creating a domain should trigger a call to create a domain on dimail too.
@@ -274,80 +207,66 @@ def test_api_mail_domains__create_dimail_domain(caplog):
client.force_login(user)
domain_name = "test.fr"
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str(
with responses.RequestsMock() as rsps:
rsp = rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(body_content_domain1),
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain_name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"support_email": f"support@{domain_name}",
},
format="json",
)
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert rsp.call_count == 1 # endpoint was called
# Logger
log_messages = [msg.message for msg in caplog.records]
assert len(caplog.records) == 4 # should be 3. Last empty info still here.
assert (
f"Domain {domain_name} successfully created on dimail by user {user.sub}"
in log_messages
caplog.records[0].message
== f"Domain {domain_name} successfully created on dimail by user {user.sub}"
)
assert f'[DIMAIL] User "{user.sub}" successfully created on dimail' in log_messages
assert (
f'[DIMAIL] Permissions granted for user "{user.sub}" on domain {domain_name}.'
in log_messages
caplog.records[1].message
== f'[DIMAIL] User "{user.sub}" successfully created on dimail'
)
assert (
caplog.records[2].message
== f'[DIMAIL] Permissions granted for user "{user.sub}" on domain {domain_name}.'
)
@responses.activate
def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"""No domain should be created when dimail returns a 409 conflict."""
user = core_factories.UserFactory()
@@ -359,47 +278,27 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"status_code": status.HTTP_409_CONFLICT,
"detail": "Domain already exists",
}
responses.add(
responses.POST,
re.compile(r".*/users/"),
body=str(
{
"name": "request-user-sub",
"is_admin": "false",
"uuid": "user-uuid-on-dimail",
"perms": [],
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/allows/"),
body=str({"user": "request-user-sub", "domain": str(domain_name)}),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(r".*/domains/"),
body=str({"detail": dimail_error["detail"]}),
status=dimail_error["status_code"],
content_type="application/json",
)
with pytest.raises(HTTPError):
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
"support_email": f"support@{domain_name}",
},
format="json",
with responses.RequestsMock() as rsps:
rsp = rsps.add(
rsps.POST,
re.compile(r".*/domains/"),
body=str({"detail": dimail_error["detail"]}),
status=dimail_error["status_code"],
content_type="application/json",
)
with pytest.raises(HTTPError):
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": domain_name,
},
format="json",
)
assert response.status_code == dimail_error["status_code"]
assert response.json() == {"detail": dimail_error["detail"]}
assert rsp.call_count == 1 # endpoint was called
assert response.status_code == dimail_error["status_code"]
assert response.json() == {"detail": dimail_error["detail"]}
# check logs
record = caplog.records[0]

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