Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7522569ce0 |
@@ -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! 👌
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
@@ -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') }}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,9 +58,6 @@ src/frontend/tsclient
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Celery beat
|
||||
src/backend/celerybeat-schedule
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
coverage.json
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
+21
-93
@@ -8,70 +8,6 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.14.1] - 2025-03-17
|
||||
|
||||
## [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
|
||||
@@ -81,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
|
||||
@@ -341,31 +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.1...main
|
||||
[1.14.1]: https://github.com/suitenumerique/people/releases/v1.14.1
|
||||
[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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -10,7 +10,7 @@ PYTHONPATH=/app
|
||||
# People settings
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST="maildev"
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
|
||||
# Backend url
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
ORGANIZATION_PLUGINS=plugins.organizations.NameFromSiretOrganizationPlugin,plugins.organizations.CommuneCreation
|
||||
+1
-1
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,4 +1,3 @@
|
||||
# pylint: disable=too-many-ancestors
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
@@ -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 +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
@@ -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 don’t 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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
-50
@@ -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 +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
|
||||
@@ -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
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
-25
@@ -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'),
|
||||
),
|
||||
]
|
||||
-52
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
-143
@@ -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"],
|
||||
)
|
||||
-73
@@ -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,
|
||||
}
|
||||
+147
-248
@@ -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
Reference in New Issue
Block a user