Compare commits

...

97 Commits

Author SHA1 Message Date
Eléonore Voisin 7522569ce0 Try to fix test 2025-02-07 10:05:03 +01:00
Quentin BEY 351c696ef8 🐛(tilt) fix the dev-keycloak configuration
The mailcatcher configuration was missing from the
configuration file when using keycloak.
2025-02-06 10:38:46 +01:00
Sabrina Demagny 579dbdee10 (api) add count mailboxes to MailDomain serializer
Return number of mailboxes of a domain in our API.
2025-02-04 15:22:00 +01:00
Quentin BEY b4a877381a 🐛(teams) disable creation endpoint from abilities
When we don't allow the user to see the team creation button,
we also want to disable the corresponding API.
2025-02-04 15:20:48 +01:00
Quentin BEY 92753082c7 🚑️(teams) hide display add button when disallowed
The frontend should not display "add team" actions when the
user has not the ability.
2025-02-04 15:20:48 +01:00
Sabrina Demagny 4df4172151 (dimail) manage 'action required' status for MailDomain
Adapt fetch domain status call to manage internal and external
fixes required. Use the new status 'action required' to
manage actions expected from support.
Call a new dimail endpoint to run a fix for internal checks
when all external checks are OK.
2025-02-04 14:13:36 +01:00
Laurent Bossavit e7af1fd591 🐛(frontend) improve e2e tests avoiding race condition from mocks
Reorder mock setup with page.route so that it takes place before nav.
2025-02-04 14:03:35 +01:00
Sabrina Demagny 8a2b0d0a76 🐛(backend) fix flaky test after add fr translations
Define user language to test invitation email content.
2025-02-03 15:24:39 +01:00
Sabrina Demagny d48a3ff677 (domains) add action required status on MailDomain
Create a new domain status to handle cases where
action is required from an external domain owner
for a domain to be fully functional.
2025-02-03 15:24:32 +01:00
renovate[bot] 4fb5d87df9 ⬆️(dependencies) update python dependencies 2025-02-03 14:30:56 +01:00
renovate[bot] 6274764f90 ⬆️(dependencies) update js dependencies 2025-02-03 14:18:33 +01:00
Marie PUPO JEAMMET 7f0e231474 (api) restrict mailbox sync to enabled domains
Pending, failed and deactivated domains should not be sync'ed.
2025-02-03 13:47:02 +01:00
Marie PUPO JEAMMET 2c15609c1e 🎨(dimail) factorize dimail response upon successful mailbox creation
dimail's ok response upon mailbox creation is used in several tests.
All those tests now reference proper response available in fixtures.
2025-02-03 13:47:02 +01:00
Marie PUPO JEAMMET cd94dc5091 (dimail) send pending mailboxes upon domain activation
send creation requests to dimail for all pending mailboxes
when domain goes from "pending" to "enabled".
2025-02-03 13:47:02 +01:00
Sabrina Demagny 9d9216cf39 🌐(backend) add missing translations
Regenerate translations, translate some and
replace "Desk" and "Régie" to "La Régie".
2025-02-03 13:24:40 +01:00
Quentin BEY 7dd9eae5d9 💚(argocd) humble try to fix the webhook call
This is an attempt to fix:
`Webhook processing failed: HMAC verification failed`
2025-02-03 13:12:42 +01:00
Quentin BEY 914319c366 💚(argocd) fix deployment command
The command should use organization variables.
2025-02-03 12:48:50 +01:00
Quentin BEY c34ad00fae 💚(docker) fix docker login command
Use the secret from github organization.
2025-02-03 12:35:12 +01:00
Quentin BEY 289879962b 🧑‍💻(tilt) add mailcatcher to the stack
This allows to start a mailcatcher for local developments.
2025-02-03 12:21:34 +01:00
Quentin BEY cd88799943 💚(github) remove secret fetch
The secrets are not managed in the folder anymore.
2025-01-30 15:55:58 +01:00
Quentin BEY 4011c8e8ed 🚑️(plugins) fix name from SIRET specific case
For some SIRET, there are no "liste_enseignes" (null), in such
cases we fallback on the global "company" name.
2025-01-30 15:55:58 +01:00
Sabrina Demagny b98281fa72 🔖(patch) release version 1.10.1
Update all version files and changelog for patch release.
2025-01-27 15:11:01 +01:00
Sabrina Demagny 04bd154bad (scripts) improve release script
One of instructions was in a wrong place.
Release tag on github belongs to the project part.
2025-01-27 14:45:58 +01:00
Marie PUPO JEAMMET 227ecd0700 🐛(dimail) fix imported mailboxes should be enabled instead of pending
Importing mailboxes creates pending mailboxes ... but these mailboxes are
already active and, for most, functional. We thus mark them as "enabled".
2025-01-27 11:23:26 +01:00
renovate[bot] 6d618b4aff ⬆️(dependencies) update python dependencies 2025-01-27 11:09:38 +01:00
renovate[bot] 3a2b2b8a53 ⬆️(dependencies) update js dependencies 2025-01-27 09:33:51 +01:00
Sabrina Demagny f969b118cb (dimail) management command to fetch domain status
Add a management command for a future cron to
to check and update regularly domains status
from dimail.
2025-01-24 19:33:45 +01:00
Sabrina Demagny c48957612b ️(api) add missing cache for stats endpoint
Add cache to display stats for anonymous
and improve some code.
2025-01-24 16:52:02 +01:00
Sabrina Demagny e09e1d1b80 (scripts) adapts release script
Adapts the release script after moving
the deployment part to a new repository.
2025-01-23 18:14:00 +01:00
Marie PUPO JEAMMET 615d5894c9 🔖(minor) release version 1.10.0
Update all version files and changelog for minor release.
heml/env.d/preprod and prod directories are now on another repo.
2025-01-21 17:18:55 +01:00
Laurent Bossavit 914554e45c 🐛(linting) fix linting errors arising from Ruff update
This update affects assertion messages in particular.
2025-01-21 00:42:47 +01:00
renovate[bot] 3f990e93b7 ⬆️(dependencies) update python dependencies 2025-01-21 00:42:47 +01:00
Jacques ROUSSEL 9de20a496e 🐛(ci) fix argocd webhook to auto deploy on staging
Changing the deployment repository broke the automatic deployment of the
main branch. This commit fixes it.
2025-01-20 17:42:24 +01:00
Marie PUPO JEAMMET 870ef424f5 (api) create stats endpoint
create stats endpoint to expose public metrics
2025-01-20 17:32:25 +01:00
renovate[bot] 0e92c1cafa ⬆️(dependencies) update js dependencies 2025-01-20 09:56:30 +01:00
Sabrina Demagny defc18ea40 🐛(backend) fix flaky test with search contact
Sometimes emails generated by faker in data field match search.
So it's necessary to create contacts with empty data field
to search contacts by full_name in tests.
2025-01-17 19:56:35 +01:00
Quentin BEY 4ccea4655b (teams) add treebeard data to serializers
This will allow the frontend to represent teams as a
tree if needed.
2025-01-17 19:00:14 +01:00
Quentin BEY 45fd10fd2d (teams) return parent teams in resource server
Also return the parent teams in the user's team endpoints.
2025-01-17 19:00:14 +01:00
Quentin BEY 201864db3a (teams) return parent teams in API
Also return the parent teams in the user's team endpoints.

This is a first implementation which returns a flat list
of teams (not a tree). This is not really helpful, but
it allows to create hierarchical teams manually (via
admin) if an organization needs it.
2025-01-17 19:00:14 +01:00
Quentin BEY 182f9c1d17 🗃️(teams) add Team dependencies as a tree
This provides the technical way to create Team trees.
The implementation is quite naive.
2025-01-17 19:00:14 +01:00
Quentin BEY cff3d5c123 🐛(tilt) add missing file after previous commit
My previous PR was merged to quickly, I forgot to add
the file to create the secret for Tilt.
2025-01-17 18:11:51 +01:00
Quentin BEY 32a576bbe9 🧑‍💻(tilt) add sync dimail from people btn
Add a button to force sync of dimail accounts from
the people database.
2025-01-17 17:53:14 +01:00
Quentin BEY 010d3674de 🧑‍💻(tilt) add dimail
This adds dimail to the tilt kube deployment
2025-01-17 17:53:14 +01:00
Jacques ROUSSEL 80976e3761 👷(helm) add CI for publishing Helm charts
We need to publish a Helm chart to facilitate separating the code from
the deployment configuration.
2025-01-17 15:26:38 +01:00
Jacques ROUSSEL b848f9eca6 ♻️(dev) refacto tilt stack
To be able to move the repository on the new organization and to
facilitate external developer integration we need to create a standalone
dev stack and use external secret.
2025-01-17 15:26:38 +01:00
Sabrina Demagny cd7135da00 🐛(backend) fix flaky test with team access #646
Faker sometimes creates users whose name starts with "Ms." or "Mr."
The implemented test code computed the order without handling
these cases and failed.
2025-01-17 10:02:27 +01:00
Sabrina Demagny 0a795f6e6f 🧑‍💻(dimail) remove 'NoneType: None' log
Fixes "NoneType: None" log appearing in debug mode.
2025-01-17 08:56:18 +01:00
Sabrina Demagny 6c8329405d 🧑‍💻(setup_dimail_db) create missing access
Add missing John Doe access role on domain test.domain.com
2025-01-17 08:42:10 +01:00
Sabrina Demagny ea3a45ea87 (dimail) improve fetch domain status tests
Add missing test case and add a new fake data
for fetch domain status from dimail
2025-01-16 23:07:14 +01:00
Sabrina Demagny 86451df8b4 ♻️(tests) reorganization of test files
Create two new folders in mailbox_manager tests
2025-01-16 15:57:25 +01:00
Quentin BEY 76fc789eb6 (organization) add admin action for plugin
This allows admin user to run the post creation plugins
from the organization list.
2025-01-16 09:28:38 +01:00
renovate[bot] 632a78c339 ⬆️(dependencies) update django to v5.1.5 [SECURITY] 2025-01-15 10:21:07 +01:00
Laurent Bossavit 20cc173e93 (anct) fetch and display organization names of communes
ANCT-specific extraction of organization names for communes, front
end changes to match.
2025-01-13 15:01:54 +01:00
Marie PUPO JEAMMET d495ef3e19 🧑‍💻(admin) add read-only fields to mailbox admin
mark local_part and domain as read-only fields in admin,
in order to prevent mistakes/temptation. For now, if an local part
needs modification, you can simply delete/recreate the email you want.
Changing the domain is a bigger operation that cannot
be settled simply by changing it Django db.
2025-01-13 08:53:57 +01:00
Sabrina Demagny 4def80214c (frontend) display email if no username
Fallback to email if no username is defined for logged user.
2025-01-12 00:45:49 +01:00
Sabrina Demagny 2428229dbb 🐛(frontend) fix flaky e2e test
Small hack to fix clicking on member action button.
Action button of the third member in the list could not
be clickable every time.
The member list table needs and will be improved soon.
2025-01-12 00:32:56 +01:00
Sabrina Demagny 9b9aa2aa37 🐛(frontend) fix disable mailbox button display
Do not display disable/enable mailbox button
for viewers.
Only owner and admin can manage mailbox and use
this button.
2025-01-10 16:38:07 +01:00
renovate[bot] a28bb5e2a2 ⬆️(dependencies) update js dependencies (#591)
* ⬆️(dependencies) update js dependencies

* ⬆️(dependencies) fixes for React 19

* ⬆️(dependencies) unit test fixes for React 19

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Bossavit <laurent@MacBook-Pro-de-Laurent.local>
2025-01-10 15:25:38 +01:00
Sabrina Demagny 6962953625 🧑‍💻(admin) add filter for domain
Allow to filter domain by status
2025-01-09 14:12:18 +01:00
Sabrina Demagny b0b718e657 (backend) add admin action to check domain health
Allow to select some domains to check and update status
thanks to a dimail call.
2025-01-09 13:59:00 +01:00
Sabrina Demagny 0abfd49fee (dimail) check domain health
Call dimail to check if a domain still works.
Turn domain into failure status if dimail returns broken state.
And enable domain if dimail returns ok state.
2025-01-09 12:55:23 +01:00
Marie PUPO JEAMMET 5d2e63fc18 🐛(tests) fix flaky back-end test
fixed a test that would fail because of random auth user name being
too close to tested names (assert would find 2 results instead of 1).
Setting the name of auth user should fix the issue permanently.
2025-01-09 10:10:49 +01:00
Marie PUPO JEAMMET 94a443525e 🧑‍💻(admin) add filter and search function to admin mailboxes view
add fields to easily filter and search mailboxes on their statuses,
local part and domains
2025-01-07 17:38:36 +01:00
renovate[bot] 42d7d00772 ⬆️(dependencies) update python dependencies 2025-01-06 10:13:57 +01:00
renovate[bot] 5ec65b1202 ⬆️(dependencies) update next to v15.1.2 [SECURITY] 2025-01-04 13:04:06 +01:00
renovate[bot] 9ba30843c5 ⬆️(dependencies) update python dependencies 2025-01-03 01:16:57 +01:00
Marie PUPO JEAMMET 4de589a7e8 🧑‍💻(mailboxes) add and improve some tests
Add and improve some tests linked to mailbox creation.
2024-12-26 22:22:27 +01:00
Sabrina Demagny 151f030582 🐛(backend) fix dimail call despite mailbox creation failure on our side
When we try to create a duplicate email, a request
to dimail is sent despite a reject on our side.
To solve this issue, we call mailbox creation first
to benefit from validation of our Django model.

Mailbox creation try was called too late after dimail call.
So in attempt to create a duplicated email a request
to dimail was sent despite a failure in our side.
2024-12-26 19:43:04 +01:00
Sabrina Demagny a58152b9a9 📝(scripts) add missing release instructions
Do not forget to check docker image of frontend.
2024-12-26 13:40:35 +01:00
Laurent Bossavit 8fd55a61c5 (e2e) change accounts to facilitate SIRET and add e2e test
We also add registration ID info to the /me endpoint, via serializers
2024-12-23 20:18:44 +01:00
Laurent Bossavit 2435a59078 🧑‍💻(keycloak) add siret attribute and mapper to Keycloak
We can now find organization data as provided by ProConnect in user_info
2024-12-23 20:18:44 +01:00
renovate[bot] fd2c90f50d ⬆️(dependencies) update python dependencies 2024-12-23 17:22:34 +01:00
Quentin BEY 469014ac41 🧑‍💻(user) fix the User.language
The use of a lazy function here make the Django migration
detector to generate a migration every time we run `makemigrations`.
This is not mandatory to have a lazy here as the settings are loaded
once at runtime beginning.
As the choices makes noop migrations, we directly use the setting in
the initial migration.
2024-12-19 22:16:08 +01:00
Marie PUPO JEAMMET e60bae4321 🔖(patch) release version 1.9.1
Update all version files and changelog for patch release.
2024-12-18 15:48:45 +01:00
Marie 2e7f224e30 ⬆️(dimail) upgrade dimail to v0.1.1
Upgrade dimail containers to v0.1.1
2024-12-18 11:38:10 +01:00
Sabrina Demagny 8865e250a0 🔖(minor) release version 1.9.0
Update all version files and changelog for minor release.
2024-12-17 14:48:38 +01:00
Nathan Panchout fa114b1064 (frontend) disable mailbox and allow to create pending mailbox
Allow to disable or enable mailboxes.
And enable mailbox creation button when domain is pending to
allow creation of mailboxes in pending status.
2024-12-17 14:16:06 +01:00
Marie PUPO JEAMMET 38369a8312 🚚(backend) split users test file to improve readability
"test_api_users" was a single test file of 900+ lines.
We used gitfilesplit to split it into several shorter files
for readability.
2024-12-17 14:00:35 +01:00
Marie PUPO JEAMMET d3940f6d09 Split test_api_users.py into users/test_api_users_retrieve.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 6061a65e44 Split test_api_users.py into users/test_api_users_create.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 4a763396e2 Split test_api_users.py into users/test_api_users_delete.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET fcc63f9b82 Split test_api_users.py into users/test_api_users_update.py 2024-12-17 13:48:18 +01:00
Marie PUPO JEAMMET 0f4db82b02 Split test_api_users.py into users/test_api_users_list.py 2024-12-17 13:48:18 +01:00
renovate[bot] bcdc57905b ⬆️(dependencies) update python dependencies 2024-12-17 12:12:57 +01:00
Marie PUPO JEAMMET 30832a20e1 ⬆️(dimail) upgrade dimail to 0.0.20
upgrade to 0.0.20 to fix weird anonymous admin behaviour when database
not initialized.
2024-12-17 11:48:52 +01:00
Sabrina Demagny a021e9eec6 🐛(maildomain) fix domain access creation
During a new domain creation, a call to dimail is made
to create user/allow on dimail side before owner role creation
on our side.
So when user/allow creation on dimain side fails,
the owner role is not created on our side.
Therefore the domain is created but invisible on the user interface.
The user will probably try to create the same domain again
and see the error message 'this domain already exists'.
To avoid this we make sure to create owner role on our side
despite dimail failure and set domain to failed status to retry
later dimail access creation.
2024-12-17 10:56:44 +01:00
Quentin BEY fa80edfaa8 🔧(helm) add organization name from siret plugin
This declares the use of the `NameFromSiretOrganizationPlugin`
to allow automatic SIRET -> Name guess when creating an
organization
2024-12-16 16:08:08 +01:00
Quentin BEY 38a5f158b5 (organizations) add siret to name conversion
This adds the plugin system to easily manage
Organization related customizations. This first
plugin tries (best effort) to get a proper name
for the Organization, using its SIRET. This
is French specificities but another plugin can
be defined for other cases.
2024-12-16 16:08:08 +01:00
Quentin BEY 6e14c2e61f (e2e) fix flaky test
The URL may or may not include the "page" query parameter.
2024-12-16 15:51:25 +01:00
Quentin BEY dd9a905dc0 📝(tilt) add startup guide to Tilt use
This provides an "how-to" to deploy a local dev
Kubernetes environment.
2024-12-16 12:35:48 +01:00
Quentin BEY 2f380b49d0 🧑‍💻(dimail) allow account populate from DB
Allow the dimail account creation command to create
accounts from the data in people's database.
2024-12-16 12:18:18 +01:00
Quentin BEY 9953dd2111 💄(frontend) redirect home according to abilities
We try to detect the landing page according to user
permissions (abilities) instead of just the configuration
setting.
This will be improved when the homepage is developed
2024-12-16 11:43:46 +01:00
Sabrina Demagny 5d84e226b7 (maildomain_access) add API endpoint to search users
Add new API endpoint to search for new users
to whom we can assign new roles.
2024-12-13 16:27:48 +01:00
Quentin BEY 6fde76fb46 (contacts) add "abilities" to API endpoint data
Returns the possible actions to the frontend using the
model's `get_abilities`.
2024-12-13 11:52:10 +01:00
Quentin BEY 7154a491f4 ♻️(contacts) switch API to get_abilities
Use the common way to define permissions on the API.

Note: we keep here the notion of "public" contacts,
even if the API does not really allows that. The use
case is not clear for that, but we allow contact w/o
owner to be displayed.
2024-12-13 11:52:10 +01:00
Quentin BEY 019ce99a86 (contacts) filter list API with email
This allows to lookup onto emails for the "magic
filter" on the API list endpoint.
2024-12-12 18:22:53 +01:00
Quentin BEY 579aac264e (contacts) list profile contacts from same org
Allow the contact API to list "profile" contacts for
user of the same organization.
2024-12-12 18:22:53 +01:00
157 changed files with 8792 additions and 3897 deletions
+1 -18
View File
@@ -11,26 +11,9 @@ jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
+9 -71
View File
@@ -19,20 +19,9 @@ jobs:
trivy-scan:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -57,36 +46,19 @@ jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-backend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
@@ -108,32 +80,15 @@ jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-frontend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
@@ -144,7 +99,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
@@ -163,29 +118,12 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "people,secrets"
+35
View File
@@ -0,0 +1,35 @@
name: Release Chart
run-name: Release Chart
on:
push:
branches:
- 'main'
paths:
- ./src/helm/desk/**
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with:
charts_dir: ./src/helm
linting: on
token: ${{ secrets.GITHUB_TOKEN }}
+76 -2
View File
@@ -8,12 +8,82 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(api) add count mailboxes to MailDomain serializer
- ✨(dimail) manage 'action required' status for MailDomain
- ✨(domains) add action required status on MailDomain
- ✨(dimail) send pending mailboxes upon domain activation
### Fixed
- 🚑️(teams) do not display add button when disallowed #676
- 🚑️(plugins) fix name from SIRET specific case #674
- 🐛(api) restrict mailbox sync to enabled domains
## [1.10.1] - 2025-01-27
### Added
- ✨(dimail) management command to fetch domain status
### Changed
- ✨(scripts) adapts release script after moving the deployment part
### Fixed
- 🐛(dimail) fix imported mailboxes should be enabled instead of pending #659
- ⚡️(api) add missing cache for stats endpoint
## [1.10.0] - 2025-01-21
### Added
- ✨(api) create stats endpoint
- ✨(teams) add Team dependencies #560
- ✨(organization) add admin action for plugin #640
- ✨(anct) fetch and display organization names of communes #583
- ✨(frontend) display email if no username #562
- 🧑‍💻(oidc) add ability to pull registration ID (e.g. SIRET) from OIDC #577
### Fixed
- 🐛(frontend) improve e2e tests avoiding race condition from mocks #641
- 🐛(backend) fix flaky test with search contact #605
- 🐛(backend) fix flaky test with team access #646
- 🧑‍💻(dimail) remove 'NoneType: None' log in debug mode
- 🐛(frontend) fix flaky e2e test #636
- 🐛(frontend) fix disable mailbox button display #631
- 🐛(backend) fix dimail call despite mailbox creation failure on our side
- 🧑‍💻(user) fix the User.language infinite migration #611
## [1.9.1] - 2024-12-18
## [1.9.0] - 2024-12-17
### Fixed
- 🐛(backend) fix manage roles on domain admin view
### Added
- ✨(backend) add admin action to check domain health
- ✨(dimail) check domain health
- ✨(frontend) disable mailbox and allow to create pending mailbox
- ✨(organizations) add siret to name conversion #584
- 💄(frontend) redirect home according to abilities #588
- ✨(maildomain_access) add API endpoint to search users #508
## [1.8.0] - 2024-12-12
### Added
- ✨(contacts) add "abilities" to API endpoint data #585
- ✨(contacts) allow filter list API with email
- ✨(contacts) return profile contact from same organization
- ✨(dimail) automate allows requests to dimail
- ✨(contacts) add notes & force full_name #565
- ✨(contacts) add notes & force full_name #565
### Changed
@@ -206,7 +276,11 @@ 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/numerique-gouv/people/compare/v1.8.0...main
[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
+46
View File
@@ -354,10 +354,56 @@ start-kind: ## Create the kubernetes cluster
./bin/start-kind.sh
.PHONY: start-kind
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
tilt-up: ## start tilt - k8s local development
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
release: ## helper for release and deployment
python scripts/release.py
.PHONY: release
install-secret: ## install the kubernetes secrets from Vaultwarden
if kubectl -n desk get secrets bitwarden-cli-desk; then \
echo "Secret already present"; \
else \
echo "Please provide the following information:"; \
read -p "Enter your vaultwarden email login: " LOGIN; \
read -p "Enter your vaultwarden password: " PASSWORD; \
read -p "Enter your vaultwarden server url: " URL; \
echo "\nCreate vaultwarden secret"; \
echo "apiVersion: v1" > /tmp/secret.yaml; \
echo "kind: Secret" >> /tmp/secret.yaml; \
echo "metadata:" >> /tmp/secret.yaml; \
echo " name: bitwarden-cli-desk" >> /tmp/secret.yaml; \
echo " namespace: desk" >> /tmp/secret.yaml; \
echo "type: Opaque" >> /tmp/secret.yaml; \
echo "stringData:" >> /tmp/secret.yaml; \
echo " BW_HOST: $$URL" >> /tmp/secret.yaml; \
echo " BW_PASSWORD: $$PASSWORD" >> /tmp/secret.yaml; \
echo " BW_USERNAME: $$LOGIN" >> /tmp/secret.yaml; \
kubectl -n desk apply -f /tmp/secret.yaml;\
rm -f /tmp/secret.yaml; \
fi; \
if kubectl get ns external-secrets; then \
echo "External secret already deployed"; \
else \
helm repo add external-secrets https://charts.external-secrets.io; \
helm upgrade --install external-secrets \
external-secrets/external-secrets \
-n external-secrets \
--create-namespace \
--set installCRDs=true; \
fi
.PHONY: build-k8s-cluster
fetch-domain-status:
@$(MANAGE) fetch_domain_status
.PHONY: fetch-domain-status
+17 -1
View File
@@ -29,7 +29,9 @@ docker_build(
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
# helmfile in docker mount the current working directory and the helmfile.yaml
# requires the keycloak config in another directory
k8s_yaml(local('cd .. && helmfile -n desk -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))
migration = '''
set -eu
@@ -56,3 +58,17 @@ cmd_button('Migrate db',
icon_name='developer_board',
text='Run database migration',
)
# Command to created domain/users/access from people to dimail
populate_dimail_from_people = '''
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 setup_dimail_db --populate-from-people
'''
cmd_button('Populate dimail from people',
argv=['sh', '-c', populate_dimail_from_people],
resource='desk-backend',
icon_name='developer_board',
text='Populate dimail from people',
)
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -o errexit
CURRENT_DIR=$(pwd)
NAMESPACE=${1:-desk}
SECRET_NAME=${2:-bitwarden-cli-desk}
TEMP_SECRET_FILE=$(mktemp)
cleanup() {
rm -f "${TEMP_SECRET_FILE}"
}
trap cleanup EXIT
# Check if kubectl is available
check_prerequisites() {
if ! command -v kubectl &> /dev/null; then
echo "Error: kubectl is not installed or not in PATH"
exit 1
fi
}
# Check if secret already exists
check_secret_exists() {
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
}
# Collect user input securely
get_user_input() {
echo "Please provide the following information:"
read -p "Enter your Vaultwarden email login: " LOGIN
read -s -p "Enter your Vaultwarden password: " PASSWORD
echo
read -p "Enter your Vaultwarden server url: " URL
}
# Create and apply the secret
create_secret() {
cat > "${TEMP_SECRET_FILE}" << EOF
apiVersion: v1
kind: Secret
metadata:
name: ${SECRET_NAME}
namespace: ${NAMESPACE}
type: Opaque
stringData:
BW_HOST: ${URL}
BW_PASSWORD: ${PASSWORD}
BW_USERNAME: ${LOGIN}
EOF
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
}
# Install external-secrets using Helm
install_external_secrets() {
if ! kubectl get ns external-secrets &>/dev/null; then
echo "Installing external-secrets…"
helm repo add external-secrets https://charts.external-secrets.io
helm upgrade --install external-secrets \
external-secrets/external-secrets \
-n external-secrets \
--create-namespace \
--set installCRDs=true
else
echo "External secrets already deployed"
fi
}
main() {
check_prerequisites
if check_secret_exists; then
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
exit 0
fi
echo -e ${TEMP_SECRET_FILE}
get_user_input
echo -e "\nCreating Vaultwarden secret…"
create_secret
install_external_secrets
echo "Secret installation completed successfully"
}
main "$@"
Regular → Executable
+2 -101
View File
@@ -1,102 +1,3 @@
#!/bin/sh
set -o errexit
#!/usr/bin/env bash
CURRENT_DIR=$(pwd)
# 0. Create ca
echo "0. Create ca"
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
# 1. Create registry container unless it already exists
echo "1. Create registry container unless it already exists"
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
# 2. Create kind cluster with containerd registry config dir enabled
echo "2. Create kind cluster with containerd registry config dir enabled"
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
# 3. Add the registry config to the nodes
echo "3. Add the registry config to the nodes"
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
# 4. Connect the registry to the cluster network if not already connected
echo "4. Connect the registry to the cluster network if not already connected"
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
# 5. Document the local registry
echo "5. Document the local registry"
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- desk
+1 -1
View File
@@ -154,7 +154,7 @@ services:
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.0.16
image: registry.mim-libre.fr/dimail/dimail-api:v0.1.1
pull_policy: always
environment:
DIMAIL_MODE: FAKE
+58 -2
View File
@@ -58,6 +58,23 @@
],
"realmRoles": ["user"]
},
{
"username": "e2e.marie",
"email": "marie.varzy@gmail.com",
"firstName": "Marie",
"lastName": "Delamairie",
"enabled": true,
"attributes": {
"siret": "21580304000017"
},
"credentials": [
{
"type": "password",
"value": "password-e2e.marie"
}
],
"realmRoles": ["user"]
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
@@ -695,9 +712,17 @@
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false,
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
"scopeMappings": [
{
"clientScope": "siret",
"roles": [
"user"
]
},
{
"clientScope": "offline_access",
"roles": ["offline_access"]
"roles": [
"offline_access"
]
}
],
"clientScopeMappings": {
@@ -947,6 +972,7 @@
"acr",
"roles",
"profile",
"siret",
"email"
],
"optionalClientScopes": [
@@ -1107,6 +1133,35 @@
}
]
},
{
"id": "eb220fbb-02ac-4105-95a3-727954f6565d",
"name": "siret",
"description": "siret",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false",
"gui.order": ""
},
"protocolMappers": [
{
"id": "333a4e89-9363-4c36-b56f-79c6b019c6c6",
"name": "siret",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"aggregate.attrs": "false",
"userinfo.token.claim": "true",
"multivalued": "false",
"user.attribute": "siret",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "siret"
}
}
]
},
{
"id": "af52ccc3-4ecb-49b4-9a67-5d4172f16070",
"name": "role_list",
@@ -1573,7 +1628,8 @@
"email",
"roles",
"web-origins",
"acr"
"acr",
"siret"
],
"defaultOptionalClientScopes": [
"offline_access",
+131
View File
@@ -0,0 +1,131 @@
# Local development with Kubernetes
We use tilt to provide a local development environment for Kubernetes.
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.
## Prerequisites
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)
- [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.
### 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 "$@"
```
## Getting started
### Create the kubernetes cluster
Run the following command to create a kubernetes cluster using kind:
```bash
./bin/start-kind.sh
```
**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.
You can access the application at https://desk.127.0.0.1.nip.io
## Management
To manage the cluster, you can use k9s.
## Next steps
- Add dimail to the local development environment
- Add a reset demo `cmd_button` to Tilt
+2 -2
View File
@@ -1,10 +1,10 @@
#!/bin/bash
#!/usr/bin/env bash
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
cat <<'EOF' >$PRE_COMMIT_FILE
#!/bin/bash
#!/usr/bin/env bash
# directories containing potential secrets
DIRS="."
+66 -25
View File
@@ -24,18 +24,6 @@ def update_files(version):
with open(path, 'w+') as file:
file.writelines(lines)
# helm files
sys.stdout.write("Update helm files...\n")
for env in ["preprod", "production"]:
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "tag:" in line:
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
# frontend package.json
sys.stdout.write("Update package.json...\n")
files_to_modify = []
@@ -55,6 +43,17 @@ def update_files(version):
return
def update_helm_files(path):
sys.stdout.write("Update helm files...\n")
with open(path, 'r') as file:
lines = file.readlines()
for index, line in enumerate(lines):
if "tag:" in line:
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
with open(path, 'w+') as file:
file.writelines(lines)
def update_changelog(path, version):
"""Update changelog file with release info
"""
@@ -77,7 +76,43 @@ def update_changelog(path, version):
file.writelines(lines)
def prepare_release(version, kind):
def deployment_part(version, kind):
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
"""
# Move to lasuite-deploiement repository
try:
os.chdir('../lasuite-deploiement')
except FileNotFoundError:
sys.stdout.write("\033[1;31m[Error] You must have a clone of https://github.com/numerique-gouv/lasuite-deploiement\x1b[0m")
sys.stdout.write("\033[0;32mPlease clone it and re-run script with option --only-deployment-part\x1b[0m")
sys.stdout.write(" >>> python scripts/release.py --only-deployment-part \x1b[0m")
else:
run_command("git checkout main", shell=True)
run_command("git pull", shell=True)
deployment_branch = f"regie/{version}/preprod"
run_command(f"git checkout -b {deployment_branch}", shell=True)
run_command("git pull --rebase origin main", shell=True)
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"""🔖({RELEASE_KINDS[kind]}) release version {version}"""
run_command(f"git commit -m '{message}'", shell=True)
confirm = input(f"""\033[0;32m
### DEPLOYMENT ###
NEXT COMMAND on lasuite-deploiement repository:
>> git push origin {deployment_branch}
Continue ? (y,n)
\x1b[0m""")
if confirm == 'y':
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
\x1b[0m""")
def project_part(version, kind):
"""Manage only la regie part with update of CHANGELOG, version files and tag"""
sys.stdout.write('Let\'s go to create branch to release\n')
branch_to_release = f"release/{version}"
run_command(f"git checkout -b {branch_to_release}", shell=True)
@@ -90,25 +125,29 @@ def prepare_release(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"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
confirm = input(f"""\033[0;32m
### RELEASE ###
NEXT COMMAND on current repository:
>> git push origin {branch_to_release}
Continue ? (y,n)
\x1b[0m""")
if confirm == 'y':
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR and merge code to main
--> Then do:
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR {branch_to_release} and merge code to main
--> Then do:
>> git checkout main
>> git pull
>> git tag v{version}
>> git push origin v{version}
--> Please check and wait for the docker image v{version} to be published here: https://hub.docker.com/r/lasuite/people-backend/tags
>> git tag -d preprod
>> git tag preprod
>> git push -f origin preprod
--> Now please generate release on github interface for the current tag v{version}
""")
--> Please check and wait for the docker image v{version} to be published here:
- https://hub.docker.com/r/lasuite/people-backend/tags
- https://hub.docker.com/r/lasuite/people-frontend/tags
--> Now please generate release on github interface for the current tag v{version}
\x1b[0m""")
if __name__ == "__main__":
@@ -117,4 +156,6 @@ if __name__ == "__main__":
version = input("Enter your release version:")
while kind not in RELEASE_KINDS:
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
prepare_release(version, kind)
if "--only-deployment-part" not in sys.argv:
project_part(version, kind)
deployment_part(version, kind)
@@ -6,5 +6,8 @@ html[data-theme="light"], :root {
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
}
ul.messagelist li.info {
background-color: #EEEEEE;
}
</style>
{% endblock %}
+31 -2
View File
@@ -1,12 +1,19 @@
"""Admin classes and registrations for People's core app."""
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from mailbox_manager.admin import MailDomainAccessInline
from . import models
from .plugins.loader import (
get_organization_plugins,
organization_plugins_run_after_create,
)
class TeamAccessInline(admin.TabularInline):
@@ -118,9 +125,10 @@ class TeamServiceProviderInline(admin.TabularInline):
@admin.register(models.Team)
class TeamAdmin(admin.ModelAdmin):
class TeamAdmin(TreeAdmin):
"""Team admin interface declaration."""
form = movenodeform_factory(models.Team)
inlines = (TeamAccessInline, TeamWebhookInline, TeamServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline
list_display = (
@@ -129,6 +137,7 @@ class TeamAdmin(admin.ModelAdmin):
"updated_at",
)
search_fields = ("name",)
readonly_fields = ("path", "depth", "numchild")
@admin.register(models.TeamAccess)
@@ -206,10 +215,23 @@ class OrganizationServiceProviderInline(admin.TabularInline):
extra = 0
@admin.action(description=_("Run post creation plugins"), permissions=["change"])
def run_post_creation_plugins(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Run the post creation plugins for the selected organizations."""
for organization in queryset:
organization_plugins_run_after_create(organization)
messages.success(
request,
_("Post creation plugins have been run for the selected organizations."),
)
@admin.register(models.Organization)
class OrganizationAdmin(admin.ModelAdmin):
"""Admin interface for organizations."""
actions = [run_post_creation_plugins]
list_display = (
"name",
"created_at",
@@ -219,6 +241,13 @@ class OrganizationAdmin(admin.ModelAdmin):
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
exclude = ("service_providers",) # Handled by the inline
def get_actions(self, request):
"""Adapt actions list to the context."""
actions = super().get_actions(request)
if not get_organization_plugins():
actions.pop("run_post_creation_plugins", None)
return actions
@admin.register(models.OrganizationAccess)
class OrganizationAccessAdmin(admin.ModelAdmin):
+23 -7
View File
@@ -10,10 +10,13 @@ from core.models import ServiceProvider
class ContactSerializer(serializers.ModelSerializer):
"""Serialize contacts."""
abilities = serializers.SerializerMethodField()
class Meta:
model = models.Contact
fields = [
"id",
"abilities",
"override",
"data",
"full_name",
@@ -21,7 +24,7 @@ class ContactSerializer(serializers.ModelSerializer):
"owner",
"short_name",
]
read_only_fields = ["id", "owner"]
read_only_fields = ["id", "owner", "abilities"]
extra_kwargs = {
"override": {"required": False},
}
@@ -31,6 +34,13 @@ class ContactSerializer(serializers.ModelSerializer):
validated_data.pop("override", None)
return super().update(instance, validated_data)
def get_abilities(self, contact) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return contact.get_abilities(request.user)
return {}
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
@@ -78,8 +88,8 @@ class UserOrganizationSerializer(serializers.ModelSerializer):
class Meta:
model = models.Organization
fields = ["id", "name"]
read_only_fields = ["id", "name"]
fields = ["id", "name", "registration_id_list"]
read_only_fields = ["id", "name", "registration_id_list"]
class UserSerializer(DynamicFieldsModelSerializer):
@@ -249,18 +259,24 @@ class TeamSerializer(serializers.ModelSerializer):
model = models.Team
fields = [
"id",
"name",
"accesses",
"abilities",
"accesses",
"created_at",
"updated_at",
"depth",
"name",
"numchild",
"path",
"service_providers",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"accesses",
"created_at",
"depth",
"numchild",
"path",
"updated_at",
]
+92 -15
View File
@@ -1,7 +1,14 @@
"""API endpoints"""
import datetime
import operator
from functools import reduce
from django.conf import settings
from django.db.models import OuterRef, Q, Subquery
from django.db.models import OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import (
decorators,
@@ -19,8 +26,9 @@ from rest_framework.permissions import AllowAny
from core import models
from core.api import permissions
from core.api.client import serializers
from core.utils.raw_sql import gen_sql_filter_json_array
SIMILARITY_THRESHOLD = 0.04
from mailbox_manager import models as domains_models
class NestedGenericViewSet(viewsets.GenericViewSet):
@@ -135,31 +143,46 @@ class ContactViewSet(
):
"""Contact ViewSet"""
permission_classes = [permissions.IsOwnedOrPublic]
queryset = models.Contact.objects.all()
permission_classes = [permissions.AccessPermission]
queryset = models.Contact.objects.select_related("user").all()
serializer_class = serializers.ContactSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
ordering_fields = ["full_name", "short_name", "created_at"]
ordering = ["full_name"]
def list(self, request, *args, **kwargs):
"""Limit listed users by a query with throttle protection."""
user = self.request.user
queryset = self.filter_queryset(self.get_queryset())
# Exclude contacts that:
# List only contacts that:
queryset = queryset.filter(
# - belong to another user (keep public and owned contacts)
Q(owner__isnull=True) | Q(owner=user),
# - are profile contacts for a user
user__isnull=True,
# - are overriden base contacts
# - is public (owner is None)
Q(owner__isnull=True)
# - are owned by the user
| Q(owner=user)
# - are profile contacts for a user from the same organization
| Q(user__organization_id=user.organization_id),
# - are not overriden by another contact
overridden_by__isnull=True,
)
# Search by case-insensitive and accent-insensitive similarity
# Search by case-insensitive and accent-insensitive on:
# - full name
# - short name
# - email (from data `emails` field)
if query := self.request.GET.get("q", ""):
queryset = queryset.filter(
Q(full_name__unaccent__icontains=query)
| Q(short_name__unaccent__icontains=query)
| Q(
id__in=gen_sql_filter_json_array(
queryset.model,
"data->'emails'",
"value",
query,
)
)
)
serializer = self.get_serializer(queryset, many=True)
@@ -280,16 +303,24 @@ class TeamViewSet(
):
"""Team ViewSet"""
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.TeamPermission, permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering_fields = ["created_at", "name", "path"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
pagination_class = None
def get_queryset(self):
"""Custom queryset to get user related teams."""
teams_queryset = models.Team.objects.filter(
accesses__user=self.request.user,
)
depth_path = teams_queryset.values("depth", "path")
if not depth_path:
return models.Team.objects.none()
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]
@@ -297,9 +328,32 @@ class TeamViewSet(
return (
models.Team.objects.prefetch_related("accesses", "service_providers")
.filter(
accesses__user=self.request.user,
reduce(
operator.or_,
(
Q(
# The team the user has access to
depth=d["depth"],
path=d["path"],
)
| Q(
# The parent team the user has access to
depth__lt=d["depth"],
path__startswith=d["path"][: models.Team.steplen],
organization_id=self.request.user.organization_id,
)
for d in depth_path
),
),
)
# 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
# because it's a parent team.
.annotate(
user_role=Coalesce(
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
)
)
.annotate(user_role=Subquery(user_role_query))
)
def perform_create(self, serializer):
@@ -537,6 +591,29 @@ class ConfigView(views.APIView):
return response.Response(dict_settings)
class StatView(views.APIView):
"""API ViewSet for sharing some public metrics."""
permission_classes = [AllowAny]
@method_decorator(cache_page(3600))
def get(self, request):
"""
GET /api/v1.0/stats/
Return a dictionary of public metrics.
"""
context = {
"total_users": models.User.objects.count(),
"mau": models.User.objects.filter(
last_login__gte=datetime.datetime.now() - datetime.timedelta(30)
).count(),
"teams": models.Team.objects.count(),
"domains": domains_models.MailDomain.objects.count(),
"mailboxes": domains_models.Mailbox.objects.count(),
}
return response.Response(context)
class ServiceProviderFilter(filters.BaseFilterBackend):
"""
Filter service providers.
+15
View File
@@ -53,3 +53,18 @@ class AccessPermission(IsAuthenticated):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class TeamPermission(IsAuthenticated):
"""Permission class for team objects viewset."""
def has_permission(self, request, view):
"""Check permission only when the user tries to create a new team."""
if not super().has_permission(request, view):
return False
if request.method != "POST":
return True
abilities = request.user.get_abilities()
return abilities["teams"]["can_create"]
@@ -12,13 +12,19 @@ class TeamSerializer(serializers.ModelSerializer):
model = models.Team
fields = [
"id",
"name",
"created_at",
"depth",
"name",
"numchild",
"path",
"updated_at",
]
read_only_fields = [
"id",
"created_at",
"depth",
"numchild",
"path",
"updated_at",
]
@@ -1,6 +1,10 @@
"""Resource server API endpoints"""
from django.db.models import OuterRef, Prefetch, Subquery
import operator
from functools import reduce
from django.db.models import OuterRef, Prefetch, Q, Subquery, Value
from django.db.models.functions import Coalesce
from rest_framework import (
filters,
@@ -56,6 +60,14 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
def get_queryset(self):
"""Custom queryset to get user related teams."""
teams_queryset = models.Team.objects.filter(
accesses__user=self.request.user,
)
depth_path = teams_queryset.values("depth", "path")
if not depth_path:
return models.Team.objects.none()
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]
@@ -74,10 +86,33 @@ class TeamViewSet( # pylint: disable=too-many-ancestors
service_provider_prefetch,
)
.filter(
accesses__user=self.request.user,
reduce(
operator.or_,
(
Q(
# The team the user has access to
depth=d["depth"],
path=d["path"],
)
| Q(
# The parent team the user has access to
depth__lt=d["depth"],
path__startswith=d["path"][: models.Team.steplen],
organization_id=self.request.user.organization_id,
)
for d in depth_path
),
),
service_providers__audience_id=service_provider_audience,
)
.annotate(user_role=Subquery(user_role_query))
# 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
# because it's a parent team.
.annotate(
user_role=Coalesce(
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
)
)
)
def perform_create(self, serializer):
+10 -1
View File
@@ -118,6 +118,16 @@ class ContactFactory(BaseContactFactory):
owner = factory.SubFactory("core.factories.UserFactory")
class ProfileContactFactory(BaseContactFactory):
"""A factory to create profile contacts for a user"""
class Meta:
model = models.Contact
owner = factory.SelfAttribute(".user")
user = factory.SubFactory("core.factories.UserFactory")
class OverrideContactFactory(BaseContactFactory):
"""A factory to create contacts for a user"""
@@ -189,7 +199,6 @@ class TeamFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Team
django_get_or_create = ("name",)
skip_postgeneration_save = True
name = factory.Sequence(lambda n: f"team{n}")
+1 -1
View File
@@ -48,7 +48,7 @@ class Migration(migrations.Migration):
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('language', models.CharField(choices=settings.LANGUAGES, default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2024-12-05 13:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_change_user_profile_to_contact'),
]
operations = [
# From 100ms to 78ms for 5 000 contacts on my computer
# This is not a big improvement and may need further investigation
migrations.RunSQL(
"CREATE INDEX json_field_index_emails_value ON people_contact USING GIN ((data->'emails'->'value') jsonb_path_ops);"
)
]
@@ -0,0 +1,58 @@
# Generated by Django 5.1.3 on 2024-11-26 13:55
from django.db import migrations, models
from treebeard.numconv import NumConv
def update_team_paths(apps, schema_editor):
Team = apps.get_model("core", "Team")
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
steplen = 5
# Initialize NumConv with the specified custom alphabet
converter = NumConv(len(alphabet), alphabet)
nodes = Team.objects.all().order_by("created_at")
for i, node in enumerate(nodes, 1):
node.depth = 1 # root nodes are at depth 1
# Use NumConv to encode the index `i` to a base representation and
# pad it to the specified step length using the custom alphabet
node.path = converter.int2str(i).rjust(steplen, alphabet[0])
if nodes:
Team.objects.bulk_update(nodes, ["depth", "path"])
class Migration(migrations.Migration):
dependencies = [
('core', '0009_contacts_add_index_on_data_emails'),
]
operations = [
migrations.AddField(
model_name='team',
name='depth',
field=models.PositiveIntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='team',
name='numchild',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='team',
name='path',
field=models.CharField(default='', max_length=400, db_collation="C"),
preserve_default=False,
),
migrations.RunPython(update_team_paths, migrations.RunPython.noop),
migrations.AlterField(
model_name="team",
name="path",
field=models.CharField(unique=True, max_length=400, db_collation="C"),
),
]
+76 -8
View File
@@ -21,14 +21,15 @@ from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.translation import gettext, override
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import jsonschema
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
from core.utils.webhooks import scim_synchronizer
from core.validators import get_field_validators_from_setting
@@ -187,6 +188,27 @@ class Contact(BaseModel):
error_message = f"Validation error in '{field_path:s}': {e.message}"
raise exceptions.ValidationError({"data": [error_message]}) from e
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the contact.
Beware that the model allows owner to be None, we are still not
sure about the use case for this and the API does not allow this.
For now, we still consider here, a contact without owner is "public"
so we allow access to it.
"""
is_owner = user.pk == self.owner_id
is_profile_member_or_same_organization = bool(self.user_id) and (
self.user.organization_id == user.organization_id
)
return {
"get": is_owner or is_profile_member_or_same_organization or not self.owner,
"patch": is_owner,
"put": is_owner,
"delete": is_owner and not self.user, # Can't delete a profile contact
}
class ServiceProvider(BaseModel):
"""
@@ -265,6 +287,16 @@ class OrganizationManager(models.Manager):
raise ValueError("Should never reach this point.")
def create(self, **kwargs):
"""
Create an organization with the given kwargs.
This method is overridden to call the Organization plugins.
"""
instance = super().create(**kwargs)
organization_plugins_run_after_create(instance)
return instance
class Organization(BaseModel):
"""
@@ -409,7 +441,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
choices=settings.LANGUAGES,
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
@@ -541,7 +573,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
.get()
)
teams_can_view = user_info.teams_can_view
teams_can_view = user_info.teams_can_view or settings.FEATURES["TEAMS_DISPLAY"]
mailboxes_can_view = user_info.mailboxes_can_view
return {
@@ -553,7 +585,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
},
"teams": {
"can_view": teams_can_view and settings.FEATURES["TEAMS_DISPLAY"],
"can_view": teams_can_view,
"can_create": teams_can_view and settings.FEATURES["TEAMS_CREATE"],
},
"mailboxes": {
@@ -602,7 +634,34 @@ class OrganizationAccess(BaseModel):
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"
class Team(BaseModel):
class TeamManager(MP_NodeManager):
"""
Custom manager for the Team model, to manage complexity/automation.
"""
def create(self, parent_id=None, **kwargs):
"""
Replace the default create method to ease the Team creation process.
Notes:
- the `add_*` methods from django-treebeard does not support the "using db".
Which means it will always use the default db.
- the `add_*` methods from django-treebeard does not support the "force_insert".
"""
if parent_id is None:
return self.model.add_root(**kwargs)
# Retrieve parent object, because django-treebeard uses raw queries for most
# write operations, and raw queries dont update the django objects of the db
# entries they modify. See caveats in the django-treebeard documentation.
# This might be changed later if we never do any operation on the parent object
# before creating the child.
# Beware the N+1 here.
return self.get(pk=parent_id).add_child(**kwargs)
class Team(MP_Node, BaseModel):
"""
Represents the link between teams and users, specifying the role a user has in a team.
@@ -612,6 +671,12 @@ class Team(BaseModel):
Team `service_providers`.
"""
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
# Django treebeard does not allow max_length = None...
steplen = 5
path = models.CharField(max_length=5 * 80, unique=True, db_collation="C")
name = models.CharField(max_length=100)
users = models.ManyToManyField(
@@ -633,6 +698,8 @@ class Team(BaseModel):
blank=True,
)
objects = TeamManager()
class Meta:
db_table = "people_team"
ordering = ("name",)
@@ -894,14 +961,15 @@ class Invitation(BaseModel):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
subject = gettext("Invitation to join La Régie!")
template_vars = {
"title": _("Invitation to join Desk!"),
"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(
_("Invitation to join Desk!"),
subject,
msg_plain,
settings.EMAIL_FROM,
[self.email],
+1
View File
@@ -0,0 +1 @@
"""Core plugins package."""
+13
View File
@@ -0,0 +1,13 @@
"""Base plugin class for organization plugins."""
class BaseOrganizationPlugin:
"""
Base class for organization plugins.
Plugins must implement all methods of this class even if it is only to "pass".
"""
def run_after_create(self, organization) -> None:
"""Method called after creating an organization."""
raise NotImplementedError("Plugins must implement the run_after_create method")
+32
View File
@@ -0,0 +1,32 @@
"""Helper functions to load and run organization plugins."""
from functools import lru_cache
from typing import List
from django.conf import settings
from django.utils.module_loading import import_string
from core.plugins.base import BaseOrganizationPlugin
@lru_cache(maxsize=None)
def get_organization_plugins() -> List[BaseOrganizationPlugin]:
"""
Return a list of all organization plugins.
While the plugins initialization does not depend on the request, we can cache the result.
"""
return [
import_string(plugin_path)() for plugin_path in settings.ORGANIZATION_PLUGINS
]
def organization_plugins_run_after_create(organization):
"""
Run the after create 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_create(organization)
@@ -86,6 +86,7 @@ def test_api_contacts_create_authenticated_missing_base():
assert response.json() == {
"override": None,
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"data": {},
"full_name": "David Bowman",
"id": str(new_contact.pk),
@@ -123,6 +124,7 @@ def test_api_contacts_create_authenticated_successful():
contact = models.Contact.objects.get(owner=user)
assert response.json() == {
"id": str(contact.id),
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"override": str(base_contact.id),
"data": CONTACT_DATA,
"full_name": "David Bowman",
@@ -195,6 +197,7 @@ def test_api_contacts_create_authenticated_successful_with_notes():
contact = models.Contact.objects.get(owner=user)
assert response.json() == {
"id": str(contact.id),
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"override": None,
"data": CONTACT_DATA,
"full_name": "David Bowman",
@@ -86,10 +86,10 @@ def test_api_contacts_delete_authenticated_owner():
def test_api_contacts_delete_authenticated_profile():
"""
Authenticated users should be allowed to delete their profile contact.
Authenticated users should not be allowed to delete their profile contact.
"""
user = factories.UserFactory()
contact = factories.ContactFactory(owner=user, user=user)
contact = factories.ProfileContactFactory(user=user)
client = APIClient()
client.force_login(user)
@@ -98,8 +98,8 @@ def test_api_contacts_delete_authenticated_profile():
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 204
assert models.Contact.objects.exists() is False
assert response.status_code == 403
assert models.Contact.objects.exists() is True
def test_api_contacts_delete_authenticated_other():
@@ -22,39 +22,75 @@ def test_api_contacts_list_anonymous():
}
def test_api_contacts_list_authenticated_no_query():
def test_api_contacts_list_authenticated_no_query(django_assert_num_queries):
"""
Authenticated users should be able to list contacts without applying a query.
Profile and overridden contacts should be excluded.
"""
user = factories.UserFactory()
factories.ContactFactory(owner=user, user=user)
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
# Let's have 5 contacts in database:
assert user.profile_contact is not None # Excluded because profile contact
base_contact = factories.BaseContactFactory() # Excluded because overridden
factories.ContactFactory(
override=base_contact
) # Excluded because belongs to other user
contact2 = factories.ContactFactory(
override=base_contact, owner=user, full_name="Bernard"
) # Included
# The user's profile contact should be listed (why not)
user_profile_contact = factories.ContactFactory(
owner=user, user=user, full_name="Dave Bowman"
)
# A contact that belongs to another user should not be listed
factories.ContactFactory()
# even if from the same organization
factories.ContactFactory(owner__organization=organization)
# A profile contact should not be listed if from another organization
factories.ProfileContactFactory()
# A profile contact for someone in the same organization should be listed
profile_contact = factories.ProfileContactFactory(
user__organization=organization, full_name="Frank Poole"
)
# An overridden contact should not be listed, but the override must be
overriden_contact = factories.ProfileContactFactory(user__organization=organization)
override_contact = factories.ContactFactory(
owner=user, override=overriden_contact, full_name="Nicole Foole"
)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/")
with django_assert_num_queries(2):
response = client.get("/api/v1.0/contacts/")
assert response.status_code == 200
assert response.json() == [
{
"id": str(contact2.id),
"override": str(base_contact.id),
"owner": str(contact2.owner.id),
"data": contact2.data,
"full_name": contact2.full_name,
"id": str(user_profile_contact.pk),
"abilities": {"delete": False, "get": True, "patch": True, "put": True},
"override": None,
"owner": str(user.pk),
"data": user_profile_contact.data,
"full_name": user_profile_contact.full_name,
"notes": "",
"short_name": contact2.short_name,
"short_name": user_profile_contact.short_name,
},
{
"id": str(profile_contact.pk),
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
"override": None,
"owner": str(profile_contact.user.pk),
"data": profile_contact.data,
"full_name": profile_contact.full_name,
"notes": "",
"short_name": profile_contact.short_name,
},
{
"id": str(override_contact.pk),
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"override": str(overriden_contact.pk),
"owner": str(user.pk),
"data": override_contact.data,
"full_name": override_contact.full_name,
"notes": "",
"short_name": override_contact.short_name,
},
]
@@ -64,12 +100,12 @@ def test_api_contacts_list_authenticated_by_full_name():
Authenticated users should be able to search users with a case insensitive and
partial query on the full name.
"""
user = factories.UserFactory()
user = factories.UserFactory(name="Prudence Crandall")
dave = factories.BaseContactFactory(full_name="David Bowman")
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
frank = factories.BaseContactFactory(full_name="Frank Poole")
factories.BaseContactFactory(full_name="Heywood Floyd")
dave = factories.BaseContactFactory(full_name="David Bowman", data={})
nicole = factories.BaseContactFactory(full_name="Nicole Foole", data={})
frank = factories.BaseContactFactory(full_name="Frank Poole", data={})
factories.BaseContactFactory(full_name="Heywood Floyd", data={})
# Full query should work
client = APIClient()
@@ -101,6 +137,78 @@ def test_api_contacts_list_authenticated_by_full_name():
assert contact_ids == [str(frank.id), str(nicole.id)]
def test_api_contacts_list_authenticated_by_email():
"""
Authenticated users should be able to search users with a case insensitive and
partial query on the email.
"""
user = factories.UserFactory()
dave = factories.BaseContactFactory(
full_name="0", # don't match on full name but allow ordering
data={
"emails": [
{"type": "Home", "value": "dave@personal.com"},
{"type": "Work", "value": "david.bowman@example.com"},
],
},
)
nicole = factories.BaseContactFactory(
full_name="1", # don't match on full name but allow ordering
data={
"emails": [
{"type": "Work", "value": "nicole.foole@example.com"},
],
},
)
frank = factories.BaseContactFactory(
full_name="2", # don't match on full name but allow ordering
data={
"emails": [
{"type": "Home", "value": "francky@personal.com"},
{"type": "Work", "value": "franck.poole@example.com"},
],
},
)
factories.BaseContactFactory(
full_name="3", # don't match on full name but allow ordering
data={
"emails": [
{"type": "Work", "value": "heywood.floyd@example.com"},
],
},
)
# Full query should work
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/?q=david.bowman@example.com")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.pk)]
# Partial query should work
response = client.get("/api/v1.0/contacts/?q=anc")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(frank.pk)]
response = client.get("/api/v1.0/contacts/?q=olé") # accented
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(nicole.pk), str(frank.pk)]
response = client.get("/api/v1.0/contacts/?q=oOl") # mixed case
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(nicole.pk), str(frank.pk)]
def test_api_contacts_list_authenticated_uppercase_content():
"""Upper case content should be found by lower case query."""
user = factories.UserFactory()
@@ -22,7 +22,7 @@ def test_api_contacts_retrieve_anonymous():
}
def test_api_contacts_retrieve_authenticated_owned():
def test_api_contacts_retrieve_authenticated_owned(django_assert_num_queries):
"""
Authenticated users should be allowed to retrieve a contact they own.
"""
@@ -32,11 +32,13 @@ def test_api_contacts_retrieve_authenticated_owned():
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
with django_assert_num_queries(2):
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(contact.id),
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
"override": None,
"owner": str(contact.owner.id),
"data": contact.data,
@@ -60,6 +62,7 @@ def test_api_contacts_retrieve_authenticated_public():
assert response.status_code == 200
assert response.json() == {
"id": str(contact.id),
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
"override": None,
"owner": None,
"data": contact.data,
@@ -35,7 +35,7 @@ def test_api_contacts_update_anonymous():
assert contact_values == old_contact_values
def test_api_contacts_update_authenticated_owned():
def test_api_contacts_update_authenticated_owned(django_assert_num_queries):
"""
Authenticated users should be allowed to update their own contacts.
"""
@@ -52,11 +52,13 @@ def test_api_contacts_update_authenticated_owned():
).data
new_contact_values["override"] = str(factories.ContactFactory().id)
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
with django_assert_num_queries(8):
# user, 2x contact, user, 3x check, update contact
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 200
@@ -59,7 +59,10 @@ 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),
"depth": team.depth,
"name": "my team",
"numchild": team.numchild,
"path": team.path,
"updated_at": team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
@@ -64,7 +64,8 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(4): # Count, Team, ServiceProvider, TeamAccess
with django_assert_num_queries(5):
# queries: Team path, Count, Team, ServiceProvider, TeamAccess
response = client.get(
"/resource-server/v1.0/teams/?ordering=created_at",
format="json",
@@ -79,26 +80,38 @@ def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
"results": [
{
"created_at": team_1.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_1.depth,
"id": str(team_1.pk),
"name": team_1.name,
"numchild": team_1.numchild,
"path": team_1.path,
"updated_at": team_1.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
"created_at": team_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_2.depth,
"id": str(team_2.pk),
"name": team_2.name,
"numchild": team_2.numchild,
"path": team_2.path,
"updated_at": team_2.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
"created_at": team_3.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_3.depth,
"id": str(team_3.pk),
"name": team_3.name,
"numchild": team_3.numchild,
"path": team_3.path,
"updated_at": team_3.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
"created_at": team_4.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": team_4.depth,
"id": str(team_4.pk),
"name": team_4.name,
"numchild": team_4.numchild,
"path": team_4.path,
"updated_at": team_4.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
],
@@ -179,6 +192,84 @@ def test_api_teams_order_param(client, force_login_via_resource_server):
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
assert (
response_team_ids == team_ids
), "created_at values are not sorted from oldest to newest"
assert response_team_ids == team_ids, (
"created_at values are not sorted from oldest to newest"
)
def test_api_teams_list_with_parent_teams(client, force_login_via_resource_server):
"""
Authenticated users should be able to list teams including parent teams.
Parent teams should not be listed if they don't have the service provider.
"""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(name="Root", service_providers=[service_provider])
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(
name="Second", parent_id=first_team.pk, service_providers=[service_provider]
)
factories.TeamAccessFactory(user=user, team=second_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
"/resource-server/v1.0/teams/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
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 set(team_ids) == {str(root_team.id), str(second_team.id)}
def test_api_teams_list_with_parent_teams_other_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should be able to list teams including parent teams.
Parent teams should not be listed if they don't have the service provider
or if the user does not belong to the organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
other_organization = factories.OrganizationFactory(with_registration_id=True)
root_team = factories.TeamFactory(
name="Root",
service_providers=[service_provider],
organization=other_organization,
)
first_team = factories.TeamFactory(
name="First", parent_id=root_team.pk, organization=other_organization
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=other_organization,
)
factories.TeamAccessFactory(user=user, team=second_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
"/resource-server/v1.0/teams/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
response_data = response.json()
assert response_data["count"] == 1
team_ids = [team["id"] for team in response_data["results"]]
assert len(team_ids) == 1
assert set(team_ids) == {str(second_team.id)}
@@ -8,7 +8,7 @@ from rest_framework.status import HTTP_404_NOT_FOUND
from rest_framework.test import APIClient
from core import factories
from core.factories import UserFactory
from core.factories import TeamAccessFactory, UserFactory
pytestmark = pytest.mark.django_db
@@ -66,9 +66,12 @@ def test_api_teams_retrieve_authenticated_related(
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 1,
"id": str(team.id),
"name": team.name,
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"numchild": 0,
"path": team.path,
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -97,3 +100,132 @@ def test_api_teams_retrieve_authenticated_other_service_provider(
)
assert response.status_code == HTTP_404_NOT_FOUND
def test_api_teams_retrieve_authenticated_related_parent_same_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should be allowed to retrieve a parent team
of a team to which they are related, only if they belong to the
same organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(
name="Root",
organization=organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=organization,
)
TeamAccessFactory(user=user, team=second_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{first_team.pk}/",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 2,
"id": str(first_team.pk),
"name": first_team.name,
"numchild": 1,
"path": first_team.path,
"updated_at": first_team.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_teams_retrieve_authenticated_related_parent_other_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should be allowed to retrieve a parent team
of a team to which they are related, only if they belong to the
same organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
other_organization = factories.OrganizationFactory(with_registration_id=True)
root_team = factories.TeamFactory(
name="Root",
organization=other_organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=other_organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=other_organization,
)
TeamAccessFactory(user=user, team=second_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{first_team.pk}/",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
def test_api_teams_retrieve_authenticated_related_child_same_organization(
client, force_login_via_resource_server
):
"""
Authenticated users should NOT be allowed to retrieve a child team
of a team to which they are related, even if they belong to the
same organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(
name="Root",
organization=organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=organization,
)
TeamAccessFactory(user=user, team=first_team, role="member")
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.get(
f"/resource-server/v1.0/teams/{second_team.pk}/",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
@@ -244,3 +244,95 @@ def test_api_teams_update_administrator_or_owner_of_another(
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
@pytest.mark.parametrize("role", ["administrator", "member", "owner"])
def test_api_teams_update_parent_team(client, force_login_via_resource_server, role):
"""
Belonging to a team should NOT grant authorization to update
another parent team.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(
name="Root",
organization=organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=organization,
)
factories.TeamAccessFactory(user=user, team=second_team, role=role)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.patch(
f"/resource-server/v1.0/teams/{first_team.pk}/",
{
"name": "New name",
},
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
first_team.refresh_from_db()
assert first_team.name == "First"
@pytest.mark.parametrize("role", ["administrator", "member", "owner"])
def test_api_teams_update_child_team(client, force_login_via_resource_server, role):
"""
Belonging to a team should NOT grant authorization to update
another child team.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
service_provider = factories.ServiceProviderFactory()
root_team = factories.TeamFactory(
name="Root",
organization=organization,
)
first_team = factories.TeamFactory(
name="First",
parent_id=root_team.pk,
organization=organization,
service_providers=[service_provider],
)
second_team = factories.TeamFactory(
name="Second",
parent_id=first_team.pk,
service_providers=[service_provider],
organization=organization,
)
factories.TeamAccessFactory(user=user, team=first_team, role=role)
with force_login_via_resource_server(client, user, service_provider.audience_id):
response = client.patch(
f"/resource-server/v1.0/teams/{second_team.pk}/",
{
"name": "New name",
},
content_type="application/json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
second_team.refresh_from_db()
assert second_team.name == "Second"
@@ -338,14 +338,19 @@ def test_api_team_accesses_list_authenticated_ordering():
def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
"""Team accesses can be ordered by user's fields."""
user = factories.UserFactory()
john = factories.UserFactory(email="john.doe@example.com", name="John Doe")
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
models.TeamAccess.objects.create(team=team, user=john)
# create 20 new team members
for _ in range(20):
extra_user = factories.UserFactory()
factories.TeamAccessFactory(team=team, user=extra_user)
# create new team members
dave = factories.UserFactory(email="bowbow@example.com", name="David Bowman")
nicole = factories.UserFactory(
email="nicole_foole@example.com", name="Nicole Foole"
)
frank = factories.UserFactory(email="frank_poole@example.com", name="Frank Poole")
mary = factories.UserFactory(email="mary_pol@example.com", name="Mary Pol")
for user in (dave, nicole, frank, mary):
factories.TeamAccessFactory(team=team, user=user)
client = APIClient()
client.force_login(user)
@@ -354,14 +359,11 @@ def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=user__{ordering_field}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
def normalize(x):
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
return x.casefold().replace(" ", "")
results = [
team_access["user"][ordering_field]
for team_access in response.json()["results"]
assert response.json()["count"] == 5
assert [access["user"]["name"] for access in response.json()["results"]] == [
dave.name,
frank.name,
john.name,
mary.name,
nicole.name,
]
assert sorted(results, key=normalize) == results
@@ -6,6 +6,7 @@ import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
@@ -28,7 +29,7 @@ def test_api_teams_create_anonymous():
assert not Team.objects.exists()
def test_api_teams_create_authenticated():
def test_api_teams_create_authenticated(settings):
"""
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team.
@@ -39,6 +40,14 @@ def test_api_teams_create_authenticated():
client = APIClient()
client.force_login(user)
settings.FEATURES = {
"TEAMS_DISPLAY": True,
"TEAMS_CREATE": True,
"CONTACTS_DISPLAY": False,
"CONTACTS_CREATE": False,
"MAILBOXES_CREATE": False,
}
response = client.post(
"/api/v1.0/teams/",
{
@@ -54,6 +63,36 @@ def test_api_teams_create_authenticated():
assert team.accesses.filter(role="owner", user=user).exists()
def test_api_teams_create_authenticated_feature_disabled(settings):
"""
Authenticated users should not be able to create teams when feature is disabled.
"""
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
settings.FEATURES = {
"TEAMS_DISPLAY": True,
"TEAMS_CREATE": False,
"CONTACTS_DISPLAY": False,
"CONTACTS_CREATE": False,
"MAILBOXES_CREATE": False,
}
response = client.post(
"/api/v1.0/teams/",
{
"name": "my team",
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert not Team.objects.exists()
def test_api_teams_create_cannot_override_organization():
"""
Authenticated users should be able to create teams and not
@@ -113,3 +113,59 @@ def test_api_teams_delete_authenticated_owner():
assert response.status_code == HTTP_204_NO_CONTENT
assert models.Team.objects.exists() is False
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_delete_authenticated_owner_parent_team(client, role):
"""
Authenticated users should not be able to delete a parent team they
don't own.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
# user is a member of the second team
factories.TeamAccessFactory(team=second_team, user=user, role=role)
response = client.delete(f"/api/v1.0/teams/{first_team.pk}/")
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Team.objects.count() == 3
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_delete_authenticated_owner_child_team(client, role):
"""
Authenticated users should not be able to delete a children team they
don't own.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
# user is a member of the first team
factories.TeamAccessFactory(team=first_team, user=user, role=role)
response = client.delete(f"/api/v1.0/teams/{second_team.pk}/")
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
assert models.Team.objects.count() == 3
@@ -95,9 +95,9 @@ def test_api_teams_order():
response_team_ids = [team["id"] for team in response_data]
team_ids.reverse()
assert (
response_team_ids == team_ids
), "created_at values are not sorted from newest to oldest"
assert response_team_ids == team_ids, (
"created_at values are not sorted from newest to oldest"
)
def test_api_teams_order_param():
@@ -122,6 +122,204 @@ def test_api_teams_order_param():
response_data = response.json()
response_team_ids = [team["id"] for team in response_data]
assert (
response_team_ids == team_ids
), "created_at values are not sorted from oldest to newest"
assert response_team_ids == team_ids, (
"created_at values are not sorted from oldest to newest"
)
@pytest.mark.parametrize(
"role,local_team_abilities",
[
(
"owner",
{
"delete": True,
"get": True,
"manage_accesses": True,
"patch": True,
"put": True,
},
),
(
"administrator",
{
"delete": False,
"get": True,
"manage_accesses": True,
"patch": True,
"put": True,
},
),
(
"member",
{
"delete": False,
"get": True,
"manage_accesses": False,
"patch": False,
"put": False,
},
),
],
)
def test_api_teams_list_authenticated_team_tree(client, role, local_team_abilities):
"""
Authenticated users should be able to list teams
they are an owner/administrator/member of, or any parent teams.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
third_team = factories.TeamFactory(name="Third", parent_id=second_team.pk)
_fourth_team = factories.TeamFactory(name="Fourth", parent_id=third_team.pk)
# user is a member of the second team
user_access = factories.TeamAccessFactory(team=second_team, user=user, role=role)
response = client.get("/api/v1.0/teams/")
assert response.status_code == HTTP_200_OK
# By default, the teams are sorted by 'created_at' descending
assert response.json() == [
{
# I have the abilities only on the team I have a specific role
"abilities": local_team_abilities,
"accesses": [str(user_access.pk)],
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": 3,
"id": str(second_team.pk),
"name": "Second",
"numchild": 1,
"path": second_team.path,
"service_providers": [],
"updated_at": second_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
# For parent teams, I only have the ability to list/retrieve
"abilities": {
"delete": False,
"get": True,
"manage_accesses": False,
"patch": False,
"put": False,
},
"accesses": [],
"created_at": first_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": 2,
"id": str(first_team.pk),
"name": "First",
"numchild": 1,
"path": first_team.path,
"service_providers": [],
"updated_at": first_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
{
# For parent teams, I only have the ability to list/retrieve
"abilities": {
"delete": False,
"get": True,
"manage_accesses": False,
"patch": False,
"put": False,
},
"accesses": [],
"created_at": root_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": 1,
"id": str(root_team.pk),
"name": "Root",
"numchild": 1,
"path": root_team.path,
"service_providers": [],
"updated_at": root_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
]
@pytest.mark.parametrize(
"role,local_team_abilities",
[
(
"owner",
{
"delete": True,
"get": True,
"manage_accesses": True,
"patch": True,
"put": True,
},
),
(
"administrator",
{
"delete": False,
"get": True,
"manage_accesses": True,
"patch": True,
"put": True,
},
),
(
"member",
{
"delete": False,
"get": True,
"manage_accesses": False,
"patch": False,
"put": False,
},
),
],
)
def test_api_teams_list_authenticated_team_different_organization(
client, role, local_team_abilities
):
"""
Authenticated users should be able to list teams they
are an owner/administrator/member of and any parent teams
only if from the same organization.
"""
organization = factories.OrganizationFactory(with_registration_id=True)
user = factories.UserFactory(organization=organization)
other_organization = factories.OrganizationFactory(with_registration_id=True)
root_team = factories.TeamFactory(name="Root", organization=other_organization)
first_team = factories.TeamFactory(
name="First", parent_id=root_team.pk, organization=other_organization
)
second_team = factories.TeamFactory(
name="Second", parent_id=first_team.pk, organization=other_organization
)
third_team = factories.TeamFactory(
name="Third", parent_id=second_team.pk, organization=other_organization
)
_fourth_team = factories.TeamFactory(
name="Fourth", parent_id=third_team.pk, organization=other_organization
)
client.force_login(user)
# user is a member of the second team
user_access = factories.TeamAccessFactory(team=second_team, user=user, role=role)
response = client.get("/api/v1.0/teams/")
assert response.status_code == HTTP_200_OK
assert response.json() == [
{
# I have the abilities only on the team I have a specific role
"abilities": local_team_abilities,
"accesses": [str(user_access.pk)],
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
"depth": 3,
"id": str(second_team.pk),
"name": "Second",
"numchild": 1,
"path": second_team.path,
"service_providers": [],
"updated_at": second_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
},
]
@@ -67,10 +67,80 @@ def test_api_teams_retrieve_authenticated_related():
]
)
assert response.json() == {
"id": str(team.id),
"name": team.name,
"abilities": team.get_abilities(user),
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
"depth": 1,
"id": str(team.id),
"name": team.name,
"numchild": 0,
"path": team.path,
"service_providers": [],
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
}
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_retrieve_authenticated_related_parent(client, role):
"""
Authenticated users should be allowed to retrieve a parent team
to which they are related through the child team whatever the role.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
# user is a member of the second team
factories.TeamAccessFactory(team=second_team, user=user, role=role)
response = client.get(f"/api/v1.0/teams/{first_team.pk!s}/")
# the abilities enforces the "get" via the queryset
abilities = first_team.get_abilities(user)
abilities["get"] = True
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"abilities": abilities,
"accesses": [],
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
"depth": 2,
"id": str(first_team.pk),
"name": first_team.name,
"numchild": 1,
"path": first_team.path,
"service_providers": [],
"updated_at": first_team.updated_at.isoformat().replace("+00:00", "Z"),
}
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_retrieve_authenticated_related_children(client, role):
"""
Authenticated users should NOT be allowed to retrieve a child team
to which they are related through the parent team whatever the role.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
# user is a member of the first team
factories.TeamAccessFactory(team=first_team, user=user, role=role)
response = client.get(f"/api/v1.0/teams/{second_team.pk!s}/")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
@@ -119,7 +119,7 @@ def test_api_teams_update_authenticated_administrators():
team.refresh_from_db()
final_values = serializers.TeamSerializer(instance=team).data
for key, value in final_values.items():
if key in ["id", "accesses", "created_at"]:
if key in ["id", "accesses", "created_at", "depth", "path", "numchild"]:
assert value == initial_values[key]
elif key == "updated_at":
assert value > initial_values[key]
@@ -152,7 +152,7 @@ def test_api_teams_update_authenticated_owners():
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
for key, value in team_values.items():
if key in ["id", "accesses", "created_at"]:
if key in ["id", "accesses", "created_at", "depth", "path", "numchild"]:
assert value == old_team_values[key]
elif key == "updated_at":
assert value > old_team_values[key]
@@ -219,3 +219,75 @@ def test_api_teams_update_authenticated_owners_add_service_providers():
team.refresh_from_db()
assert team.service_providers.count() == 2
assert set(team.service_providers.all()) == {service_provider_1, service_provider_2}
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_update_whatever_access_of_child_team(client, role):
"""
Being member, administrator or owner of a team should not grant
authorization to update a parent team.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
# user is a member of the second team
factories.TeamAccessFactory(team=second_team, user=user, role=role)
response = client.patch(
f"/api/v1.0/teams/{first_team.pk}/",
{
"name": "New name",
},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
first_team.refresh_from_db()
assert first_team.name == "First"
@pytest.mark.parametrize(
"role",
["owner", "administrator", "member"],
)
def test_api_teams_update_whatever_access_of_parent_team(client, role):
"""
Being member, administrator or owner of a team should not grant
authorization to update a child team.
"""
user = factories.UserFactory()
client.force_login(user)
root_team = factories.TeamFactory(name="Root")
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
# user is a member of the first team
factories.TeamAccessFactory(team=first_team, user=user, role=role)
response = client.patch(
f"/api/v1.0/teams/{second_team.pk}/",
{
"name": "New name",
},
format="json",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
second_team.refresh_from_db()
assert second_team.name == "Second"
+75
View File
@@ -0,0 +1,75 @@
"""
Test stats endpoint
"""
from django.core.cache import cache
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories as domains_factories
from mailbox_manager import models as domains_models
pytestmark = pytest.mark.django_db
def test_api_stats__anonymous(django_assert_num_queries):
"""Stats endpoint should be available even when not connected."""
domains_factories.MailDomainFactory.create_batch(5)
core_factories.TeamFactory.create_batch(3)
# clear cache to allow stats count
cache.clear()
with django_assert_num_queries(5):
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_users": 0,
"mau": 0,
"domains": 5,
"mailboxes": 0,
"teams": 3,
}
# no new request made due to caching
with django_assert_num_queries(0):
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_users": 0,
"mau": 0,
"domains": 5,
"mailboxes": 0,
"teams": 3,
}
def test_api_stats__expected_count():
"""Objects should be correctly counted."""
core_factories.UserFactory.create_batch(4)
logged_in_users = core_factories.UserFactory.create_batch(6)
client = APIClient()
for user in logged_in_users:
client.force_login(user)
core_factories.TeamFactory.create_batch(3)
domains_factories.MailDomainFactory.create_batch(2)
domains_factories.MailboxFactory.create_batch(
10, domain=domains_models.MailDomain.objects.all()[1]
)
# clear cache to allow stats count
cache.clear()
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_users": 10,
"mau": 6,
"domains": 2,
"mailboxes": 10,
"teams": 3,
}
@@ -248,7 +248,9 @@ def test_models_team_invitations_email():
assert len(mail.outbox) == 0
factories.TeamAccessFactory(team=team)
invitation = factories.InvitationFactory(team=team, email="john@people.com")
invitation = factories.InvitationFactory(
team=team, email="john@people.com", issuer__language="fr-fr"
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
@@ -257,10 +259,10 @@ def test_models_team_invitations_email():
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Desk!"
assert email.subject == "Invitation à rejoindre La Régie!"
email_content = " ".join(email.body.split())
assert "Invitation to join Desk!" in email_content
assert "Invitation à rejoindre La Régie!" in email_content
assert "[//example.com]" in email_content
@@ -134,3 +134,86 @@ def test_models_teams_get_abilities_preset_role(django_assert_num_queries):
"put": False,
"manage_accesses": False,
}
# test trees
def test_models_teams_create_root_team():
"""Create a root team."""
team = models.Team.add_root(name="Root Team")
assert team.is_root()
assert team.name == "Root Team"
def test_models_teams_create_child_team():
"""Create a child team."""
root_team = models.Team.add_root(name="Root Team")
child_team = root_team.add_child(name="Child Team")
assert child_team.is_child_of(root_team)
assert child_team.name == "Child Team"
assert child_team.get_parent() == root_team
def test_models_teams_create_grandchild_team():
"""Create a grandchild team."""
root_team = models.Team.add_root(name="Root Team")
child_team = root_team.add_child(name="Child Team")
grandchild_team = child_team.add_child(name="Grandchild Team")
assert grandchild_team.is_child_of(child_team)
assert grandchild_team.name == "Grandchild Team"
assert grandchild_team.get_parent() == child_team
def test_models_teams_move_team():
"""Move a team to another parent."""
root_team = models.Team.add_root(name="Root Team")
child_team = root_team.add_child(name="Child Team")
new_root_team = models.Team.add_root(name="New Root Team")
child_team.move(new_root_team, pos="first-child")
child_team.refresh_from_db()
assert child_team.get_parent(update=True) == new_root_team
def test_models_teams_delete_team():
"""
Delete a parent team also deletes children.
This might not be what we want, but it's the default behavior of treebeard.
"""
root_team = models.Team.add_root(name="Root Team")
root_team.add_child(name="Child Team")
assert models.Team.objects.all().count() == 2
root_team.delete()
assert models.Team.objects.all().count() == 0
def test_models_teams_manager_create():
"""Create a team using the manager."""
team = models.Team.objects.create(name="Team")
assert team.is_root()
assert team.name == "Team"
child_team = models.Team.objects.create(name="Child Team", parent_id=team.pk)
assert child_team.is_child_of(team)
assert child_team.name == "Child Team"
def test_models_teams_tree_alphabet():
"""Test the creation of teams with treebeard methods."""
organization = factories.OrganizationFactory(with_registration_id=True)
models.Team.load_bulk(
[
{
"data": {
"name": f"team-{i}",
"organization_id": organization.pk,
}
}
for i in range(len(models.Team.alphabet) * 2)
]
)
assert models.Team.objects.count() == len(models.Team.alphabet) * 2
@@ -0,0 +1,19 @@
"""Tests for the raw SQL utility functions."""
from django.db.models.expressions import RawSQL
from core.models import Contact
from core.utils.raw_sql import gen_sql_filter_json_array
def test_gen_sql_filter_json_array():
"""Test the generation of a raw SQL query to filter on a JSON array."""
raw_sql = gen_sql_filter_json_array(Contact, "data->'emails'", "value", "blah")
assert isinstance(raw_sql, RawSQL)
assert raw_sql.sql == (
"SELECT people_contact.id FROM people_contact, "
"jsonb_array_elements(data->'emails') AS temp_filter_table WHERE "
"unaccent(temp_filter_table->>'value') ILIKE unaccent(%s)"
)
assert raw_sql.params == ["%blah%"]
+1
View File
@@ -0,0 +1 @@
"""Users tests package."""
@@ -0,0 +1,50 @@
"""
Test users API endpoints in the People core app: focus on "create" action
"""
import pytest
from rest_framework.status import (
HTTP_401_UNAUTHORIZED,
HTTP_405_METHOD_NOT_ALLOWED,
)
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_users_create_anonymous():
"""Anonymous users should not be able to create users via the API."""
response = APIClient().post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert "Authentication credentials were not provided." in response.content.decode(
"utf-8"
)
assert models.User.objects.exists() is False
def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
format="json",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "POST" not allowed.'}
assert models.User.objects.exclude(id=user.id).exists() is False
@@ -0,0 +1,81 @@
"""
Test users API endpoints in the People core app: focus on "delete" action
"""
import pytest
from rest_framework.status import (
HTTP_401_UNAUTHORIZED,
HTTP_405_METHOD_NOT_ALLOWED,
)
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_users_delete_list_anonymous():
"""Anonymous users should not be allowed to delete a list of users."""
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.delete("/api/v1.0/users/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert models.User.objects.count() == 2
def test_api_users_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of users."""
user = factories.UserFactory()
factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.delete(
"/api/v1.0/users/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert models.User.objects.count() == 3
def test_api_users_delete_anonymous():
"""Anonymous users should not be allowed to delete a user."""
user = factories.UserFactory()
response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert models.User.objects.count() == 1
def test_api_users_delete_authenticated():
"""
Authenticated users should not be allowed to delete a user other than themselves.
"""
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert models.User.objects.count() == 2
def test_api_users_delete_self():
"""Authenticated users should not be able to delete their own user."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert models.User.objects.count() == 1
@@ -1,24 +1,19 @@
"""
Test users API endpoints in the People core app.
Test users API endpoints in the People core app: focus on "list" action
"""
from unittest import mock
import jq
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_405_METHOD_NOT_ALLOWED,
)
from rest_framework.test import APIClient
from core import factories, models
from core.api.client import serializers
from core.api.client.viewsets import Pagination
from core.factories import TeamAccessFactory
from mailbox_manager.factories import MailDomainAccessFactory
pytestmark = pytest.mark.django_db
@@ -83,7 +78,13 @@ def test_api_users_list_authenticated_response_content(
response = client.get("/api/v1.0/users/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
json = response.json()
edited_json = (
jq.compile(".results[] |= (.organization |= del(.registration_id_list))")
.input(json)
.first()
)
assert edited_json == {
"count": 2,
"next": None,
"previous": None,
@@ -161,7 +162,13 @@ def test_api_users_authenticated_list_by_email():
response = client.get("/api/v1.0/users/?q=ool")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
json = response.json()
edited_json = (
jq.compile(".results[] |= (.organization |= del(.registration_id_list))")
.input(json)
.first()
)
assert edited_json["results"] == [
{
"id": str(frank.id),
"email": frank.email,
@@ -234,7 +241,13 @@ def test_api_users_authenticated_list_by_name():
response = client.get("/api/v1.0/users/?q=oole")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
json = response.json()
edited_json = (
jq.compile(".results[] |= (.organization |= del(.registration_id_list))")
.input(json)
.first()
)
assert edited_json["results"] == [
{
"id": str(frank.id),
"email": frank.email,
@@ -513,427 +526,3 @@ def test_api_users_list_pagination_wrong_page_size(
# Length should not exceed "max_page_size" default value
assert len(content["results"]) == 100
def test_api_users_retrieve_me_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
user = factories.UserFactory(with_organization=True)
client = APIClient()
client.force_login(user)
# Define profile contact
factories.ContactFactory(owner=user, user=user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": str(user.id),
"name": str(user.name),
"email": str(user.email),
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
"is_staff": False,
"abilities": {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": False, "can_view": False},
"teams": {"can_create": False, "can_view": False},
},
"organization": {
"id": str(user.organization.pk),
"name": user.organization.name,
},
}
def test_api_users_retrieve_me_authenticated_abilities():
"""
Authenticated users should be able to retrieve their own user via the "/users/me" path
with the proper abilities.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Define profile contact
factories.ContactFactory(owner=user, user=user)
factories.UserFactory.create_batch(2)
# Test the mailboxes abilities
mail_domain_access = MailDomainAccessFactory(user=user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
assert response.json()["abilities"] == {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": True, "can_view": True},
"teams": {"can_create": False, "can_view": False},
}
# Test the teams abilities - user is not an admin/owner
team_access = TeamAccessFactory(user=user, role=models.RoleChoices.MEMBER)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
assert response.json()["abilities"] == {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": True, "can_view": True},
"teams": {"can_create": False, "can_view": False},
}
# Test the teams abilities - user is an admin/owner
team_access.role = models.RoleChoices.ADMIN
team_access.save()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
assert response.json()["abilities"] == {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": True, "can_view": True},
"teams": {"can_create": True, "can_view": True},
}
# Test the mailboxes abilities - user has no mail domain access anymore
mail_domain_access.delete()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
assert response.json()["abilities"] == {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": False, "can_view": False},
"teams": {"can_create": True, "can_view": True},
}
def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
user = factories.UserFactory()
response = client.get(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_users_retrieve_authenticated_self():
"""
Authenticated users should be allowed to retrieve their own user.
The returned object should not contain the password.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "GET" not allowed.'}
def test_api_users_retrieve_authenticated_other():
"""
Authenticated users should be able to retrieve another user's detail view with
limited information.
"""
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "GET" not allowed.'}
def test_api_users_create_anonymous():
"""Anonymous users should not be able to create users via the API."""
response = APIClient().post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert "Authentication credentials were not provided." in response.content.decode(
"utf-8"
)
assert models.User.objects.exists() is False
def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/users/",
{
"language": "fr-fr",
"password": "mypassword",
},
format="json",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "POST" not allowed.'}
assert models.User.objects.exclude(id=user.id).exists() is False
def test_api_users_update_anonymous():
"""Anonymous users should not be able to update users via the API."""
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = APIClient().put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_update_authenticated_self():
"""
Authenticated users should be able to update their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == HTTP_200_OK
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
if key in ["language", "timezone"]:
assert value == new_user_values[key]
else:
assert value == old_user_values[key]
def test_api_users_update_authenticated_other():
"""Authenticated users should not be allowed to update other users."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_patch_anonymous():
"""Anonymous users should not be able to patch users via the API."""
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = APIClient().patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_patch_authenticated_self():
"""
Authenticated users should be able to patch their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == HTTP_200_OK
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
if key in ["language", "timezone"]:
assert value == new_user_values[key]
else:
assert value == old_user_values[key]
def test_api_users_patch_authenticated_other():
"""Authenticated users should not be allowed to patch other users."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=other_user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.put(
f"/api/v1.0/users/{other_user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
other_user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=other_user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_delete_list_anonymous():
"""Anonymous users should not be allowed to delete a list of users."""
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.delete("/api/v1.0/users/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert models.User.objects.count() == 2
def test_api_users_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of users."""
user = factories.UserFactory()
factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.delete(
"/api/v1.0/users/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert models.User.objects.count() == 3
def test_api_users_delete_anonymous():
"""Anonymous users should not be allowed to delete a user."""
user = factories.UserFactory()
response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert models.User.objects.count() == 1
def test_api_users_delete_authenticated():
"""
Authenticated users should not be allowed to delete a user other than themselves.
"""
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert models.User.objects.count() == 2
def test_api_users_delete_self():
"""Authenticated users should not be able to delete their own user."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert models.User.objects.count() == 1
@@ -0,0 +1,183 @@
"""
Test users API endpoints in the People core app: focus on "retrieve" action
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_405_METHOD_NOT_ALLOWED,
)
from rest_framework.test import APIClient
from core import factories, models
from core.factories import TeamAccessFactory
from mailbox_manager.factories import MailDomainAccessFactory
pytestmark = pytest.mark.django_db
def test_api_users_retrieve_me_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
user = factories.UserFactory(with_organization=True)
client = APIClient()
client.force_login(user)
# Define profile contact
factories.ContactFactory(owner=user, user=user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"id": str(user.id),
"name": str(user.name),
"email": str(user.email),
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
"is_staff": False,
"abilities": {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": False, "can_view": False},
"teams": {"can_create": True, "can_view": True},
},
"organization": {
"id": str(user.organization.pk),
"name": user.organization.name,
"registration_id_list": user.organization.registration_id_list,
},
}
def test_api_users_retrieve_me_authenticated_abilities(settings):
"""
Authenticated users should be able to retrieve their own user via the "/users/me" path
with the proper abilities.
"""
settings.FEATURES = {
"TEAMS_DISPLAY": False,
"TEAMS_CREATE": True,
"CONTACTS_DISPLAY": True,
"CONTACTS_CREATE": True,
"MAILBOXES_CREATE": True,
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Define profile contact
factories.ContactFactory(owner=user, user=user)
factories.UserFactory.create_batch(2)
# Test the mailboxes abilities
mail_domain_access = MailDomainAccessFactory(user=user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
assert response.json()["abilities"] == {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": True, "can_view": True},
"teams": {"can_create": False, "can_view": False},
}
# Test the teams abilities - user is not an admin/owner
team_access = TeamAccessFactory(user=user, role=models.RoleChoices.MEMBER)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
assert response.json()["abilities"] == {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": True, "can_view": True},
"teams": {"can_create": False, "can_view": False},
}
# Test the teams abilities - user is an admin/owner
team_access.role = models.RoleChoices.ADMIN
team_access.save()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
assert response.json()["abilities"] == {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": True, "can_view": True},
"teams": {"can_create": True, "can_view": True},
}
# Test the mailboxes abilities - user has no mail domain access anymore
mail_domain_access.delete()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_200_OK
assert response.json()["abilities"] == {
"contacts": {"can_create": True, "can_view": True},
"mailboxes": {"can_create": False, "can_view": False},
"teams": {"can_create": True, "can_view": True},
}
def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
user = factories.UserFactory()
response = client.get(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_users_retrieve_authenticated_self():
"""
Authenticated users should be allowed to retrieve their own user.
The returned object should not contain the password.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "GET" not allowed.'}
def test_api_users_retrieve_authenticated_other():
"""
Authenticated users should be able to retrieve another user's detail view with
limited information.
"""
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.json() == {"detail": 'Method "GET" not allowed.'}
@@ -0,0 +1,180 @@
"""
Test users API endpoints in the People core app: focus on "update" action
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
from core import factories
from core.api.client import serializers
pytestmark = pytest.mark.django_db
def test_api_users_update_anonymous():
"""Anonymous users should not be able to update users via the API."""
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = APIClient().put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_update_authenticated_self():
"""
Authenticated users should be able to update their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == HTTP_200_OK
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
if key in ["language", "timezone"]:
assert value == new_user_values[key]
else:
assert value == old_user_values[key]
def test_api_users_update_authenticated_other():
"""Authenticated users should not be allowed to update other users."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
response = client.put(
f"/api/v1.0/users/{user.id!s}/",
new_user_values,
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_patch_anonymous():
"""Anonymous users should not be able to patch users via the API."""
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = APIClient().patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
def test_api_users_patch_authenticated_self():
"""
Authenticated users should be able to patch their own user but only "language"
and "timezone" fields.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
old_user_values = dict(serializers.UserSerializer(instance=user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == HTTP_200_OK
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
if key in ["language", "timezone"]:
assert value == new_user_values[key]
else:
assert value == old_user_values[key]
def test_api_users_patch_authenticated_other():
"""Authenticated users should not be allowed to patch other users."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=other_user).data)
new_user_values = dict(
serializers.UserSerializer(instance=factories.UserFactory()).data
)
for key, new_value in new_user_values.items():
response = client.put(
f"/api/v1.0/users/{other_user.id!s}/",
{key: new_value},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
other_user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=other_user).data)
for key, value in user_values.items():
assert value == old_user_values[key]
+41
View File
@@ -0,0 +1,41 @@
"""Helper functions to generate raw SQL queries for Django models."""
from typing import Type
from django.db import models
from django.db.models.expressions import RawSQL
def gen_sql_filter_json_array(
model: Type[models.Model],
lookup_path: str,
nested_key: str,
lookup_value: str,
) -> RawSQL:
"""
Filter a queryset on a nested JSON key in an array field with
a case-insensitive and unaccent match.
Highly inspired from
https://gist.github.com/TobeTek/df2e9783a64e431c228c513441eaa8df#file-utils-py
:param Type[models.Model] model: Your Django model to filter on
:param str lookup_path: The lookup path of the array field/key in
Postgres format e.g `data->"sub-key1"->"sub-key2"`
:param str nested_key: The name of the nested key to filter on
:param str lookup_value: The value to match/filter the queryset on
"""
# Disabling S608 Possible SQL injection vector through string-based query construction
# because we are using Django's RawSQL with parameters.
# Disabling S611 Use of `RawSQL` can lead to SQL injection vulnerabilities
# for the same reason.
table_name = model._meta.db_table # noqa: SLF001
query = (
f"SELECT {table_name}.id FROM {table_name}, " # noqa: S608
f"jsonb_array_elements({lookup_path}) AS temp_filter_table "
f"WHERE unaccent(temp_filter_table->>'{nested_key}') ILIKE unaccent(%s)"
)
return RawSQL(sql=query, params=[f"%{lookup_value}%"]) # noqa: S611
@@ -13,6 +13,7 @@ from django.core.management.base import BaseCommand, CommandError
from django.utils.text import slugify
from faker import Faker
from treebeard.mp_tree import MP_Node
from core import models
@@ -45,7 +46,33 @@ class BulkQueue:
if not objects:
return
objects[0]._meta.model.objects.bulk_create(objects, ignore_conflicts=False) # noqa: SLF001
objects_model = objects[0]._meta.model # noqa: SLF001
if issubclass(objects_model, MP_Node):
# For treebeard models, we need to create the tree structure
# in a specific way. This is not perfect but it works for the
# current use case.
model_fields = {
field
for field in objects_model._meta.concrete_fields # noqa: SLF001
if field.name not in {"depth", "numchild", "path"}
}
bulk_data = [
{
"data": {
field.name: field.value_from_object(obj)
for field in model_fields
if field.value_from_object(obj)
}
}
for obj in objects
]
objects_model.load_bulk(bulk_data)
else:
objects_model.objects.bulk_create(
objects,
ignore_conflicts=False,
)
# In debug mode, Django keeps query cache which creates a memory leak in this case
db.reset_queries()
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
@@ -192,21 +219,15 @@ def create_demo(stdout): # pylint: disable=too-many-locals
)
with Timeit(stdout, "Creating domains"):
created = set()
for _i in range(defaults.NB_OBJECTS["domains"]):
name = fake.domain_name()
if name in created:
continue
created.add(name)
slug = slugify(name)
for i in range(defaults.NB_OBJECTS["domains"]):
name = f"{fake.domain_name()}-i{i:d}"
queue.push(
mailbox_models.MailDomain(
name=name,
# slug should be automatic but bulk_create doesn't use save
slug=slug,
status=random.choice(MailDomainStatusChoices.choices)[0],
slug=slugify(name),
status=random.choice(MailDomainStatusChoices.values),
)
)
queue.flush()
@@ -12,16 +12,17 @@ from core import models
from demo import defaults
from mailbox_manager import models as mailbox_models
TEST_NB_OBJECTS = {
"users": 5,
"teams": 3,
"max_users_per_team": 5,
"domains": 2,
}
pytestmark = pytest.mark.django_db
TEST_NB_OBJECTS = {
"users": 100,
"teams": 100,
"max_users_per_team": 5,
"domains": 100,
}
@override_settings(DEBUG=True)
@mock.patch.dict(defaults.NB_OBJECTS, TEST_NB_OBJECTS)
def test_commands_create_demo():
Binary file not shown.
+255 -156
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-18 23:02+0000\n"
"POT-Creation-Date: 2025-02-03 10:27+0000\n"
"PO-Revision-Date: 2024-01-03 23:15\n"
"Last-Translator: \n"
"Language-Team: French\n"
@@ -17,314 +17,359 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: core/admin.py:55
#: core/admin.py:63
msgid "Personal info"
msgstr ""
msgstr "Informations personnelles"
#: core/admin.py:57
#: core/admin.py:65
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:69
#: core/admin.py:77
msgid "Important dates"
msgstr ""
msgstr "Dates importantes"
#: core/admin.py:108
#: core/admin.py:116
msgid "User"
msgstr ""
msgstr "Utilisateur"
#: core/authentication/backends.py:89
#: core/admin.py:218
msgid "Run post creation plugins"
msgstr "Exécuter les plugins de post-création"
#: core/admin.py:226
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
"Les plugins de post-création ont été exécutés pour les organisations "
"sélectionnées."
#: core/authentication/backends.py:94
msgid "User info contained no recognizable user identification"
msgstr ""
"Les informations de l'utilisateur ne contiennent aucune identification "
"reconnaissable"
#: core/authentication/backends.py:111
#: core/authentication/backends.py:116
msgid "User account is disabled"
msgstr ""
msgstr "Le compte de l'utilisateur est désactivé"
#: core/authentication/backends.py:157
#: core/authentication/backends.py:162
msgid "Claims contained no recognizable user identification"
msgstr ""
"Les claims ne contiennent aucune identification reconnaissable pour "
"l'utilisateur"
#: core/authentication/backends.py:176
#: core/authentication/backends.py:181
msgid "Claims contained no recognizable organization identification"
msgstr ""
"Les claims ne contiennent aucune identification reconnaissable pour "
"l'organisation"
#: core/enums.py:23
msgid "Failure"
msgstr ""
msgstr "En échec"
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:30
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr "En attente"
#: core/enums.py:25
msgid "Success"
msgstr ""
msgstr "Réussi"
#: core/models.py:46
#: core/models.py:47
msgid "Member"
msgstr ""
msgstr "Membre"
#: core/models.py:47 core/models.py:59 mailbox_manager/enums.py:14
#: core/models.py:48 core/models.py:60 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
msgstr "Administrateur"
#: core/models.py:48 mailbox_manager/enums.py:15
#: core/models.py:49 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: core/models.py:71
msgid "id"
msgstr ""
msgstr "Propriétaire"
#: core/models.py:72
msgid "primary key for the record as UUID"
msgstr ""
msgid "id"
msgstr "identifiant"
#: core/models.py:78
msgid "created at"
msgstr ""
#: core/models.py:73
msgid "primary key for the record as UUID"
msgstr "Clé primaire pour l'enregistrement en tant que UUID"
#: core/models.py:79
msgid "date and time at which a record was created"
msgstr ""
msgid "created at"
msgstr "Créé le"
#: core/models.py:84
msgid "updated at"
msgstr ""
#: core/models.py:80
msgid "date and time at which a record was created"
msgstr "Date et heure de création de l'enregistrement"
#: core/models.py:85
msgid "updated at"
msgstr "mis à jour le"
#: core/models.py:86
msgid "date and time at which a record was last updated"
msgstr ""
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: core/models.py:116
#: core/models.py:125
msgid "full name"
msgstr ""
msgstr "nom complet"
#: core/models.py:117
#: core/models.py:126
msgid "short name"
msgstr ""
msgstr "nom court"
#: core/models.py:122
#: core/models.py:129
msgid "notes"
msgstr "notes"
#: core/models.py:131
msgid "contact information"
msgstr ""
msgstr "informations de contact"
#: core/models.py:123
#: core/models.py:132
msgid "A JSON object containing the contact information"
msgstr ""
msgstr "Un objet JSON contenant les informations de contact"
#: core/models.py:137
#: core/models.py:146
msgid "contact"
msgstr ""
msgstr "contact"
#: core/models.py:138
#: core/models.py:147
msgid "contacts"
msgstr ""
msgstr "contacts"
#: core/models.py:262 core/models.py:331 mailbox_manager/models.py:24
#: core/models.py:221 core/models.py:319 core/models.py:441
#: mailbox_manager/models.py:24
msgid "name"
msgstr "nom"
#: core/models.py:223
msgid "audience id"
msgstr ""
#: core/models.py:270
#: core/models.py:228
msgid "service provider"
msgstr "fournisseur de service"
#: core/models.py:229
msgid "service providers"
msgstr "fournisseurs de service"
#: core/models.py:327
msgid "registration ID list"
msgstr ""
msgstr "liste d'identifiants d'inscription"
#: core/models.py:279
#: core/models.py:334
msgid "domain list"
msgstr ""
msgstr "liste de domaines"
#: core/models.py:289
#: core/models.py:350
msgid "organization"
msgstr ""
msgstr "organisation"
#: core/models.py:290
#: core/models.py:351
msgid "organizations"
msgstr ""
msgstr "organisations"
#: core/models.py:297
#: core/models.py:358
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
"Une organisation doit avoir au moins un identifiant d'inscription ou un "
"domaine."
#: core/models.py:316
#: core/models.py:426
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez une sub valide. Cette valeur peut contenir uniquement des lettres, "
"des chiffres et des caractères @/./+/-/_"
#: core/models.py:322
#: core/models.py:432
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:324
#: core/models.py:434
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Requis. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ "
"uniquement."
#: core/models.py:330 core/models.py:737
#: core/models.py:440 core/models.py:880
msgid "email address"
msgstr ""
msgstr "adresse email"
#: core/models.py:343
#: core/models.py:446
msgid "language"
msgstr ""
msgstr "langue"
#: core/models.py:344
#: core/models.py:447
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:350
#: core/models.py:453
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:353
#: core/models.py:456
msgid "device"
msgstr ""
msgstr "appareil"
#: core/models.py:355
#: core/models.py:458
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:358
#: core/models.py:461
msgid "staff status"
msgstr ""
#: core/models.py:360
#: core/models.py:463
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Si l'utilisateur peut se connecter à cette interface d'administration."
#: core/models.py:363
#: core/models.py:466
msgid "active"
msgstr ""
#: core/models.py:366
#: core/models.py:469
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si cet utilisateur doit être considéré comme actif. Désélectionnez cette "
"option au lieu de supprimer les comptes."
#: core/models.py:385
#: core/models.py:488
msgid "user"
msgstr ""
msgstr "utilisateur"
#: core/models.py:386
#: core/models.py:489
msgid "users"
msgstr ""
msgstr "utilisateurs"
#: core/models.py:525
#: core/models.py:623
msgid "Organization/user relation"
msgstr ""
msgstr "Relation entre une organisation et un utilisateur"
#: core/models.py:526
#: core/models.py:624
msgid "Organization/user relations"
msgstr ""
msgstr "Relations entre une organisation et un utilisateur"
#: core/models.py:531
#: core/models.py:629
msgid "This user is already in this organization."
msgstr ""
msgstr "Cet utilisateur est déjà dans cette organisation."
#: core/models.py:563
#: core/models.py:706
msgid "Team"
msgstr ""
msgstr "Equipe"
#: core/models.py:564
#: core/models.py:707
msgid "Teams"
msgstr ""
msgstr "Equipes"
#: core/models.py:615
#: core/models.py:758
msgid "Team/user relation"
msgstr ""
msgstr "Relation entre une équipe et un utilisateur"
#: core/models.py:616
#: core/models.py:759
msgid "Team/user relations"
msgstr ""
msgstr "Relations entre une équipe et un utilisateur"
#: core/models.py:621
#: core/models.py:764
msgid "This user is already in this team."
msgstr ""
msgstr "Cet utilisateur est déjà dans cette équipe."
#: core/models.py:710
#: core/models.py:853
msgid "url"
msgstr ""
msgstr "url"
#: core/models.py:711
#: core/models.py:854
msgid "secret"
msgstr ""
msgstr "secret"
#: core/models.py:720
#: core/models.py:863
msgid "Team webhook"
msgstr ""
msgstr "Webhook d'équipe"
#: core/models.py:721
#: core/models.py:864
msgid "Team webhooks"
msgstr ""
msgstr "Webhooks d'équipe"
#: core/models.py:754
#: core/models.py:897
msgid "Team invitation"
msgstr ""
msgstr "Invitation d'équipe"
#: core/models.py:755
#: core/models.py:898
msgid "Team invitations"
msgstr ""
msgstr "Invitations d'équipe"
#: core/models.py:780
#: core/models.py:923
msgid "This email is already associated to a registered user."
msgstr ""
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: core/models.py:822 core/models.py:828
msgid "Invitation to join Desk!"
msgstr ""
#: core/models.py:965 core/models.py:971
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie!"
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
msgstr "Logo de l'entreprise"
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
msgstr "Bonjour %(name)s"
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
msgstr "Bonjour"
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
msgstr "Merci beaucoup pour votre visite!"
#: core/templates/mail/html/hello.html:221
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
"Cette mail a été envoyée à %(email)s par <a href=\"%(href)s\">%(name)s</a>"
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
msgstr "La Suite Numérique"
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
msgstr "Invitation à rejoindre une équipe"
#: core/templates/mail/html/invitation.html:198
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to"
msgstr ""
msgstr "Bienvenue sur"
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
msgstr "Logo"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
"We are delighted to welcome you to our community on Régie, your new "
"We are delighted to welcome you to our community on La Régie, your new "
"companion to simplify the management of your groups efficiently, "
"intuitively, and securely."
msgstr ""
"Nous sommes ravis de vous accueillir dans notre communauté sur Régie, votre "
"nouveau compagnon pour simplifier la gestion de vos groupes de manière "
"efficace, intuitive et sécurisée."
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
@@ -332,21 +377,25 @@ msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
"Notre application est conçue pour vous aider à organiser, collaborer et "
"gérer les permissions."
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Régie, you will be able to:"
msgstr ""
msgid "With La Régie, you will be able to:"
msgstr "Avec La Régie, vous pourrez :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create customized groups according to your specific needs."
msgstr ""
"Créer des groupes personnalisés en fonction de vos besoins spécifiques."
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your team or community in just a few clicks."
msgstr ""
"Inviter des membres de votre équipe ou de votre communauté en quelques clics."
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
@@ -354,11 +403,15 @@ msgid ""
"Plan events, meetings, or activities effortlessly with our integrated "
"calendar."
msgstr ""
"Planifier des événements, des réunions ou des activités sans effort avec "
"notre calendrier intégré."
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/text/invitation.txt:20
msgid "Share documents, photos, and important information securely."
msgstr ""
"Partager des documents, des photos et des informations importantes de "
"manière sécurisée."
#: core/templates/mail/html/invitation.html:241
#: core/templates/mail/text/invitation.txt:21
@@ -366,18 +419,22 @@ msgid ""
"Facilitate exchanges and communication with our messaging and group "
"discussion tools."
msgstr ""
"Faciliter les échanges et la communication avec nos outils de messagerie et "
"de discussion de groupe."
#: core/templates/mail/html/invitation.html:252
#: core/templates/mail/text/invitation.txt:23
msgid "Visit Régie"
msgstr ""
msgid "Visit La Régie"
msgstr "Visiter La Régie"
#: core/templates/mail/html/invitation.html:261
#: core/templates/mail/text/invitation.txt:25
msgid ""
"We are confident that Régie will help you increase efficiency and "
"We are confident that La Régie will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
"Nous sommes convaincus que La Régie vous aidera à augmenter l'efficacité et "
"la productivité tout en renforçant le lien entre les membres."
#: core/templates/mail/html/invitation.html:266
#: core/templates/mail/text/invitation.txt:26
@@ -386,6 +443,10 @@ msgid ""
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
"N'hésitez pas à explorer toutes les fonctionnalités de l'application et à "
"partager vos commentaires et suggestions avec nous. Vos retours sont "
"précieux pour nous et nous permettront de continuer à améliorer notre "
"service."
#: core/templates/mail/html/invitation.html:271
#: core/templates/mail/text/invitation.txt:27
@@ -393,6 +454,8 @@ msgid ""
"Once again, welcome aboard! We are eager to accompany you on this group "
"management adventure."
msgstr ""
"Encore une fois, bienvenue parmi nous ! Nous sommes impatients de vous "
"accompagner dans cette aventure de gestion de groupe."
#: core/templates/mail/html/invitation.html:278
#: core/templates/mail/html/new_mailbox.html:272
@@ -454,82 +517,118 @@ msgstr "L'équipe de La Suite"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
msgstr "Cette mail a été envoyée à %(email)s par %(name)s [%(href)s]"
#: mailbox_manager/admin.py:12
#: mailbox_manager/admin.py:13
msgid "Synchronise from dimail"
msgstr ""
msgstr "Synchroniser à partir de dimail"
#: mailbox_manager/admin.py:23
#: mailbox_manager/admin.py:24
#, python-brace-format
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
msgstr ""
msgstr "Synchronisation échouée pour {domain.name} avec le message: [{err}]"
#: mailbox_manager/admin.py:29
#: mailbox_manager/admin.py:30
#, python-brace-format
msgid "Synchronisation succeed for {domain.name}. "
msgstr "Synchronisation réussie pour {domain.name}. "
#: mailbox_manager/admin.py:36
msgid "Check and update status from dimail"
msgstr "Vérification et mise à jour de l'état à partir de dimail"
#: mailbox_manager/admin.py:52
#, python-brace-format
msgid "- <b>{domain.name}</b> with message: '{err}'"
msgstr "- <b>{domain.name}</b> avec le message: '{err}'"
#: mailbox_manager/admin.py:63
msgid "Check domains done with success."
msgstr "Vérification des domains réalisée avec success."
#: mailbox_manager/admin.py:64
msgid "Domains updated: {', '.join(domains_updated)}"
msgstr "Domaines mis à jour : {', '.join(domains_updated)}"
#: mailbox_manager/admin.py:66
msgid "No domain updated."
msgstr "Aucun domain n'a été mis à jour."
#: mailbox_manager/admin.py:70
msgid "Check domain failed for:"
msgstr "La vérification de domain a échoué pour :"
#: mailbox_manager/admin.py:76
msgid "Domains disabled are excluded from check: {', '.join(excluded_domains)}"
msgstr ""
"Les domains désactivés sont exclus de la vérification : {', '."
"join(excluded_domains)}"
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
msgstr "Lecteur"
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:31
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr "Actif"
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:32
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En échec"
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:33
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr "Désactivé"
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr "Action requise"
#: mailbox_manager/models.py:35
msgid "Mail domain"
msgstr ""
msgstr "Domaine de messagerie"
#: mailbox_manager/models.py:36
msgid "Mail domains"
msgstr ""
msgstr "Domaines de messagerie"
#: mailbox_manager/models.py:102
msgid "User/mail domain relation"
msgstr ""
msgstr "Relation entre un utilisateur et un domaine de mail"
#: mailbox_manager/models.py:103
msgid "User/mail domain relations"
msgstr ""
msgstr "Relations entre un utilisateur et un domaine de mail"
#: mailbox_manager/models.py:175
msgid "local_part"
msgstr ""
msgstr "local_part"
#: mailbox_manager/models.py:189
msgid "secondary email address"
msgstr ""
msgstr "adresse email secondaire"
#: mailbox_manager/models.py:199
msgid "Mailbox"
msgstr ""
msgstr "Boîte mail"
#: mailbox_manager/models.py:200
msgid "Mailboxes"
msgstr ""
msgstr "Boîtes mails"
#: mailbox_manager/models.py:224
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou modifier une boîte mail pour un domain désactivé."
msgstr ""
"Vous ne pouvez pas créer ou modifier une boîte mail pour un domain désactivé."
#: mailbox_manager/utils/dimail.py:183
#: mailbox_manager/utils/dimail.py:266
msgid "Your new mailbox information"
msgstr "Informations concernant votre nouvelle boîte mail"
#: people/settings.py:135
#: people/settings.py:146
msgid "English"
msgstr ""
msgstr "Anglais"
#: people/settings.py:136
#: people/settings.py:147
msgid "French"
msgstr ""
msgstr "Français"
+68 -4
View File
@@ -1,20 +1,28 @@
"""Admin classes and registrations for People's mailbox manager app."""
from django.contrib import admin, messages
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from requests import exceptions
from mailbox_manager import models
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
@admin.action(description=_("Synchronise from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to synchronize existing mailboxes from dimail to our database."""
"""Admin action to synchronize existing mailboxes from dimail to our database.
Only works on enabled domains."""
excluded_domains = []
client = DimailAPIClient()
for domain in queryset:
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
try:
imported_mailboxes = client.import_mailboxes(domain)
except exceptions.HTTPError as err:
@@ -30,6 +38,58 @@ def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disabl
f"Imported mailboxes: {', '.join(imported_mailboxes)}"
),
)
if excluded_domains:
messages.warning(
request,
_(
f"Sync require enabled domains. Excluded domains: {', '.join(excluded_domains)}"
),
)
@admin.action(description=_("Check and update status from dimail"))
def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to check domain health with dimail and update domain status."""
client = DimailAPIClient()
domains_updated, excluded_domains, msg_error = [], [], []
success = False
for domain in queryset:
# do not check disabled domains
if domain.status == enums.MailDomainStatusChoices.DISABLED:
excluded_domains.append(domain.name)
continue
old_status = domain.status
try:
response = client.fetch_domain_status(domain)
except exceptions.HTTPError as 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
if domain.status == enums.MailDomainStatusChoices.FAILED:
messages.info(request, response.json())
if old_status != domain.status:
domains_updated.append(domain.name)
if success:
msg_success = [
_("Check domains done with success."),
_(f"Domains updated: {', '.join(domains_updated)}")
if domains_updated
else _("No domain updated."),
]
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("<br> ".join(map(str, msg_error))))
if excluded_domains:
messages.warning(
request,
_(
f"Domains disabled are excluded from check: {', '.join(excluded_domains)}"
),
)
class UserMailDomainAccessInline(admin.TabularInline):
@@ -53,15 +113,19 @@ class MailDomainAdmin(admin.ModelAdmin):
)
search_fields = ("name",)
readonly_fields = ["created_at", "slug"]
list_filter = ("status",)
inlines = (UserMailDomainAccessInline,)
actions = (sync_mailboxes_from_dimail,)
actions = (sync_mailboxes_from_dimail, fetch_domain_status_from_dimail)
@admin.register(models.Mailbox)
class MailboxAdmin(admin.ModelAdmin):
"""Admin for mailbox model."""
list_display = ("__str__", "first_name", "last_name", "status")
list_display = ("__str__", "domain", "status", "updated_at")
list_filter = ("status",)
search_fields = ("local_part", "domain__name")
readonly_fields = ["updated_at", "local_part", "domain"]
@admin.register(models.MailDomainAccess)
@@ -1,7 +1,8 @@
"""Client serializers for People's mailbox manager app."""
import json
from logging import getLogger
from requests.exceptions import HTTPError
from rest_framework import exceptions, serializers
from core.api.client.serializers import UserSerializer
@@ -10,6 +11,8 @@ from core.models import User
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
logger = getLogger(__name__)
class MailboxSerializer(serializers.ModelSerializer):
"""Serialize mailbox."""
@@ -31,36 +34,29 @@ class MailboxSerializer(serializers.ModelSerializer):
"""
Override create function to fire a request on mailbox creation.
"""
mailbox_status = enums.MailDomainStatusChoices.PENDING
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
mailbox = super().create(validated_data)
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
client = DimailAPIClient()
# send new mailbox request to dimail
response = client.create_mailbox(
validated_data, self.context["request"].user.sub
)
response = client.create_mailbox(mailbox, self.context["request"].user.sub)
# fix format to have actual json, and remove uuid
mailbox_data = json.loads(
response.content.decode("utf-8").replace("'", '"')
)
del mailbox_data["uuid"]
mailbox_status = enums.MailDomainStatusChoices.ENABLED
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox.save()
# send confirmation email
client.notify_mailbox_creation(
recipient=validated_data["secondary_email"], mailbox_data=mailbox_data
recipient=mailbox.secondary_email, mailbox_data=response.json()
)
# actually save mailbox on our database
return models.Mailbox.objects.create(**validated_data, status=mailbox_status)
return mailbox
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
abilities = serializers.SerializerMethodField(read_only=True)
count_mailboxes = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.MailDomain
@@ -73,6 +69,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
"abilities",
"created_at",
"updated_at",
"count_mailboxes",
]
read_only_fields = [
"id",
@@ -81,6 +78,7 @@ class MailDomainSerializer(serializers.ModelSerializer):
"abilities",
"created_at",
"updated_at",
"count_mailboxes",
]
def get_abilities(self, domain) -> dict:
@@ -90,6 +88,10 @@ class MailDomainSerializer(serializers.ModelSerializer):
return domain.get_abilities(request.user)
return {}
def get_count_mailboxes(self, domain) -> int:
"""Return count of mailboxes for the domain."""
return domain.mailboxes.count()
def create(self, validated_data):
"""
Override create function to fire a request to dimail upon domain creation.
@@ -190,14 +192,20 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
"""
dimail = DimailAPIClient()
user = validated_data["user"]
domain = validated_data["domain"]
if validated_data["role"] in [
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.OWNER,
]:
dimail.create_user(validated_data["user"].sub)
dimail.create_allow(
validated_data["user"].sub, validated_data["domain"].name
)
try:
dimail.create_user(user.sub)
dimail.create_allow(user.sub, domain.name)
except HTTPError:
logger.exception("[DIMAIL] access creation failed %s")
domain.status = enums.MailDomainStatusChoices.FAILED
domain.save()
return super().create(validated_data)
@@ -1,12 +1,13 @@
"""API endpoints"""
from django.db.models import Subquery
from django.db.models import Q, Subquery
from rest_framework import exceptions, filters, mixins, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from core import models as core_models
from core.api.client.serializers import UserSerializer
from mailbox_manager import enums, models
from mailbox_manager.api import permissions
@@ -76,6 +77,9 @@ class MailDomainAccessViewSet(
Return list of all domain accesses related to the logged-in user and one
domain access if an id is provided.
GET /api/v1.0/mail-domains/<domain_slug>/accesses/users/
Return list of all users who can have an access to the domain
POST /api/v1.0/mail-domains/<domain_slug>/accesses/ with expected data:
- user: str
- role: str [owner|admin|viewer]
@@ -183,6 +187,30 @@ class MailDomainAccessViewSet(
return super().destroy(request, *args, **kwargs)
@action(detail=False, url_path="users", methods=["get"])
def get_available_users(self, request, domain_slug):
"""API endpoint to search user to give them new access.
More filters and permission will be added soon.
"""
domain = models.MailDomain.objects.get(slug=domain_slug)
abilities = domain.get_abilities(request.user)
if not abilities["manage_accesses"]:
raise exceptions.PermissionDenied()
queryset = (
core_models.User.objects.order_by("-created_at")
# exclude inactive users and get users from identified user's organization
.filter(is_active=True, organization_id=request.user.organization_id)
# exclude all users with already an access config
.exclude(mail_domain_accesses__domain__slug=domain_slug)
)
# Search by case-insensitive and accent-insensitive
if query := request.GET.get("q", ""):
queryset = queryset.filter(
Q(name__unaccent__icontains=query) | Q(email__unaccent__icontains=query)
)
return Response(UserSerializer(queryset.all(), many=True).data)
class MailBoxViewSet(
mixins.CreateModelMixin,
+1
View File
@@ -22,6 +22,7 @@ class MailDomainStatusChoices(models.TextChoices):
ENABLED = "enabled", _("Enabled")
FAILED = "failed", _("Failed")
DISABLED = "disabled", _("Disabled")
ACTION_REQUIRED = "action_required", _("Action required")
class MailboxStatusChoices(models.TextChoices):
@@ -0,0 +1,58 @@
"""Management command to check and update domain status"""
import logging
from django.core.management.base import BaseCommand
import requests
from mailbox_manager.enums import MailDomainStatusChoices
from mailbox_manager.models import MailDomain
from mailbox_manager.utils.dimail import DimailAPIClient
logger = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Management command to check and update domains status from dimail
"""
help = (
"This command calls dimail to get and update the status of domains."
"All domains without a disabled status will be checked and updated if status"
"sent by dimail does not match our status saved in our database."
)
def handle(self, *args, **options):
"""Handling of the management command."""
self.stdout.write("Start fetching domain status from dimail...")
client = DimailAPIClient()
# do not fetch status of disabled domains
domains = MailDomain.objects.exclude(status=MailDomainStatusChoices.DISABLED)
for domain in domains:
old_status = domain.status
try:
client.fetch_domain_status(domain)
except requests.exceptions.HTTPError as err:
self.stdout.write(
self.style.ERROR(
f"Fetch failed for {domain.name} with message: '{err}'"
)
)
else:
action = "UPDATED" if old_status != domain.status else "CHECKED"
domain_name = (
f"{domain.name[:40]}..." if len(domain.name) > 40 else domain.name
)
self.stdout.write(
self.style.SUCCESS(
(
f"Domain {domain_name}"
+ "." * (50 - len(domain_name))
+ action
)
)
)
self.stdout.write("Done", ending="\n")
@@ -7,8 +7,8 @@ from django.core.management.base import BaseCommand, CommandError
import requests
from rest_framework import status
from mailbox_manager.enums import MailDomainStatusChoices
from mailbox_manager.models import MailDomain
from mailbox_manager import enums
from mailbox_manager.models import MailDomain, MailDomainAccess
User = get_user_model()
@@ -25,11 +25,23 @@ class Command(BaseCommand):
help = "Populate local dimail database, for dev purposes."
def add_arguments(self, parser):
"""Add arguments to the command."""
parser.add_argument(
"--populate-from-people",
action="store_true",
help="Create accounts from already exising account in people database.",
)
def handle(self, *args, **options):
"""Handling of the management command."""
if not settings.DEBUG:
# Allow only in local dev environment: debug or django-configuration is "local"
if (
not settings.DEBUG
and str(settings.CONFIGURATION) != "people.settings.Local"
):
raise CommandError(
("This command is meant to run in local dev environment.")
f"This command is not meant to run in {settings.CONFIGURATION} environment."
)
# Create a first superuser for dimail-api container. User creation is usually
@@ -53,10 +65,9 @@ class Command(BaseCommand):
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
if not MailDomain.objects.filter(name=domain_name).exists():
MailDomain.objects.create(
name=domain_name, status=MailDomainStatusChoices.ENABLED
)
domain = MailDomain.objects.get_or_create(
name=domain_name, defaults={"status": enums.MailDomainStatusChoices.ENABLED}
)[0]
self.create_domain(domain_name)
# we create a dimail user for keycloak+regie user John Doe
@@ -67,8 +78,17 @@ class Command(BaseCommand):
except User.DoesNotExist:
self.stdout.write("people@people.world user not found", ending="\n")
else:
# create accesses for john doe
self.create_user(name=people_base_user.sub)
self.create_allows(people_base_user.sub, domain_name)
MailDomainAccess.objects.get_or_create(
user=people_base_user,
domain=domain,
defaults={"role": enums.MailDomainRoleChoices.OWNER},
)
if options["populate_from_people"]:
self._populate_dimail_from_people()
self.stdout.write("DONE", ending="\n")
@@ -157,3 +177,39 @@ class Command(BaseCommand):
........ failed: {response.json()['detail']}"
)
)
def _populate_dimail_from_people(self):
self.stdout.write("Creating accounts from people database", ending="\n")
user_to_create = set()
domain_to_create = set()
access_to_create = set()
for mail_access in MailDomainAccess.objects.select_related(
"domain", "user"
).all():
user_to_create.add(mail_access.user)
domain_to_create.add(mail_access.domain)
access_to_create.add(mail_access)
# create missing domains
for domain in domain_to_create:
# enforce domain status
if domain.status != enums.MailDomainStatusChoices.ENABLED:
self.stdout.write(
f" - {domain.name} status {domain.status} -> ENABLED"
)
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
self.create_domain(domain.name)
# create missing users
for user in user_to_create:
self.create_user(
auth=(admin["username"], admin["password"]),
name=user.sub,
perms=[], # no permission needed for "classic" users
)
# create missing accesses
for access in access_to_create:
self.create_allows(access.user.sub, access.domain.name)
@@ -0,0 +1,19 @@
# Generated by Django 5.1.5 on 2025-01-31 17:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0015_change_mailboxes_status_to_enabled'),
]
operations = [
migrations.AlterField(
model_name='mailbox',
name='domain',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailboxes', to='mailbox_manager.maildomain'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-03 12:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0016_alter_mailbox_domain'),
]
operations = [
migrations.AlterField(
model_name='maildomain',
name='status',
field=models.CharField(choices=[('pending', 'Pending'), ('enabled', 'Enabled'), ('failed', 'Failed'), ('disabled', 'Disabled'), ('action_required', 'Action required')], default='pending', max_length=20),
),
]
+2 -2
View File
@@ -25,7 +25,7 @@ class MailDomain(BaseModel):
)
slug = models.SlugField(null=False, blank=False, unique=True, max_length=80)
status = models.CharField(
max_length=10,
max_length=20,
default=MailDomainStatusChoices.PENDING,
choices=MailDomainStatusChoices.choices,
)
@@ -181,7 +181,7 @@ class Mailbox(BaseModel):
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
related_name="mail_domain",
related_name="mailboxes",
null=False,
blank=False,
)
@@ -114,6 +114,7 @@ def test_api_mail_domains__create_authenticated():
"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,
}
# a new domain with status "pending" is created and authenticated user is the owner
@@ -122,6 +123,78 @@ def test_api_mail_domains__create_authenticated():
assert domain.accesses.filter(role="owner", user=user).exists()
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.
"""
user = core_factories.UserFactory()
client = APIClient()
client.force_login(user)
domain_name = "test.domain.fr"
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
assert response.json() == {
"id": str(domain.id),
"name": domain.name,
"slug": domain.slug,
"status": enums.MailDomainStatusChoices.FAILED,
"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,
}
# 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()
## SYNC TO DIMAIL
def test_api_mail_domains__create_dimail_domain(caplog):
"""
@@ -71,6 +71,7 @@ def test_api_mail_domains__retrieve_authenticated_related():
domain = factories.MailDomainEnabledFactory()
factories.MailDomainAccessFactory(domain=domain, user=user)
factories.MailboxFactory.create_batch(10, domain=domain)
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/",
@@ -85,4 +86,5 @@ def test_api_mail_domains__retrieve_authenticated_related():
"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": 10,
}
@@ -0,0 +1,203 @@
"""
Tests for MailDomainAccess API endpoint in People's mailbox manager app.
Focus on get available users.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from core import models as core_models
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mail_domain__available_users_anonymous():
"""Anonymous users should not be allowed to list users."""
maildomain = factories.MailDomainFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_mail_domain__available_users_forbidden():
"""Authenticated user without accesses on maildomain should not be able to see available
users.
"""
authenticated_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
maildomain = factories.MailDomainFactory()
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
],
)
def test_api_mail_domain__list_available_users__with_abilities(role):
"""Authenticated users with roles owner and admin should be allowed to list available users
for a domain.
"""
dave = core_factories.UserFactory(email="bowbow@example.com", name="David Bowman")
nicole = core_factories.UserFactory(
email="nicole_foole@example.com", name="Nicole Foole"
)
frank = core_factories.UserFactory(
email="frank_poole@example.com", name="Frank Poole"
)
mary = core_factories.UserFactory(email="mary_pol@example.com", name="Mary Pol")
expected_ids = {str(user.id) for user in core_models.User.objects.all()}
authenticated_user = core_factories.UserFactory(name="Owen Rights")
client = APIClient()
client.force_login(authenticated_user)
maildomain = factories.MailDomainFactory(name="example.com")
factories.MailDomainAccessFactory(
user=authenticated_user,
domain=maildomain,
role=role,
)
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert len(results) == 4
results_id = {result["id"] for result in results}
assert expected_ids == results_id
# now test filter user
response = client.get(
f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/?q=OL"
)
assert response.status_code == status.HTTP_200_OK
expected_ids = {str(user.id) for user in [nicole, frank, mary]}
results = response.json()
assert len(results) == 3
results_id = {result["id"] for result in results}
assert expected_ids == results_id
# filter on email info
response = client.get(
f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/?q=bowbow"
)
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert len(results) == 1
assert results[0]["id"] == str(dave.id)
def test_api_mail_domain__list_available_users__viewer():
"""A viewer should not be allowed to list available users for a domain."""
core_factories.UserFactory.create_batch(10)
authenticated_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
maildomain = factories.MailDomainFactory(name="example.com")
factories.MailDomainAccessFactory(
user=authenticated_user,
domain=maildomain,
role=enums.MailDomainRoleChoices.VIEWER,
)
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
],
)
def test_api_mail_domain__list_available_users__organization(role):
"""If an authenticated owner or admin of a domain has an organization,
only users from the same organization are available.
"""
organization = core_factories.OrganizationFactory(with_registration_id=True)
other_organization = core_factories.OrganizationFactory(with_registration_id=True)
authenticated_user = core_factories.UserFactory(
name="Owen Rights", organization=organization
)
client = APIClient()
client.force_login(authenticated_user)
maildomain = factories.MailDomainFactory(name="example.com")
factories.MailDomainAccessFactory(
user=authenticated_user,
domain=maildomain,
role=role,
)
dave = core_factories.UserFactory(
email="bowbow@example.com",
name="David Bowman",
organization=organization,
)
nicole = core_factories.UserFactory(
email="nicole_foole@example.com",
name="Nicole Foole",
organization=organization,
)
core_factories.UserFactory(
email="frank_poole@example.com",
name="Frank Poole",
organization=other_organization,
)
core_factories.UserFactory(
email="mary_pol@example.com",
name="Mary Pol",
organization=other_organization,
)
expected_ids = sorted([str(nicole.id), str(dave.id)])
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert len(results) == 2
results_id = sorted({result["id"] for result in results})
assert expected_ids == results_id
def test_api_mail_domain__list_available_users__organization_viewer():
"""A viewer should not be allowed to list available users for a domain."""
organization = core_factories.OrganizationFactory(with_registration_id=True)
other_organization = core_factories.OrganizationFactory(with_registration_id=True)
authenticated_user = core_factories.UserFactory(organization=organization)
client = APIClient()
client.force_login(authenticated_user)
maildomain = factories.MailDomainFactory()
factories.MailDomainAccessFactory(
user=authenticated_user,
domain=maildomain,
role=enums.MailDomainRoleChoices.VIEWER,
)
core_factories.UserFactory.create_batch(10, organization=organization)
core_factories.UserFactory.create_batch(5, organization=other_organization)
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -19,6 +19,10 @@ from core import factories as core_factories
from mailbox_manager import enums, factories, models
from mailbox_manager.api.client import serializers
from mailbox_manager.tests.fixtures.dimail import (
TOKEN_OK,
response_mailbox_created,
)
pytestmark = pytest.mark.django_db
@@ -102,19 +106,15 @@ def test_api_mailboxes__create_roles_success(role):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{mail_domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -161,19 +161,15 @@ def test_api_mailboxes__create_with_accent_success(role):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_values['local_part']}@{mail_domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{mail_domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -269,6 +265,102 @@ def test_api_mailboxes__cannot_create_on_disabled_domain(role):
]
def test_api_mailboxes__no_dimail_call_if_mailbox_creation_failed():
"""Duplication case fails on our side at creation step thanks to unique_together
on Mailbox model and no dimail call should be made."""
mail_domain = factories.MailDomainEnabledFactory()
mailbox = factories.MailboxFactory(domain=mail_domain)
access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.ADMIN, domain=mail_domain
)
client = APIClient()
client.force_login(access.user)
# now we try to make the same mailbox
mailbox_data = serializers.MailboxSerializer(mailbox).data
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
# try to create duplicate mailbox but django validation does not agree
assert response.status_code == status.HTTP_400_BAD_REQUEST
# no new mailbox should be created
assert models.Mailbox.objects.count() == 1
# no dimail call should be made
assert len(rsps.calls) == 0
def test_api_mailboxes__same_local_part_on_different_domains():
"""A domain admin should be able to create a mailbox with the same local part
of another mailbox, on different domain."""
# a mailbox exists on another domain
existing_mailbox = factories.MailboxFactory()
access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.ADMIN,
domain=factories.MailDomainEnabledFactory(),
)
client = APIClient()
client.force_login(access.user)
# create a full dict with same local part as another existing mailbox
mailbox_values = serializers.MailboxSerializer(
factories.MailboxFactory.build(local_part=existing_mailbox.local_part)
).data
with responses.RequestsMock() as rsps:
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert (
len(models.Mailbox.objects.filter(local_part=existing_mailbox.local_part)) == 2
)
@pytest.mark.parametrize(
"domain_status",
[
@@ -385,7 +477,7 @@ def test_api_mailboxes__async_dimail_unauthorized(mock_error):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK, # user is in dimail-api
content_type="application/json",
)
@@ -438,19 +530,15 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsp = rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -490,7 +578,7 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
@override_settings(MAIL_PROVISIONING_API_CREDENTIALS="wrongCredentials")
def test_api_mailboxes__dimail_token_permission_denied():
def test_api_mailboxes__dimail_token_permission_denied(caplog):
"""
API should raise a clear "permission denied" error
when receiving a permission denied from dimail upon requesting token.
@@ -524,14 +612,24 @@ def test_api_mailboxes__dimail_token_permission_denied():
assert response.json() == {
"detail": "Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
}
assert not models.Mailbox.objects.exists()
# mailbox was created in our side only and in pending status
mailbox = models.Mailbox.objects.get()
assert mailbox.status == enums.MailboxStatusChoices.PENDING
# Check error logger was called
assert caplog.records[0].levelname == "ERROR"
assert (
caplog.records[0].message
== "[DIMAIL] 403 Forbidden: Could not retrieve a token,"
"please check 'MAIL_PROVISIONING_API_CREDENTIALS' setting."
)
def test_api_mailboxes__user_unrelated_to_domain():
"""
API should raise a clear "permission denied" when dimail returns a permission denied
on mailbox creation. This means token was granted for this user
but user is not allowed to modify this domain (i.e. not owner)
but user is not allowed to modify this domain (i.e. does not have a allow)
"""
# creating all needed objects
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
@@ -547,7 +645,7 @@ def test_api_mailboxes__user_unrelated_to_domain():
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -569,7 +667,9 @@ def test_api_mailboxes__user_unrelated_to_domain():
assert response.json() == {
"detail": "Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
}
assert not models.Mailbox.objects.exists()
# mailbox was created in our side only and in pending status
mailbox = models.Mailbox.objects.get()
assert mailbox.status == enums.MailboxStatusChoices.PENDING
def test_api_mailboxes__handling_dimail_unexpected_error(caplog):
@@ -590,7 +690,7 @@ def test_api_mailboxes__handling_dimail_unexpected_error(caplog):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -612,7 +712,10 @@ def test_api_mailboxes__handling_dimail_unexpected_error(caplog):
assert response.json() == {
"detail": "Unexpected response from dimail: {'details': 'Internal server error'}"
}
assert not models.Mailbox.objects.exists()
# mailbox was created in our side only and in pending status
mailbox = models.Mailbox.objects.get()
assert mailbox.status == enums.MailboxStatusChoices.PENDING
# Check error logger was called
assert caplog.records[0].levelname == "ERROR"
@@ -642,19 +745,15 @@ def test_api_mailboxes__send_correct_logger_infos(mock_info, mock_error):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -703,19 +802,15 @@ def test_api_mailboxes__sends_new_mailbox_notification(mock_info):
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "domain_owner_token"}',
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=str(
{
"email": f"{mailbox_data['local_part']}@{access.domain.name}",
"password": "newpass",
"uuid": "uuid",
}
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
@@ -0,0 +1,128 @@
"""Test the `setup_dimail_db` management command"""
from django.contrib.auth import get_user_model
from django.core.management import call_command
import pytest
import responses
from core import factories
from mailbox_manager import factories as mailbox_factories
from mailbox_manager.management.commands.setup_dimail_db import DIMAIL_URL, admin
pytestmark = pytest.mark.django_db
admin_auth = (admin["username"], admin["password"])
User = get_user_model()
@responses.activate
def test_commands_setup_dimail_db(settings):
"""The create_demo management command should create objects as expected."""
settings.DEBUG = True # required to run the command
john_doe = factories.UserFactory(
name="John Doe", email="people@people.world", sub="sub.john.doe"
)
# mock dimail API
responses.add(responses.POST, f"{DIMAIL_URL}/users/", status=201)
responses.add(responses.POST, f"{DIMAIL_URL}/domains/", status=201)
responses.add(responses.POST, f"{DIMAIL_URL}/allows/", status=201)
responses.add(
responses.GET,
f"{DIMAIL_URL}/users/",
json=[
{
"is_admin": False,
"name": john_doe.sub,
"perms": [],
},
{
"is_admin": True,
"name": "admin",
"perms": [],
},
{
"is_admin": False,
"name": "la_regie",
"perms": [
"new_domain",
"create_users",
"manage_users",
],
},
],
)
call_command("setup_dimail_db")
# check dimail API received the expected requests
assert len(responses.calls) == 5
assert responses.calls[0].request.url == f"{DIMAIL_URL}/users/"
assert (
responses.calls[0].request.body
== b'{"name": "admin", "password": "admin", "is_admin": true, "perms": []}'
)
assert responses.calls[1].request.url == f"{DIMAIL_URL}/users/"
assert responses.calls[1].request.body == (
b'{"name": "la_regie", "password": "password", "is_admin": false, '
b'"perms": ["new_domain", "create_users", "manage_users"]}'
)
assert responses.calls[2].request.url == f"{DIMAIL_URL}/domains/"
assert responses.calls[2].request.body == (
b'{"name": "test.domain.com", "context_name": "context", '
b'"features": ["webmail", "mailbox", "alias"]}'
)
assert responses.calls[3].request.url == f"{DIMAIL_URL}/users/"
assert (
responses.calls[3].request.body
== b'{"name": "sub.john.doe", "password": "no", "is_admin": false, "perms": []}'
)
assert responses.calls[4].request.url == f"{DIMAIL_URL}/allows/"
assert (
responses.calls[4].request.body
== b'{"domain": "test.domain.com", "user": "sub.john.doe"}'
)
# reset the responses counter
responses.calls.reset() # pylint: disable=no-member
# check the command with "populate-from-people" option
mailbox_factories.MailDomainAccessFactory(
domain__name="some.domain.com", user__sub="sub.toto.123"
)
call_command("setup_dimail_db", "--populate-from-people")
# check dimail API received the expected requests
assert (
len(responses.calls) == 5 + 3 + 3
) # calls for some.domain.com and test.domain.com
dimail_calls = []
for call in responses.calls[5:]:
dimail_calls.append((call.request.url, call.request.body))
assert (
f"{DIMAIL_URL}/domains/",
(
b'{"name": "some.domain.com", "context_name": "context", '
b'"features": ["webmail", "mailbox", "alias"]}'
),
) in dimail_calls
assert (
f"{DIMAIL_URL}/users/",
(b'{"name": "sub.toto.123", "password": "no", "is_admin": false, "perms": []}'),
) in dimail_calls
assert (
f"{DIMAIL_URL}/allows/",
b'{"domain": "some.domain.com", "user": "sub.toto.123"}',
) in dimail_calls
@@ -0,0 +1,73 @@
"""Test the `fetch_domain_status_from_dimail` management command"""
import json
import re
from io import StringIO
from django.core.management import call_command
import pytest
import responses
from rest_framework import status
from mailbox_manager import enums, factories
from mailbox_manager.tests.fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
)
pytestmark = pytest.mark.django_db
@responses.activate
def test_fetch_domain_status():
"""Test fetch domain status from dimail"""
domain_enabled1 = factories.MailDomainEnabledFactory()
domain_enabled2 = factories.MailDomainEnabledFactory()
domain_disabled = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.DISABLED
)
domain_failed = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.FAILED
)
body_content_ok1 = CHECK_DOMAIN_OK.copy()
body_content_ok1["name"] = domain_enabled1.name
body_content_broken = CHECK_DOMAIN_BROKEN.copy()
body_content_broken["name"] = domain_enabled2.name
body_content_ok2 = CHECK_DOMAIN_OK.copy()
body_content_ok2["name"] = domain_disabled.name
body_content_ok3 = CHECK_DOMAIN_OK.copy()
body_content_ok3["name"] = domain_failed.name
for domain, body_content in [
(domain_enabled1, body_content_ok1),
(domain_enabled2, body_content_broken),
(domain_failed, body_content_ok3),
]:
# mock dimail API
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
output = StringIO()
call_command("fetch_domain_status", verbosity=2, stdout=output)
domain_enabled1.refresh_from_db()
domain_enabled2.refresh_from_db()
domain_disabled.refresh_from_db()
domain_failed.refresh_from_db()
# nothing change for the first domain enable
assert domain_enabled1.status == enums.MailDomainStatusChoices.ENABLED
# status of the second activated domain has changed to action required
assert domain_enabled2.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
# status of the failed domain has changed to enabled
assert domain_failed.status == enums.MailDomainStatusChoices.ENABLED
# disabled domain was excluded
assert domain_disabled.status == enums.MailDomainStatusChoices.DISABLED
assert output.getvalue().count("CHECKED") == 1
assert output.getvalue().count("UPDATED") == 2
+267
View File
@@ -0,0 +1,267 @@
# pylint: disable=line-too-long
"""Define here some fake data from dimail, useful to mock dimail response"""
import json
## DOMAINS
CHECK_DOMAIN_BROKEN = {
"name": "example.fr",
"state": "broken",
"valid": False,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"webmail_domain": None,
"imap_domain": None,
"smtp_domain": None,
"context_name": "example.fr",
"transport": None,
"domain_exist": {
"ok": True,
"internal": False,
"errors": [],
},
"mx": {
"ok": False,
"internal": False,
"errors": [
{
"code": "wrong_mx",
"detail": "Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr., or je trouve example-fr.mail.protection.outlook.com.",
}
],
},
"cname_imap": {
"ok": False,
"internal": False,
"errors": [
{
"code": "no_cname_imap",
"detail": "Il faut un CNAME 'imap.example.fr' qui renvoie vers 'imap.ox.numerique.gouv.fr.'",
}
],
},
"cname_smtp": {
"ok": False,
"internal": False,
"errors": [
{
"code": "wrong_cname_smtp",
"detail": "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": {
"ok": False,
"internal": False,
"errors": [
{
"code": "no_cname_webmail",
"detail": "Il faut un CNAME 'webmail.example.fr' qui renvoie vers 'webmail.ox.numerique.gouv.fr.'",
}
],
},
"spf": {
"ok": False,
"internal": False,
"errors": [
{
"code": "wrong_spf",
"detail": "Le SPF record ne contient pas include:_spf.ox.numerique.gouv.fr",
}
],
},
"dkim": {
"ok": False,
"internal": False,
"errors": [
{"code": "no_dkim", "detail": "Il faut un DKIM record, avec la bonne clef"}
],
},
"postfix": {
"ok": True,
"internal": True,
"errors": [],
},
"ox": {
"ok": True,
"internal": True,
"errors": [],
},
"cert": {
"ok": False,
"internal": True,
"errors": [
{"code": "no_cert", "detail": "Pas de certificat pour ce domaine (ls)"}
],
},
}
CHECK_DOMAIN_BROKEN_INTERNAL = {
"name": "example.fr",
"state": "broken",
"valid": False,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"webmail_domain": None,
"imap_domain": None,
"smtp_domain": None,
"context_name": "example.fr",
"transport": None,
"domain_exist": {
"ok": True,
"internal": False,
"errors": [],
},
"mx": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_imap": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_smtp": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_webmail": {
"ok": True,
"internal": False,
"errors": [],
},
"spf": {
"ok": True,
"internal": False,
"errors": [],
},
"dkim": {
"ok": True,
"internal": False,
"errors": [],
},
"postfix": {
"ok": True,
"internal": True,
"errors": [],
},
"ox": {
"ok": True,
"internal": True,
"errors": [],
},
"cert": {
"ok": False,
"internal": True,
"errors": [
{"code": "no_cert", "detail": "Pas de certificat pour ce domaine (ls)"}
],
},
}
CHECK_DOMAIN_BROKEN_EXTERNAL = {
"name": "example.fr",
"state": "broken",
"valid": False,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"webmail_domain": None,
"imap_domain": None,
"smtp_domain": None,
"context_name": "example.fr",
"transport": None,
"domain_exist": {
"ok": True,
"internal": False,
"errors": [],
},
"mx": {
"ok": False,
"internal": False,
"errors": [
{
"code": "wrong_mx",
"detail": "Je veux que le MX du domaine soit mx.ox.numerique.gouv.fr., or je trouve example-fr.mail.protection.outlook.com.",
}
],
},
"cname_imap": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_smtp": {
"ok": True,
"internal": False,
"errors": [],
},
"cname_webmail": {
"ok": True,
"internal": False,
"errors": [],
},
"spf": {
"ok": True,
"internal": False,
"errors": [],
},
"dkim": {
"ok": True,
"internal": False,
"errors": [],
},
"postfix": {
"ok": True,
"internal": True,
"errors": [],
},
"ox": {
"ok": True,
"internal": True,
"errors": [],
},
"cert": {
"ok": True,
"internal": True,
"errors": [],
},
}
CHECK_DOMAIN_OK = {
"name": "example.fr",
"state": "ok",
"valid": True,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"webmail_domain": None,
"imap_domain": None,
"smtp_domain": None,
"context_name": "example.fr",
"transport": None,
"domain_exist": {"ok": True, "internal": False, "errors": []},
"mx": {"ok": True, "internal": False, "errors": []},
"cname_imap": {"ok": True, "internal": False, "errors": []},
"cname_smtp": {"ok": True, "internal": False, "errors": []},
"cname_webmail": {"ok": True, "internal": False, "errors": []},
"spf": {"ok": True, "internal": False, "errors": []},
"dkim": {"ok": True, "internal": False, "errors": []},
"postfix": {"ok": True, "internal": True, "errors": []},
"ox": {"ok": True, "internal": True, "errors": []},
"cert": {"ok": True, "internal": True, "errors": []},
}
## TOKEN
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
## MAILBOXES
def response_mailbox_created(email_address):
"""mimic dimail response upon succesfull mailbox creation."""
return json.dumps({"email": email_address, "password": "password"})
@@ -0,0 +1,152 @@
"""
Unit tests for admin actions
"""
import json
import re
from django.urls import reverse
import pytest
import responses
from rest_framework import status
from core import factories as core_factories
from mailbox_manager import enums, factories, models
from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
TOKEN_OK,
response_mailbox_created,
)
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.FAILED,
enums.MailDomainStatusChoices.DISABLED,
],
)
@pytest.mark.django_db
def test_sync_mailboxes__should_not_sync_if_domain_is_not_enabled(
domain_status, client
):
"""Mailboxes should not be sync'ed on non-enabled domains."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain = factories.MailDomainFactory(status=domain_status)
data = {
"action": "sync_mailboxes_from_dimail",
"_selected_action": [domain.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
with responses.RequestsMock():
# No call expected
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
assert (
f"Sync require enabled domains. Excluded domains: {domain}"
in response.content.decode("utf-8")
)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client):
"""Test admin action to check health of some domains"""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain1 = factories.MailDomainEnabledFactory()
domain2 = factories.MailDomainEnabledFactory()
data = {
"action": "fetch_domain_status_from_dimail",
"_selected_action": [
domain1.id,
domain2.id,
],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain1.name
body_content_domain2 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain2["name"] = domain2.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain1.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/{domain2.name}/check/"),
body=json.dumps(body_content_domain2),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
domain1.refresh_from_db()
domain2.refresh_from_db()
assert domain1.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert domain2.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
assert "Check domains done with success" in response.content.decode("utf-8")
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
"""Test admin action should switch domain state to ENABLED
when dimail's response is "ok". It should also activate any pending mailbox."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
domain1 = factories.MailDomainFactory()
factories.MailboxFactory.create_batch(3, domain=domain1)
domain2 = factories.MailDomainFactory()
data = {
"action": "fetch_domain_status_from_dimail",
"_selected_action": [domain1.id],
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain1.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
status=status.HTTP_200_OK,
content_type="application/json",
)
# we need to get a token to create mailboxes
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
body=response_mailbox_created(f"truc@{domain1.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(url, data, follow=True)
assert response.status_code == status.HTTP_200_OK
domain1.refresh_from_db()
domain2.refresh_from_db()
assert domain1.status == enums.MailDomainStatusChoices.ENABLED
assert domain2.status == enums.MailDomainStatusChoices.PENDING
assert "Check domains done with success" in response.content.decode("utf-8")
for mailbox in models.Mailbox.objects.filter(domain=domain1):
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
@@ -1,69 +0,0 @@
"""Test the `setup_dimail_db` management command"""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management import call_command
import pytest
import requests
from mailbox_manager.management.commands.setup_dimail_db import DIMAIL_URL, admin
pytestmark = pytest.mark.django_db
admin_auth = (admin["username"], admin["password"])
User = get_user_model()
@pytest.mark.skipif(
settings.DEBUG is not True,
reason="Run only in local (dimail container not running in other envs)",
)
def test_commands_setup_dimail_db():
"""The create_demo management command should create objects as expected."""
call_command("setup_dimail_db")
# check created users
response = requests.get(url=f"{DIMAIL_URL}/users/", auth=admin_auth, timeout=10)
users = response.json()
# if John Doe exists, we created a dimail user for them
local_user = User.objects.filter(name="John Doe").exists()
assert len(users) == 3 if local_user else 2
# remove uuid because we cannot devine them
[user.pop("uuid") for user in users] # pylint: disable=W0106
if local_user:
assert users.pop() == {
"is_admin": False,
"name": User.objects.get(name="John Doe").uuid,
"perms": [],
}
assert users == [
{
"is_admin": True,
"name": "admin",
"perms": [],
},
{
"is_admin": False,
"name": "la_regie",
"perms": [
"new_domain",
"create_users",
"manage_users",
],
},
]
# check created domains
response = requests.get(url=f"{DIMAIL_URL}/domains/", auth=admin_auth, timeout=10)
domains = response.json()
assert len(domains) == 1
assert domains[0]["name"] == "test.domain.com"
# check created allows
response = requests.get(url=f"{DIMAIL_URL}/allows/", auth=admin_auth, timeout=10)
allows = response.json()
assert len(allows) == 2 if local_user else 1
@@ -2,6 +2,8 @@
Unit tests for dimail client
"""
import json
import logging
import re
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from logging import Logger
@@ -11,9 +13,18 @@ import pytest
import responses
from rest_framework import status
from mailbox_manager import factories, models
from mailbox_manager import enums, factories, models
from mailbox_manager.utils.dimail import DimailAPIClient
from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_BROKEN_EXTERNAL,
CHECK_DOMAIN_BROKEN_INTERNAL,
CHECK_DOMAIN_OK,
TOKEN_OK,
response_mailbox_created,
)
pytestmark = pytest.mark.django_db
@@ -153,4 +164,240 @@ def test_dimail_synchronization__synchronize_mailboxes(mock_warning):
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == "oxadmin"
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
assert imported_mailboxes == [mailbox_valid["email"]]
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.ACTION_REQUIRED,
enums.MailDomainStatusChoices.FAILED,
enums.MailDomainStatusChoices.ENABLED,
],
)
@responses.activate
def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
"""Domains should be enabled when dimail check returns ok status"""
domain = factories.MailDomainFactory(status=domain_status)
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
# call again, should be ok
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.ENABLED,
enums.MailDomainStatusChoices.ACTION_REQUIRED,
enums.MailDomainStatusChoices.FAILED,
],
)
@responses.activate
def test_dimail__fetch_domain_status__switch_to_action_required(
domain_status,
):
"""Domains should be in status action required when dimail check
returns broken status for external checks."""
domain = factories.MailDomainFactory(status=domain_status)
body_content = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
# Support team fixes their part of the problem
# Now domain is OK again
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.ENABLED,
enums.MailDomainStatusChoices.ACTION_REQUIRED,
],
)
@responses.activate
def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
"""Domains should be in status failed when dimail check returns broken status
for only internal checks dispite a fix call."""
domain = factories.MailDomainFactory(status=domain_status)
# nothing can be done by support team, domain should be in failed
body_content = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and still returns KO for internal checks
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.FAILED
@pytest.mark.parametrize(
"domain_status",
[
enums.MailDomainStatusChoices.PENDING,
enums.MailDomainStatusChoices.ENABLED,
enums.MailDomainStatusChoices.ACTION_REQUIRED,
],
)
@responses.activate
def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
"""Domains should be enabled when dimail check returns ok status
after a fix call."""
domain = factories.MailDomainFactory(status=domain_status)
# with all checks KO, domain should be in action required
body_content = CHECK_DOMAIN_BROKEN.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client = DimailAPIClient()
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
# We assume that the support has fixed their part.
# So now dimail returns OK for external checks but still KO for internal checks.
# A call to dimail fix endpoint is necessary and will be done by
# the fetch_domain_status call
body_content = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and returns OK. Hooray!
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
content_type="application/json",
)
dimail_client.fetch_domain_status(domain)
domain.refresh_from_db()
assert domain.status == enums.MailDomainStatusChoices.ENABLED
def test_dimail__enable_pending_mailboxes(caplog):
"""Status of pending mailboxes should switch to "enabled"
when calling enable_pending_mailboxes."""
caplog.set_level(logging.INFO)
domain = factories.MailDomainFactory()
mailbox1 = factories.MailboxFactory(
domain=domain, status=enums.MailboxStatusChoices.PENDING
)
mailbox2 = factories.MailboxFactory(
domain=domain, status=enums.MailboxStatusChoices.PENDING
)
factories.MailboxFactory.create_batch(
2, domain=domain, status=enums.MailboxStatusChoices.ENABLED
)
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.enable_pending_mailboxes(domain=domain)
mailbox1.refresh_from_db()
mailbox2.refresh_from_db()
assert mailbox1.status == enums.MailboxStatusChoices.ENABLED
assert mailbox2.status == enums.MailboxStatusChoices.ENABLED
assert len(caplog.records) == 6
assert (
caplog.records[0].message
== "Token succesfully granted by mail-provisioning API."
)
assert (
caplog.records[1].message
== f"Mailbox successfully created on domain {domain.name} by user None"
)
assert (
caplog.records[2].message
== f"Information for mailbox mock@{domain.name} sent to {mailbox1.secondary_email}."
)
assert (
caplog.records[4].message
== f"Mailbox successfully created on domain {domain.name} by user None"
)
assert (
caplog.records[5].message
== f"Information for mailbox mock@{domain.name} sent to {mailbox2.secondary_email}."
)
+115 -15
View File
@@ -17,7 +17,7 @@ import requests
from rest_framework import status
from urllib3.util import Retry
from mailbox_manager import models
from mailbox_manager import enums, models
logger = getLogger(__name__)
@@ -68,8 +68,8 @@ class DimailAPIClient:
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: Could not retrieve a token,\
please check 'MAIL_PROVISIONING_API_CREDENTIALS' setting.",
"[DIMAIL] 403 Forbidden: Could not retrieve a token,"
"please check 'MAIL_PROVISIONING_API_CREDENTIALS' setting.",
)
raise exceptions.PermissionDenied(
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
@@ -116,15 +116,15 @@ class DimailAPIClient:
"""Send a CREATE mailbox request to mail provisioning API."""
payload = {
"givenName": mailbox["first_name"],
"surName": mailbox["last_name"],
"displayName": f"{mailbox['first_name']} {mailbox['last_name']}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
headers = self.get_headers(user_sub)
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox['domain']}/mailboxes/{mailbox['local_part']}",
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}",
json=payload,
headers=headers,
verify=True,
@@ -141,7 +141,7 @@ class DimailAPIClient:
if response.status_code == status.HTTP_201_CREATED:
logger.info(
"Mailbox successfully created on domain %s by user %s",
str(mailbox["domain"]),
str(mailbox.domain),
user_sub,
)
return response
@@ -149,7 +149,7 @@ class DimailAPIClient:
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(mailbox["domain"]),
str(mailbox.domain),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
@@ -245,11 +245,15 @@ class DimailAPIClient:
except json.decoder.JSONDecodeError:
error_content = response.content.decode(response.encoding)
logger.error(
"[DIMAIL] unexpected error : %s %s", response.status_code, error_content
logger.exception(
"[DIMAIL] unexpected error : %s %s",
response.status_code,
error_content,
exc_info=False,
)
raise requests.exceptions.HTTPError(
f"Unexpected response from dimail: {response.status_code} {error_content}"
f"Unexpected response from dimail: {response.status_code} "
f"{error_content.get('detail') or error_content}"
)
def notify_mailbox_creation(self, recipient, mailbox_data):
@@ -333,10 +337,10 @@ class DimailAPIClient:
last_name=dimail_mailbox["surName"],
local_part=address.username,
domain=domain,
secondary_email=dimail_mailbox[
"email"
], # secondary email is mandatory. Unfortunately, dimail doesn't
secondary_email=dimail_mailbox["email"],
# secondary email is mandatory. Unfortunately, dimail doesn't
# store any. We temporarily give current email as secondary email.
status=enums.MailboxStatusChoices.ENABLED,
)
imported_mailboxes.append(str(mailbox))
else:
@@ -394,3 +398,99 @@ class DimailAPIClient:
)
return response
return self.raise_exception_for_unexpected_response(response)
def enable_pending_mailboxes(self, domain):
"""Send requests for all pending mailboxes of a domain."""
for mailbox in domain.mailboxes.filter(
status=enums.MailboxStatusChoices.PENDING
):
response = self.create_mailbox(mailbox)
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox.save()
# send confirmation email
self.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=response.json(),
)
def check_domain(self, domain):
"""Send a request to dimail to check domain health."""
response = session.get(
f"{self.API_URL}/domains/{domain.name}/check/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
verify=True,
timeout=10,
)
if response.status_code == status.HTTP_200_OK:
return response.json()
return self.raise_exception_for_unexpected_response(response)
def fix_domain(self, domain):
"""Send a request to dimail to fix a domain.
Allow to fix internal checks."""
response = session.get(
f"{self.API_URL}/domains/{domain.name}/fix/",
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
verify=True,
timeout=10,
)
if response.status_code == status.HTTP_200_OK:
logger.info(
"Domain %s successfully fixed by dimail",
str(domain),
)
return response.json()
return self.raise_exception_for_unexpected_response(response)
def fetch_domain_status(self, domain):
"""Send a request to check and update status of a domain."""
dimail_response = self.check_domain(domain)
dimail_state = dimail_response["state"]
# if domain is not enabled and dimail returns ok status, enable it
if (
domain.status != enums.MailDomainStatusChoices.ENABLED
and dimail_state == "ok"
):
self.enable_pending_mailboxes(domain)
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
# if dimail returns broken status, we need to extract external and internal checks
# and manage the case where the domain has to be fixed by support
elif dimail_state == "broken":
external_checks = self._get_dimail_checks(dimail_response, internal=False)
internal_checks = self._get_dimail_checks(dimail_response, internal=True)
# manage the case where the domain has to be fixed by support
if not all(external_checks.values()):
domain.status = enums.MailDomainStatusChoices.ACTION_REQUIRED
domain.save()
# if all external checks are ok but not internal checks, we need to fix internal checks
elif all(external_checks.values()) and not all(internal_checks.values()):
# a call to fix endpoint is needed because all external checks are ok
dimail_response = self.fix_domain(domain)
# we need to check again if all internal and external checks are ok
external_checks = self._get_dimail_checks(
dimail_response, internal=False
)
internal_checks = self._get_dimail_checks(
dimail_response, internal=True
)
if all(external_checks.values()) and all(internal_checks.values()):
domain.status = enums.MailDomainStatusChoices.ENABLED
domain.save()
elif all(external_checks.values()) and not all(
internal_checks.values()
):
domain.status = enums.MailDomainStatusChoices.FAILED
domain.save()
return dimail_response
def _get_dimail_checks(self, dimail_response, internal: bool):
checks = {
key: value
for key, value in dimail_response.items()
if isinstance(value, dict) and value.get("internal") is internal
}
return {key: value.get("ok", False) for key, value in checks.items()}
+1
View File
@@ -51,4 +51,5 @@ urlpatterns = [
),
path(f"api/{settings.API_VERSION}/", include("mailbox_manager.urls")),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
path(f"api/{settings.API_VERSION}/stats/", viewsets.StatView.as_view()),
]
+14
View File
@@ -215,6 +215,7 @@ class Base(Configuration):
"dockerflow.django",
"rest_framework",
"parler",
"treebeard",
"easy_thumbnails",
# Django
"django.contrib.auth",
@@ -473,6 +474,11 @@ class Base(Configuration):
environ_prefix=None,
)
)
ORGANIZATION_PLUGINS = values.ListValue(
default=[],
environ_name="ORGANIZATION_PLUGINS",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
@@ -634,6 +640,10 @@ class Development(Base):
# this is a dev credentials for mail provisioning API
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"
ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
def __init__(self):
"""In dev, force installs needed for Swagger API."""
# pylint: disable=invalid-name
@@ -679,6 +689,10 @@ class Test(Base):
# this is a dev credentials for mail provisioning API
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = "siret"
ORGANIZATION_PLUGINS = ["plugins.organizations.NameFromSiretOrganizationPlugin"]
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
{
"NAME": "django.core.validators.RegexValidator",
+1
View File
@@ -0,0 +1 @@
"""Concrete implementation of plugins which can be used or not in the application."""
+78
View File
@@ -0,0 +1,78 @@
"""Organization related plugins."""
import logging
import requests
from requests.adapters import HTTPAdapter, Retry
from core.plugins.base import BaseOrganizationPlugin
logger = logging.getLogger(__name__)
class NameFromSiretOrganizationPlugin(BaseOrganizationPlugin):
"""
This plugin is used to convert the organization registration ID
to the proper name. For French organization the registration ID
is the SIRET.
This is a very specific plugin for French organizations and this
first implementation is very basic. It surely needs to be improved
later.
"""
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
@staticmethod
def get_organization_name_from_results(data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
is_commune = (
result.get("nature_juridique") == "7210"
) # INSEE code for commune
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
if is_commune:
return organization["libelle_commune"].title()
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title()
if name := result.get("nom_raison_sociale"):
return name.title()
logger.warning("No organization name found for SIRET %s", siret)
return None
def run_after_create(self, organization):
"""After creating an organization, update the organization name."""
if not organization.registration_id_list:
# No registration ID to convert...
return
if organization.name not in organization.registration_id_list:
# The name has probably already been customized
return
# In the nominal case, there is only one registration ID because
# the organization as been created from it.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.get(self._api_url.format(siret=siret), timeout=10)
response.raise_for_status()
data = response.json()
name = self.get_organization_name_from_results(data, siret)
if not name:
return
except requests.RequestException as exc:
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
return
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
+1
View File
@@ -0,0 +1 @@
"""Tests for the plugins module."""
@@ -0,0 +1 @@
"""Test for the Organization plugins module."""
@@ -0,0 +1,210 @@
"""Tests for the NameFromSiretOrganizationPlugin plugin."""
import pytest
import responses
from core.models import Organization
from core.plugins.loader import get_organization_plugins
from plugins.organizations import NameFromSiretOrganizationPlugin
pytestmark = pytest.mark.django_db
# disable unused-argument for because organization_plugins_settings
# is used to set the settings not to be used in the test
# pylint: disable=unused-argument
@pytest.fixture(name="organization_plugins_settings")
def organization_plugins_settings_fixture(settings):
"""
Fixture to set the organization plugins settings and
leave the initial state after the test.
"""
_original_plugins = settings.ORGANIZATION_PLUGINS
settings.ORGANIZATION_PLUGINS = [
"plugins.organizations.NameFromSiretOrganizationPlugin"
]
# reset get_organization_plugins cache
get_organization_plugins.cache_clear()
get_organization_plugins() # call to populate the cache
yield
# reset get_organization_plugins cache
settings.ORGANIZATION_PLUGINS = _original_plugins
get_organization_plugins.cache_clear()
get_organization_plugins() # call to populate the cache
@responses.activate
def test_organization_plugins_run_after_create(organization_plugins_settings):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(
responses.GET,
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={
"results": [
{
# skipping some fields
"matching_etablissements": [
# skipping some fields
{
"liste_enseignes": ["AMAZING ORGANIZATION"],
"siret": "12345678901234",
}
]
}
],
"total_results": 1,
"page": 1,
"per_page": 10,
"total_pages": 1,
},
status=200,
)
organization = Organization.objects.create(
name="12345678901234", registration_id_list=["12345678901234"]
)
assert organization.name == "Amazing Organization"
# Check that the organization has been updated in the database also
organization.refresh_from_db()
assert organization.name == "Amazing Organization"
@responses.activate
def test_organization_plugins_run_after_create_api_fail(organization_plugins_settings):
"""Test the plugin when the API call fails."""
responses.add(
responses.GET,
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={"error": "Internal Server Error"},
status=500,
)
organization = Organization.objects.create(
name="12345678901234", registration_id_list=["12345678901234"]
)
assert organization.name == "12345678901234"
@responses.activate
@pytest.mark.parametrize(
"results",
[
{"results": []},
{"results": [{"matching_etablissements": []}]},
{"results": [{"matching_etablissements": [{"siret": "12345678901234"}]}]},
{
"results": [
{
"matching_etablissements": [
{"siret": "12345678901234", "liste_enseignes": []}
]
}
]
},
],
)
def test_organization_plugins_run_after_create_missing_data(
organization_plugins_settings, results
):
"""Test the plugin when the API call returns missing data."""
responses.add(
responses.GET,
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json=results,
status=200,
)
organization = Organization.objects.create(
name="12345678901234", registration_id_list=["12345678901234"]
)
assert organization.name == "12345678901234"
@responses.activate
def test_organization_plugins_run_after_create_name_already_set(
organization_plugins_settings,
):
"""Test the plugin does nothing when the name already differs from the registration ID."""
organization = Organization.objects.create(
name="Magic WOW", registration_id_list=["12345678901234"]
)
assert organization.name == "Magic WOW"
def test_extract_name_from_org_data_when_commune(
organization_plugins_settings,
):
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
plugin = NameFromSiretOrganizationPlugin()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
@responses.activate
def test_organization_plugins_run_after_create_no_list_enseignes(
organization_plugins_settings,
):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(
responses.GET,
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={
"results": [
{
"nom_raison_sociale": "AMAZING ORGANIZATION",
# skipping some fields
"matching_etablissements": [
# skipping some fields
{
"liste_enseignes": None,
"siret": "12345678901234",
}
],
}
],
"total_results": 1,
"page": 1,
"per_page": 10,
"total_pages": 1,
},
status=200,
)
organization = Organization.objects.create(
name="12345678901234", registration_id_list=["12345678901234"]
)
assert organization.name == "Amazing Organization"
# Check that the organization has been updated in the database also
organization.refresh_from_db()
assert organization.name == "Amazing Organization"
+15 -13
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.8.0"
version = "1.10.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,31 +25,32 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.76",
"boto3==1.36.11",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-parler==2.3",
"django-treebeard==4.7.1",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.4",
"django==5.1.5",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.3",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"joserfc==1.0.1",
"joserfc==1.0.2",
"requests==2.32.3",
"sentry-sdk[django]==2.19.2",
"sentry-sdk[django]==2.20.0",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
]
@@ -63,19 +64,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.12.1",
"drf-spectacular-sidecar==2025.2.1",
"ipdb==0.13.13",
"ipython==8.30.0",
"pyfakefs==5.7.2",
"ipython==8.32.0",
"jq==1.8.0",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.2",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.8.2",
"responses==0.25.6",
"ruff==0.9.4",
"types-requests==2.32.0.20241016",
"freezegun==1.5.1",
]
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+24 -24
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.8.0",
"version": "1.10.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,47 +16,47 @@
},
"dependencies": {
"@gouvfr-lasuite/integration": "1.0.2",
"@hookform/resolvers": "3.9.1",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.62.0",
"i18next": "24.0.2",
"@hookform/resolvers": "3.10.0",
"@openfun/cunningham-react": "3.0.0",
"@tanstack/react-query": "5.66.0",
"i18next": "24.2.2",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.0.3",
"next": "15.1.6",
"react": "*",
"react-dom": "*",
"react-hook-form": "7.53.2",
"react-i18next": "15.1.3",
"react-select": "5.8.3",
"sass": "1.81.0",
"styled-components": "6.1.13",
"zod": "3.23.8",
"zustand": "5.0.1"
"react-hook-form": "7.54.2",
"react-i18next": "15.4.0",
"react-select": "5.10.0",
"sass": "1.83.4",
"styled-components": "6.1.14",
"zod": "3.24.1",
"zustand": "5.0.3"
},
"devDependencies": {
"@hookform/devtools": "4.3.1",
"@hookform/devtools": "4.3.3",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.62.0",
"@tanstack/react-query-devtools": "5.66.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@testing-library/react": "16.2.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/lodash": "4.17.15",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.12",
"@types/react": "19.0.8",
"@types/react-dom": "*",
"dotenv": "16.4.5",
"dotenv": "16.4.7",
"eslint-config-people": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.4.1",
"stylelint": "16.11.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"prettier": "3.4.2",
"stylelint": "16.14.1",
"stylelint-config-standard": "37.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*"
}
}
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import { useConfigStore } from '@/core';
import { useAuthStore } from '@/core/auth';
import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
@@ -20,6 +21,20 @@ describe('Page', () => {
afterEach(() => jest.clearAllMocks());
it('checks Page rendering with team feature', () => {
useAuthStore.setState({
authenticated: true,
userData: {
id: '1',
email: 'test@example.com',
name: 'Test User',
abilities: {
contacts: { can_view: false },
teams: { can_view: false },
mailboxes: { can_view: false },
},
},
});
useConfigStore.setState({
config: {
RELEASE: '1.0.0',
@@ -35,6 +50,20 @@ describe('Page', () => {
});
it('checks Page rendering without team feature', () => {
useAuthStore.setState({
authenticated: true,
userData: {
id: '1',
email: 'test@example.com',
name: 'Test User',
abilities: {
contacts: { can_view: false },
teams: { can_view: false },
mailboxes: { can_view: false },
},
},
});
useConfigStore.setState({
config: {
RELEASE: '1.0.0',
@@ -48,4 +77,62 @@ describe('Page', () => {
expect(mockedPush).toHaveBeenCalledWith('/mail-domains/');
});
it('checks Page rendering with team permission', () => {
useAuthStore.setState({
authenticated: true,
userData: {
id: '1',
email: 'test@example.com',
name: 'Test User',
abilities: {
contacts: { can_view: false },
teams: { can_view: true },
mailboxes: { can_view: false },
},
},
});
useConfigStore.setState({
config: {
RELEASE: '1.0.0',
COMMIT: 'NA',
FEATURES: { TEAMS_DISPLAY: false },
LANGUAGES: [],
},
});
render(<Page />, { wrapper: AppWrapper });
expect(mockedPush).toHaveBeenCalledWith('/teams/');
});
it('checks Page rendering with mailbox permission', () => {
useAuthStore.setState({
authenticated: true,
userData: {
id: '1',
email: 'test@example.com',
name: 'Test User',
abilities: {
contacts: { can_view: false },
teams: { can_view: false },
mailboxes: { can_view: true },
},
},
});
useConfigStore.setState({
config: {
RELEASE: '1.0.0',
COMMIT: 'NA',
FEATURES: { TEAMS_DISPLAY: true },
LANGUAGES: [],
},
});
render(<Page />, { wrapper: AppWrapper });
expect(mockedPush).toHaveBeenCalledWith('/mail-domains/');
});
});
@@ -1,4 +1,4 @@
import { ComponentPropsWithRef, ReactHTML } from 'react';
import { ComponentPropsWithRef } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
@@ -9,7 +9,7 @@ import {
} from '@/utils/styleBuilder';
export interface BoxProps {
as?: keyof ReactHTML;
as?: keyof HTMLElementTagNameMap;
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$color?: CSSProperties['color'];
@@ -17,7 +17,7 @@ export const InfiniteScroll = ({
scrollContainer,
...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => {
const timeout = useRef<ReturnType<typeof setTimeout>>();
const timeout = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
if (!scrollContainer) {
@@ -1,4 +1,4 @@
import { CSSProperties, ComponentPropsWithRef, ReactHTML } from 'react';
import { CSSProperties, ComponentPropsWithRef } from 'react';
import styled from 'styled-components';
import { tokens } from '@/cunningham';
@@ -10,7 +10,7 @@ type TextSizes = keyof typeof sizes;
export interface TextProps extends BoxProps {
as?: keyof Pick<
ReactHTML,
HTMLElementTagNameMap,
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>;
$weight?: CSSProperties['fontWeight'];
@@ -9,6 +9,7 @@ export interface User {
id: string;
email: string;
name?: string;
organization?: Organization;
abilities?: {
mailboxes: UserAbilities;
contacts: UserAbilities;
@@ -16,6 +17,12 @@ export interface User {
};
}
export interface Organization {
id: string;
name: string;
registration_id_list: [string];
}
export type UserAbilities = {
can_view?: boolean;
can_create?: boolean;
@@ -9,12 +9,17 @@ export const AccountDropdown = () => {
const { t } = useTranslation();
const { userData, logout } = useAuthStore();
const userName = userData?.name || t('No Username');
const userName = userData?.name || userData?.email || t('No Username');
return (
<DropButton
button={
<Box $flex $direction="row" $align="center">
<Text $theme="primary">{userName}</Text>
<Box $flex $direction="column" $align="left">
<Text $theme="primary">{userName}</Text>
{userData?.organization?.registration_id_list?.at(0) && (
<Text $theme="primary">{userData?.organization?.name}</Text>
)}
</Box>
<Text className="material-icons" $theme="primary" aria-hidden="true">
arrow_drop_down
</Text>
@@ -40,7 +40,7 @@ describe('AccountDropdown', () => {
});
renderAccountDropdown();
expect(screen.getByText('No Username')).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('opens the dropdown and shows logout button when clicked', async () => {
@@ -126,7 +126,7 @@ describe('MailDomainAccessesPage', () => {
mailDomain: mockMailDomain,
currentRole: Role.OWNER,
},
{}, // adding this empty object is necessary to load jest context
undefined, // adding this undefined value is necessary to load jest context
);
});
});

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