Compare commits

...

150 Commits

Author SHA1 Message Date
mjeammet df93284bf8 🌐(i18n) update translated strings
Update translated files with new translations
2026-03-24 09:28:53 +00:00
Marie PUPO JEAMMET 49d890981f 🔖(minor) release version 1.24.0
Update all version files and changelog for minor release.
2026-03-24 09:28:53 +00:00
Eléonore fd57afb29b 💄(config) update ui configuration domain modale (#1089)
update ui of configuration domain modale
2026-03-23 11:15:41 +01:00
renovate[bot] 99eaec9653 ⬆️(dependencies) update next to v15.5.14 [SECURITY] 2026-03-20 10:51:17 +00:00
renovate[bot] 2ab3a67a44 ⬆️(dependencies) update next to v15.5.13 [SECURITY] 2026-03-20 10:30:25 +00:00
Marie PUPO JEAMMET 0fd29e82d5 🧑‍💻(makefile) build front when bootstraping
add frontend-dev to build command to rebuild front image
2026-03-19 11:39:14 +00:00
Eléonore 0776b7d95a 💄(ui-kit) regie v2 ui-kit (#1083)
update regie to ui-kit v2
2026-03-19 10:56:07 +01:00
Eléonore Voisin 19f7bb77c0 🚚(route) prioritize mail domains as default landing page
add line in changelog
2026-03-19 10:30:19 +01:00
Louis 83cae4036c (frontend) update test to expect /mail-domains/ as fallback redirect
The fallback now always redirects to /mail-domains/, so the test
with TEAMS_DISPLAY=true should expect /mail-domains/ instead of /teams/.
2026-03-19 10:30:19 +01:00
Louis ed07eecad4 🐛(frontend) fix fallback redirect to always go to /mail-domains/
The ternary was incorrectly inverted, causing users to be sent to
/teams/ when TEAMS_DISPLAY was off. Simplify to always redirect
to /mail-domains/ as the fallback.
2026-03-19 10:30:19 +01:00
Louis 46ba75b119 🚸(frontend) prioritize mail domains as default landing page
(made with ai, review carefully)
 Swap the redirect priority so that users landing on the root page
are directed to /mail-domains/ instead of /teams/ by default.
This applies both to the permission-based checks and the fallback
using the TEAMS_DISPLAY feature flag.
2026-03-19 10:30:19 +01:00
Louis 1b4d1043af 🍱(static) update logo in invitation email template (#1085)
Replace logo-footer-mail.png with an updated version in the
invitation email template. Remove unused logo-suite-numerique.png.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:43:29 +01:00
Eléonore bc458b3f68 (datagrid) add sort to mailboxes list + mail domain list (#1057)
add sort to mailboxes list + mail domain list

(invitations) allow delete invitations by an admin (#1052)

allow delete invitations mails domains access by an admin
2026-03-16 16:23:29 +00:00
renovate[bot] 5bc845172b ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] 2026-03-16 11:47:31 +00:00
Marie PUPO JEAMMET 8a35e862ff ♻️(tests) finish cleaning dimail token fixture
finish cleaning the fixtures returning valid
token as dimail
2026-03-13 15:20:04 +00:00
Marie PUPO JEAMMET bc0dbcb678 🩹(mailboxes) enforce mailboxes are lowercase
enforce lowercase for new mailboxes, upon creation
and when sending to dimail
2026-03-13 15:20:04 +00:00
Stephan Meijer 68ec85e94a ⬆️(ci) upgrade GitHub Actions workflow steps to latest versions
Update all GitHub Actions to their latest major versions for improved
performance, security patches, and Node.js runtime compatibility.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-03-13 14:47:35 +00:00
Marie PUPO JEAMMET ca754d5bcd ⬆️(security) upgrade tornado to fix CVE
Upgrade tornado to fix this CVE
https://github.com/tornadoweb/tornado/security/advisories/GHSA-qjxf-f2mg-c6mc
2026-03-13 14:29:15 +00:00
renovate[bot] 06df255072 ⬆️(dependencies) update python dependencies 2026-03-13 14:29:15 +00:00
Jacob Weisz da1a9e1d7a ✏️(typo) fix readme typo
Small fix of a typo in the README
2026-03-12 18:01:45 +00:00
Louis 74ada8fd6b 🐛(i18n) fix missing translations for status tag labels
(made with ai, review carefully)
Status labels in the Tag component ("enabled", "action required", etc.) were
not translated because `t(status)` used a dynamic variable that i18next-parser
cannot extract.

Replace with a static `translations` map using literal `t()` calls so the keys
are properly detected during extraction and sent to Crowdin for translation.

Co-authored-by: BEY Quentin <quentin.bey@mail.numerique.gouv.fr>
2026-03-12 17:33:22 +00:00
renovate[bot] 0c123bb9de ⬆️(dependencies) update django to v5.2.12 [SECURITY] 2026-03-12 17:17:47 +00:00
renovate[bot] 2a6367c3ac ⬆️(dependencies) update joserfc to v1.6.3 [SECURITY] 2026-03-12 17:17:47 +00:00
Quentin BEY 79bdc6606e ⬆️(dependencies) bump django-lasuite to 0.0.25
Required to use the django-lasuite custom admin.
2026-03-10 14:47:33 +01:00
Quentin BEY c53434411d 🔥(admin) switch to django-lasuite customization
The admin application override has been moved into
the django-lasuite library.
2026-02-26 09:21:56 +01:00
Marie PUPO JEAMMET b9c69e7c08 🚨(lint) fix linter warnings
fix linter warnings after ruff upgrade
2026-02-18 18:13:42 +00:00
renovate[bot] b1e0a24626 ⬆️(dependencies) update python dependencies 2026-02-18 18:13:42 +00:00
mjeammet a1b58fb864 🌐(i18n) update translated strings
Update translated files with new translations
2026-02-16 14:39:42 +00:00
Marie PUPO JEAMMET e1a8cc31f9 🔖(patch) release version 1.23.1
Update all version files and changelog for patch release.
2026-02-16 14:39:42 +00:00
Marie PUPO JEAMMET d0e5aa5952 🚚(exceptions) move invitation-related exception to core
move invitation-related from mailbox_manager to core, as
it will be useful for teams too
2026-02-13 11:09:17 +00:00
Marie PUPO JEAMMET 3b58fb7e1e (invitations) refresh invitations
invitations can now be refreshed, de-expiring
them or moving their expiration date further
in the future.
2026-02-13 11:09:17 +00:00
Marie PUPO JEAMMET 1e02ded7b8 ⬆️(dependencies) bump pillow and cryptography to fix CVEs
Bump with `uv lock --upgrade-package pillow` or `cryptography`
This fixes CVEs https://avd.aquasec.com/nvd/cve-2026-26007 and
https://avd.aquasec.com/nvd/cve-2026-25990
2026-02-12 15:18:31 +00:00
mjeammet 92fcb359c9 🌐(i18n) update translated strings
Update translated files with new translations
2026-02-12 15:18:31 +00:00
Marie PUPO JEAMMET 8c9b35ec47 🔖(minor) release version 1.23.0
Update all version files and changelog for minor release.
2026-02-12 15:18:31 +00:00
Marie PUPO JEAMMET 4e75d675f8 (stats) count aliases in stats endpoint
stats endpoint now return the count of aliases
2026-02-12 13:55:53 +00:00
Marie PUPO JEAMMET 1d4d40aad0 🧑‍💻(demo) add aliases to demo
add aliases to all domains in demo
2026-02-12 13:55:53 +00:00
Quentin BEY aaa9b27c61 🚸(email) we should ignore case when looking for existing emails
Our products mostly rely on email regardless of their case.

Next step would be to normalize email to lower case when storing
them in database (or sending them to dimail if not done yet).
2026-02-12 09:47:24 +01:00
elvoisin a29ef05d8b (front) add icon to button to configure a domain (#1054)
Added an icon to help understanding of the configuration button/tag
2026-02-11 15:39:42 +00:00
elvoisin 40c39bd9ba (invitations) allow delete invitations by an admin (#1052)
allow delete invitations mails domains access by an admin
2026-02-11 10:29:03 +01:00
Quentin BEY 59f9f54b34 💚(crowdin) fix upload job
Since the switch to `uv` we should not use pip.
2026-02-10 15:01:50 +01:00
Marie PUPO JEAMMET 7804583632 (tests) simplify mail domain invitations tests
just a quick simplification for our tests regarding domain invitations
2026-02-06 11:09:43 +00:00
Marie PUPO JEAMMET 63b8984eb2 🐛(domains) fix attemps to send invitations to existing users
Users can only search other users inside their own organization.
This leads to a bug where we try to invite an email already linked to an user
2026-02-06 11:09:43 +00:00
renovate[bot] 0df8f53037 ⬆️(dependencies) update next to v15.5.10 [SECURITY] 2026-02-06 10:41:06 +00:00
Marie PUPO JEAMMET 41084baa0f 🔧(docker) set python version to 3.14.2
set python version to 3.14.2 to match dependencies' expectations
2026-02-05 17:12:34 +00:00
renovate[bot] 32de0b9221 ⬆️(dependencies) update python dependencies 2026-02-05 17:12:34 +00:00
renovate[bot] 1384640a3a ⬆️(dependencies) update django to v5.2.11 [SECURITY] 2026-02-03 21:00:40 +00:00
elvoisin 569aff05a1 (front) add show invitations mails domains access (#1040)
* (front) add show invitations mails domains access

add show invitations to mails domains access

* (front) delete invitations mails domains access

add delete button for delete invitations to mails domains access
2026-02-03 16:06:18 +01:00
Quentin BEY b13f4db536 🔧(actions) migrate from pip to uv
Migrate usage of pip to uv in github actions. How python is setup is
also changed. Doing like this, we will just have to upgrade the python
version requirement in the pyproject file
2026-01-29 23:25:51 +01:00
Quentin BEY 8ace3099b9 🔧(backend) ignore .venv in compile messages command
We have to change the ignored files used in the compoilemessages
command. uv is using .venv directory and not venv
2026-01-29 23:17:03 +01:00
Quentin BEY 38d2125ecf 🏗️(core) migrate from pip to uv
We want to migrate our projects from pip to uv to take the benefits of
the lock file and have reproducible installations.
A first uv.lock file is comitted and the Dockerfile and compose are
modified to work with uv
2026-01-29 23:16:16 +01:00
Marie PUPO JEAMMET 6ae195b90c 🔥(cleanup) remove comment from permissions file
remote remaining commented line from previous PR's tests
2026-01-28 14:49:31 +00:00
Marie PUPO JEAMMET be64abb22f (invitations) can delete domain invitations
add delete method to domain viewset
2026-01-28 14:49:31 +00:00
mjeammet ca56eb0cac 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-26 11:30:57 +00:00
Marie PUPO JEAMMET 99ca34719f 🔖(patch) release version 1.22.2
Update all version files and changelog for patch release.
2026-01-26 11:30:57 +00:00
renovate[bot] 014fb62f95 ⬆️(dependencies) update lodash to v4.17.23 [SECURITY] 2026-01-26 10:48:52 +00:00
Marie PUPO JEAMMET 99433a6722 🐛(aliases) alias destination can be devnull@devnull
devnull@devnull is not considered a valid email address by django's
EmailFieldValidator but it's a special address in dimail's config.

Make "destination" a CharField instead of an EmailField to replace
validator and add devnull to allowlist.
2026-01-23 17:56:53 +00:00
Marie PUPO JEAMMET 5ebc88bcff 🔖(patch) release version 1.22.1
Update all version files and changelog for patch release.
2026-01-22 11:10:44 +00:00
Quentin BEY a65e61bd96 🔒️(organization) the first user is not admin
The first user of a organization is probably not an admin.
This was implemented for first tests but for now it's more
a security issue than something helpful.

FIXES #775
2026-01-22 11:26:58 +01:00
Marie PUPO JEAMMET b8beb56135 🐛(admin) fix broken alias import
fix admin action to sync aliases of a given domain
2026-01-21 17:30:30 +00:00
mjeammet 37e5b5b346 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-20 12:29:49 +00:00
Marie PUPO JEAMMET 31201fbd59 🔖(minor) release version 1.22.0
Update all version files and changelog for minor release.
2026-01-20 12:29:49 +00:00
elvoisin eb0683ffe0 (front) create, manage & delete aliases (#1013)
* (front) add aliases

add list view aliases + creation aliases

* (front) add delete alias

add modale to delete aliases

* 🐛(react-query) remove onMutateResult from mutation callbacks

remove onMutateResult from mutation callbacks
2026-01-19 17:04:57 +01:00
Marie PUPO JEAMMET 54219d25b8 (tests) update auth-related tests after bumping django-lasuite
update the expected number of queries in authentication-related tests
due to bumping django-lasuite to 0.0.22
2026-01-19 10:09:29 +00:00
renovate[bot] 89e7703d53 ⬆️(dependencies) update python dependencies
keep django < 6.0 for compatibility with django-celery-beat
See https://github.com/celery/django-celery-beat/issues/977
2026-01-19 10:09:29 +00:00
Marie PUPO JEAMMET 7d44e54913 (admin) manage aliases from admin
manage aliases from admin, including alias creation,
deletion and sync
2026-01-16 15:19:37 +00:00
Marie PUPO JEAMMET 01583ba94f 🐛(aliases) sort aliases by local part
sort aliases by local part to ease front-end interface
2026-01-16 15:19:37 +00:00
Marie PUPO JEAMMET 5feee53bdd 🔒️(security) upgrade python version to fix vulnerability
Vulnerability in jaraco.context caused security issue
in setuptools and python3. change python version to fix
see https://github.com/advisories/GHSA-58pv-8j8x-9vj2
2026-01-16 11:23:55 +00:00
Marie PUPO JEAMMET 9c62efc9f8 🐛(dimail) authorize alias and mailbox with same local part
a mailbox and alias can coexist despite having the same local part
2026-01-15 10:29:49 +00:00
Marie PUPO JEAMMET d2ef9e0beb 🐛(dimail) ignore oxadmin mailboxes when importing mailboxes
oxadmin mailbox are technical mailboxes used by dimail. It should
not be imported when importing mailboxes from dimail.
2026-01-15 10:29:49 +00:00
Marie PUPO JEAMMET bc1cbef168 (aliases) delete all aliases of a given local part
added a bulk delete method for aliases, when filtering on local part
this is convenient when in need to delete the local part and all its
destinations in a single call
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET 8ab1b2e2ef 🔧(settings) separate dimail container from app-dev
separate dimail container from app-dev to avoid setting
dimail container everytime we run the test suite.
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET f498e3b6d2 ♻️(lint) mark a few dimail methods as private
mark a few dimail methods as private, to calm linter
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET e47573e22f (aliases) delete aliases
delete a single alias using its pk
2026-01-12 14:31:53 +00:00
Sabrina Demagny c7aab6f5b5 🔥(plugins) remove CommuneCreation plugin
Remove the French communes auto-provisioning plugin that handled
SIRET lookup, DNS setup, and MailDomain creation.
2026-01-09 09:38:01 +01:00
renovate[bot] 2144ad5da1 ⬆️(dependencies) update next to v15.4.10 [SECURITY] 2025-12-15 14:43:33 +00:00
renovate[bot] f00deeebe9 ⬆️(dependencies) update next to v15.4.9 [SECURITY] 2025-12-12 12:41:33 +00:00
Marie PUPO JEAMMET 30aea9b350 🔖(minor) release version 1.21.0
Update all version files and changelog for minor release.
2025-12-05 16:34:06 +00:00
Marie PUPO JEAMMET e01b422c13 (mailboxes) check imported mailboxes don't clash with existing alias
check that dimail imported mailboxes don't use the same username
as existing aliases.
2025-12-05 15:03:17 +00:00
Marie PUPO JEAMMET 040f949e5e (aliases) import existing aliases from dimail
import existing aliases from dimail, making sure usernames don't clash with
existing mailboxes. Convenient when people fall out of sync
with dimail or for domains partially operated outside people.
2025-12-05 15:03:17 +00:00
renovate[bot] befcecf33c ⬆️(dependencies) update django to v5.2.9 [SECURITY] 2025-12-04 13:21:36 +00:00
renovate[bot] c5edabf3b0 ⬆️(dependencies) update next to v15.4.8 [SECURITY] 2025-12-04 09:36:36 +00:00
Marie PUPO JEAMMET 3a2b5a6428 🛂(permissions) return 404 to users with no domain access
users having no domain access should be forbidden to know the domain is
managed in people. To this effect, 403_FORBIDDEN responses have been
replaced by 404_NOT_FOUND.
2025-11-21 17:04:20 +00:00
Marie PUPO JEAMMET 15337a2226 (aliases) warn that domain is out of sync when unexpected 404
deleting an alias should trigger a request to dimail. if dimail's
response if a 404, it means people and dimail are out of sync with
each other. log error and warn user
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 0f7e312eb6 (mailboxes) cannot create mailbox with same local part as alias
should not be able to create a mailbox having the same local part as an alias
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 23561cd0e0 🔥(sops) remove obsolete sops file
remove obsolete sops file
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 53d0336755 (aliases) delete aliases
add feature to delete aliases
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET b79e12b4be (aliases) list aliases
Can GET a list of all aliases of a domain
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET c237bb4b10 (aliases) create aliases
allow domain managers to create aliases on their domain
2025-11-20 11:24:59 +00:00
renovate[bot] 64068efff4 ⬆️(dependencies) update django to v5.2.8 [SECURITY] 2025-11-06 00:11:45 +01:00
renovate[bot] 6866babb32 ⬆️(dependencies) update Brotli to v1.2.0 [SECURITY] 2025-11-06 00:11:04 +01:00
Quentin BEY 80caa062e9 🐛(mail-domains) fix zod resolver use in forms
References:
https://github.com/react-hook-form/react-hook-form/issues/12816
https://github.com/react-hook-form/resolvers/issues/768
2025-11-04 16:10:27 +01:00
Quentin BEY 513dbe6f83 💚(frontend) fix using side effects within waitFor callback
New lint error detected.
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET 490fdd2ed4 🐛(tests) fix lint-front and test-front errors
fix dependency import error and failing e2e tests due to
@tanstack upgrade
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET fa7d24545f 🔥(config) remove empty duplicate yarn.lock
remove duplicate yarn.lock from previous config attempts
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET e5938e144e 📌(frontend) hold next to v15.4.7
Avoid renovate to bump next to its latest major version.
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET 39af4313cf 🚨(frontend) adapt signatures to @tanstack/react-query to >5.90
Recent upgrade of @tanstack/react-query to
version >5.90 introduced a breaking change in the
onSuccess and onError callback signatures for
the useMutation hook.
The context parameter has been replaced with an
onMutateResult parameter, which provides
information about the result of the
onMutate callback.

Shamelessly copied from https://github.com/suitenumerique/docs/pull/1375/commits
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET abfe32569e ⬇️(dependencies) hold cunningham to v3.2.3
Cunningham v4.0.0 is not yet compatible with lasuite ui-kit
2025-11-04 16:10:27 +01:00
renovate[bot] 964981bbfb ⬆️(dependencies) update js dependencies 2025-11-04 16:10:27 +01:00
mjeammet 33faa5f224 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-22 10:18:21 +00:00
Marie PUPO JEAMMET 99967b450e 🔖(minor) release version 1.20.0
Update all version files and changelog for minor release.
2025-10-22 10:18:21 +00:00
Marie PUPO JEAMMET 71a7bf688f 🐛(mailbox) fix case-sensitive duplicate display names
uniqueness on first name + last name was case-sensitive, which allowed
duplicates
2025-10-22 08:22:48 +00:00
Marie PUPO JEAMMET 302671bc69 ⬆️(dependencies) upgrade node to 22
node 18 reached end-of-life and is now unsupported. we jump straight
to 22 as recommended here
https://nodejs.org/en/blog/announcements/node-18-eol-support
2025-10-21 11:52:51 +00:00
Marie PUPO JEAMMET 4262f469d6 ♻️(tests) refacto dimail tests with fixtures
test_api_mailboxes_create exceeded 1000 lines. By using fixtures, we can
at least factorize dimail response when token is ok or mailbox_data sent
to our API when creating a mailbox.
2025-10-14 10:00:28 +00:00
Marie PUPO JEAMMET b24cb23a83 (models) impose uniqueness on display name, to match ox's constraint
OpenXchange's primary key is display name (= first name + last name).
It must be unique in the domain's context. We don't have context info
but we can impose uniqueness by domain.
2025-10-14 10:00:28 +00:00
Marie PUPO JEAMMET 608f8c6988 🐛(dimail) grab duplicate displayname error
OpenXchange's primary key is display name (= first name + last name).
In absence of clear error message from dimail (yet), we catch errors 500 and
check if they're not due to the display name already existing in the context
2025-10-14 10:00:28 +00:00
renovate[bot] 2ddfef59d8 ⬆️(dependencies) update next to v15.4.7 [SECURITY] 2025-10-13 10:01:30 +00:00
renovate[bot] 6b8cb16bd5 ⬆️(dependencies) update django to v5.2.7 [SECURITY] 2025-10-13 08:50:32 +00:00
Marie PUPO JEAMMET 8b89a1112d 📝(release) update release.md
Update documentation on release
2025-09-23 18:56:56 +02:00
Laurent Bossavit 230ff21220 (mailbox) synchronize password of newly created mailbox with Dimail's
When using La Régie as Identity Provider this allows signing in.
2025-09-23 17:40:08 +02:00
Quentin BEY a37c2a8e83 🔒️(frontend) update alpine packages in production image
Force an update of installed package in the image used for
the frontend in production.
2025-09-23 14:31:13 +02:00
mjeammet 80da8bea74 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-19 17:04:02 +02:00
Marie PUPO JEAMMET 76f4bf36c7 🔖(patch) release version 1.19.1
Update all version files and changelog for patch release.
2025-09-19 17:04:02 +02:00
Eléonore Voisin 78a5d907ca 🐛(fix) add enabled update your mailbox
can update your mailbox as viewer
2025-09-19 16:45:40 +02:00
Marie PUPO JEAMMET 0ad60ab292 ⬆️(backend) bump Django to 5.2.6
Bump Django version to 5.2.6, fixing CVE-2025-57833. See
https://www.djangoproject.com/weblog/2025/sep/03/security-releases/
2025-09-15 14:36:26 +02:00
Quentin BEY ac443d3b6f 🔒️(all) refactor Docker Hub login to use official GitHub actions
Replace custom Docker Hub authentication with standard, secure,
official GitHub actions for improved security and maintainability.

Uses officially supported actions that follow security best practices
and receive regular updates from GitHub.

Avoid unsecure handling of GitHub secrets.

Thanks to @lebaudantoine
2025-09-05 14:55:17 +02:00
Quentin BEY 48ff0e9f3a 🔧(ci) always run all git-lint steps
git-lint steps are independant and we would like to have all checks at
once. Using the `if: always()` instruction should ensure all steps
should be run event if the previous fails.

thanks @lunika
2025-09-05 14:44:20 +02:00
mjeammet 675e719a22 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-03 11:52:42 +02:00
mjeammet 0875fae87b 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-03 11:52:42 +02:00
Marie PUPO JEAMMET 668a296142 🔖(minor) release version 1.19.0
Update all version files and changelog for minor release.
2025-09-03 11:52:42 +02:00
elvoisin f1892b7049 (front) add modal update mailboxes (#954)
* (front) add modal update mailboxes

add modal update mailboxes

* ️(labels) improve aria-labels on domain panel's buttons

improve descriptions on aria-label on domain panel's buttons

---------

Co-authored-by: Marie PUPO JEAMMET <marie.pupojeammet@numerique.gouv.fr>
2025-09-03 11:02:23 +02:00
Marie PUPO JEAMMET 1bfad507ef (api) retrieve mailboxes
add feature to retrieve mailboxes when having the right access
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET 72e73bff45 (api) give update rights to domain viewer on own mailbox
Introduces the notion of self in permissions
allowing a domain viewer to update their own mailbox.
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET e45cf8dd8b (api) update mailboxes
Allow update of mailboxes. Secondary email, first and last names can be updated
but not domain or local_part.
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET 79f8e5276a 💚(ci) fix changelog CI job
The command git watchanged is deprecated and is flag to be removed soon.
It can be easily replace with git log
2025-08-29 15:32:26 +02:00
Marie PUPO JEAMMET 4f97685204 🐛(admin) fix mailbox import from dimail
importing mailboxes from dimail was broken due to a change of format in dimail's
response.
2025-08-29 11:46:33 +02:00
Marie PUPO JEAMMET 935058582e ⬆️(dependencies) upgrade dimail to 0.5.2
manually upgrade dimail to match prod version
2025-08-29 11:46:33 +02:00
Marie PUPO JEAMMET 636b27321b 🧑‍💻(demo) set invitations for local user
prepare invitations for local user. Upon first connexion
to keycloack, invitations are consumed and dev are ready to go
:)
2025-08-26 17:39:58 +02:00
Marie PUPO JEAMMET 2fdddb3e12 🧑‍💻(demo) add mailboxes to demo
add mailboxes to demo domains.
2025-08-26 17:39:58 +02:00
Quentin BEY f7a97e11e8 🎨(ruff) fix linter error after ruff update
Fix the `make lint` errors after dependencies update.
2025-08-26 11:36:05 +02:00
renovate[bot] a56f86965f ⬆️(dependencies) update python dependencies 2025-08-26 09:23:11 +00:00
Hsiaoming Yang 89674ad718 🎨(joserfc) fix import path of RSAKey
`rfc7518` module will be removed in v1.4.0 of joserfc.
2025-08-26 10:59:35 +02:00
Quentin BEY 4e592382dc 💥(sentry) remove DJANGO_ before Sentry DSN env variable
Other "La Suite" projects don't have the "DJANGO_" prefix, so
we align this project with others.
2025-07-24 23:53:47 +02:00
Marie PUPO JEAMMET 1fdd2e052b 🐛(config) remove default sentry_dsn
set default sentry_dsn to None to avoid all outside
deployments sending infos to our sentry instance.
2025-07-23 12:08:28 +02:00
Quentin BEY 25918830ff ⬆️(django-lasuite) bump to version 0.0.11
This allows to accept JWE from new Proconnect introspection endpoint.
2025-07-09 14:47:55 +02:00
Marie PUPO JEAMMET b47282430e 🔖(patch) release version 1.18.2
Update all version files and changelog for patch release.
2025-07-03 11:28:01 +02:00
Marie PUPO JEAMMET ebd3f467c4 🐛(admin) fix read_only preventing mailbox creation in admin
removed read_only on "local_part" and "domain" which prevented
admin user from creating mailboxes from admin
2025-07-02 18:06:02 +02:00
Marie PUPO JEAMMET 0f4ec63a9f 🐛(webhooks) fix special case for tchap integration
we want to use Tchap integration server for our own preprod
but it doesn't follow the same url naming convention as the
other Tchap instances, so it requires a special treatment.
This quick fix should do it.
2025-07-02 17:52:44 +02:00
Eléonore Voisin fdec3b4196 🐛(front) fix missing pagination mail domains
fix position div ref
2025-07-02 17:38:21 +02:00
Marie PUPO JEAMMET b78723ecce 🔖(patch) release version 1.18.1
Update all version files and changelog for patch release.
2025-07-02 11:29:10 +02:00
Marie PUPO JEAMMET df0cea225c 🐛(changelog) add missing changelog
add missing changelog for consistency
2025-07-02 11:29:10 +02:00
mjeammet 3b73cca2f5 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-02 11:29:10 +02:00
elvoisin 06d4d5c9e8 🐛(front) fix missing pagination mail domains (#946)
fix missing pagination mail domains + mailboxes list
2025-07-02 09:03:11 +02:00
mjeammet 405e5fda90 🌐(i18n) update translated strings
Update translated files with new translations
2025-06-30 15:23:08 +02:00
Marie PUPO JEAMMET 8a434448dd 🔖(minor) release version 1.18.0
Update all version files and changelog for minor release.
2025-06-30 15:23:08 +02:00
Marie PUPO JEAMMET 5b5bd312b4 🔧(backend) fix Redis dependency to <6.0.0
Pin the redis dependency to <6.0.0 to fix ResolutionImpossible errors
in future renovate PRs.
See https://github.com/suitenumerique/meet/pull/553
2025-06-30 11:57:28 +02:00
Marie PUPO JEAMMET 99bf301a9c ⬆️(dependencies) update python dependencies
manually upgrade dependencies
2025-06-30 11:57:28 +02:00
Marie PUPO JEAMMET 3dc11c9b52 🐛(webhook) search existing Matrix user before inviting
fix wrong formatting of user id + now searches for existing Matrix
account of the user before inviting them to webhook room.
2025-06-27 16:34:18 +02:00
Marie PUPO JEAMMET 5327baacbd 🔧(settings) rename matrix bot token variable
rename "tchap access token" to "matrix bot access token" to
fit general uses
2025-06-27 16:34:18 +02:00
Manuel Raynaud 709628a26a 🔧(back) remove usage of deprecated db engine (#937)
The db engine postgresql_psycopg2 does not exists anymore in django but
for BC compat it is possible to use it in the configuration and it is
replace by postgresql at runtime. We changed this settings to use the
good one.
2025-06-27 15:18:27 +02:00
Marie PUPO JEAMMET d2bf267160 🐛(webhooks) fix InsecureRequestWarning from webhook calls
Matrix webhook calls were unverified and caused a security warning.
Setting option "verify" to "True" on related calls should help.
2025-06-25 19:54:45 +02:00
270 changed files with 27190 additions and 10885 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
@@ -48,7 +48,7 @@ jobs:
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+12 -12
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
with-build_mails: true
@@ -20,19 +20,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
working-directory: src/backend
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -45,10 +45,10 @@ jobs:
- name: generate pot files
working-directory: src/backend
run: |
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
DJANGO_CONFIGURATION=Build uv run python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+9 -9
View File
@@ -5,7 +5,7 @@ on:
inputs:
node_version:
required: false
default: '18.x'
default: '22.x'
type: string
with-front-dependencies-installation:
type: boolean
@@ -20,16 +20,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
@@ -37,7 +37,7 @@ jobs:
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -50,10 +50,10 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -61,7 +61,7 @@ jobs:
- name: Setup Node.js
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node_version }}
@@ -79,7 +79,7 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Call argocd github webhook
run: |
+11 -5
View File
@@ -21,7 +21,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Docker meta
id: meta
@@ -48,7 +48,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Docker meta
id: meta
@@ -58,7 +58,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
@@ -82,7 +85,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Docker meta
id: meta
@@ -99,7 +102,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
+47 -34
View File
@@ -13,7 +13,7 @@ jobs:
dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
with-build_mails: true
@@ -22,20 +22,24 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep -E "(\bprint\(|\bpprint\()"
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
@@ -45,17 +49,17 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
run: git log --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -69,10 +73,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -82,7 +86,7 @@ jobs:
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
@@ -92,10 +96,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -109,10 +113,10 @@ jobs:
needs: dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
@@ -132,7 +136,7 @@ jobs:
shardTotal: [4]
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set services env variables
run: |
@@ -140,7 +144,7 @@ jobs:
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -148,14 +152,14 @@ jobs:
fail-on-cache-miss: true
- name: Restore the frontend cache
uses: actions/cache@v4
uses: actions/cache@v5
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
uses: actions/cache@v5
id: cache-build
with:
path: src/frontend/apps/desk/out/
@@ -216,19 +220,22 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: uv run pylint .
test-back:
runs-on: ubuntu-latest
@@ -262,29 +269,35 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Install Python
uses: actions/setup-python@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the dependencies
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
run: uv run pytest -n 2
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
-11
View File
@@ -1,11 +0,0 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
+135 -1
View File
@@ -8,8 +8,125 @@ and this project adheres to
## [Unreleased]
## [1.24.0] - 2026-03-24
### Fixed
- ✨(mailboxes) enforce lowercase on mailboxes
- 🐛(i18n) fix missing translations for status tag labels
- 🚚(route) prioritize mail domains as default landing page
### Changed
- 💄(config) update ui configuration domain modale
- 🍱(static) update logo in invitation email template #1085
- ✨(uiV2) use Lasuite UI kit, new layout
## [1.23.1] - 2026-02-16
- ✨(invitations) refresh expired invitations
## [1.23.0] - 2026-02-12
### Added
- ✨(demo) add aliases to demo #1050
- ✨(front) add icon to button to configure a domain
- ✨(datagrid) add sort to mailboxes list + mail domain list
- ✨(invitations) allow delete invitations mails domains access by an admin
- ✨(front) delete invitations mails domains access
- ✨(front) add show invitations mails domains access #1040
- ✨(invitations) can delete domain invitations
### Fixed
- 🐛(domains) fix attemps to send invitations to existing users #953
### Changed
- 🚸(email) we should ignore case when looking for existing emails #1056
- 🏗️(core) migrate from pip to uv
- ✨(front) add show invitations mails domains access #1040
## [1.22.2] - 2026-01-26
### Fixed
- 🐛(aliases) authorize special domain devnull in alias destinations #1029
## [1.22.1] - 2026-01-21
- 🔒️(organization) the first user is not admin #776
- 🐛(admin) fix broken alias import #1021
## [1.22.0] - 2026-01-19
### Added
- ✨(front) create, manage & delete aliases
- ✨(domains) alias sorting and admin
- ✨(aliases) delete all aliases in one call #1002
### Fixed
- 🔒️(security) upgrade python version to fix vulnerability #1010
- 🐛(dimail) ignore oxadmin when importing mailboxes from dimail #986
- ✨(aliases) fix deleting single aliases #1002
### Changed
- 🐛(dimail) allow mailboxes and aliases to have the same local part #986
### Removed
- 🔥(plugins) remove CommuneCreation plugin
## [1.21.0] - 2025-12-05
- ✨(aliases) import existing aliases from dimail
- 🛂(permissions) return 404 to users with no access to domain #985
- ✨(aliases) can create, list and delete aliases #974
## [1.20.0] - 2025-10-22
- ✨(models) impose uniqueness on display name, to match ox's constraint
- 🐛(dimail) catch duplicate displayname error
- ✨(mailbox) synchronize password of newly created mailbox with Dimail's
## [1.19.1] - 2025-09-19
- 🐛(fix) add enabled update your mailbox
## [1.19.0] - 2025-09-03
- ✨(front) add modal update mailboxes #954
### Added
- ✨(api) update mailboxes #934
- ✨(api) give update rights to domain viewer on own mailbox #934
### Fixed
- 🐛(dimail) grab duplicate displayname error #961
### Changed
- 💥(sentry) remove `DJANGO_` before Sentry DSN env variable #957
## [1.18.2] - 2025-07-03
### Fixed
- 🐛(front) fix missing pagination mail domains #950
## [1.18.1] - 2025-07-02
### Fixed
- 🐛(front) fix missing pagination on mail domains #946
## [1.18.0] - 2025-06-30
### Added
- 🐛(front) fix missing pagination mail domains
- 🐛(front) fix button add mail domain
- ✨(teams) add matrix webhook for teams #904
- ✨(resource-server) add SCIM /Me endpoint #895
@@ -19,6 +136,10 @@ and this project adheres to
- 🧑‍💻(docker) split frontend to another file #924
### Fixed
- 🐛(webhook) handle user on different home server than room server
## [1.17.0] - 2025-06-11
### Added
@@ -405,7 +526,20 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/suitenumerique/people/compare/v1.17.0...main
[unreleased]: https://github.com/suitenumerique/people/compare/v1.24.0...main
[1.24.0]: https://github.com/suitenumerique/people/releases/v1.24.0
[1.23.1]: https://github.com/suitenumerique/people/releases/v1.23.1
[1.23.0]: https://github.com/suitenumerique/people/releases/v1.23.0
[1.22.2]: https://github.com/suitenumerique/people/releases/v1.22.2
[1.22.1]: https://github.com/suitenumerique/people/releases/v1.22.1
[1.22.0]: https://github.com/suitenumerique/people/releases/v1.22.0
[1.21.0]: https://github.com/suitenumerique/people/releases/v1.21.0
[1.20.0]: https://github.com/suitenumerique/people/releases/v1.20.0
[1.19.1]: https://github.com/suitenumerique/people/releases/v1.19.1
[1.19.0]: https://github.com/suitenumerique/people/releases/v1.19.0
[1.18.2]: https://github.com/suitenumerique/people/releases/v1.18.2
[1.18.1]: https://github.com/suitenumerique/people/releases/v1.18.1
[1.18.0]: https://github.com/suitenumerique/people/releases/v1.18.0
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
[1.15.0]: https://github.com/suitenumerique/people/releases/v1.15.0
+37 -25
View File
@@ -1,10 +1,7 @@
# Django People
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
FROM python:3.14.2-alpine AS base
# Upgrade system packages to install security updates
RUN apk update && \
@@ -13,17 +10,30 @@ RUN apk update && \
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Copy required python dependencies
COPY ./src/backend /builder
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
RUN mkdir /install && \
pip install --prefix=/install .
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:22 AS mail-builder
COPY ./src/mail /mail/app
@@ -42,14 +52,13 @@ RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy people application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
@@ -80,14 +89,18 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy people application (see .dockerignore)
COPY ./src/backend /app/
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
# Ensure the uv venv is used at runtime
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
@@ -102,10 +115,9 @@ USER root:root
# Install psql
RUN apk add postgresql-client
# Uninstall people and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y people
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.10,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Restore the un-privileged user running the application
ARG DOCKER_USER
+9 -8
View File
@@ -107,7 +107,8 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev
@$(COMPOSE) build app-dev frontend-dev
@$(COMPOSE) build dimail
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@@ -123,7 +124,7 @@ run: ## start the wsgi (production) and servers with production Docker images
.PHONY: run
run-dev: ## start the servers in development mode (watch) Docker images
@$(COMPOSE) up --force-recreate --detach app-dev frontend-dev celery-dev celery-beat-dev nginx maildev
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
.PHONY: run-dev
status: ## an alias for "docker compose ps"
@@ -214,7 +215,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@@ -240,21 +241,21 @@ resetdb: ## flush database and create a superuser "admin"
.PHONY: resetdb
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
cp --update=none env.d/development/common.dist env.d/development/common
env.d/development/france:
cp -n env.d/development/france.dist env.d/development/france
cp --update=none env.d/development/france.dist env.d/development/france
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
cp --update=none env.d/development/postgresql.dist env.d/development/postgresql
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
cp --update=none env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
# -- Internationalization
env.d/development/crowdin:
cp -n env.d/development/crowdin.dist env.d/development/crowdin
cp --update=none env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from Crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
+1 -1
View File
@@ -1,6 +1,6 @@
# People
People is an application to handle users and teams, and distribute permissions accross [La Suite](https://lasuite.numerique.gouv.fr/).
People is an application to handle users and teams, and distribute permissions across [La Suite](https://lasuite.numerique.gouv.fr/).
It is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).
+4 -4
View File
@@ -42,12 +42,11 @@ services:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_healthy
restart: true
dimail:
condition: service_started
maildev:
condition: service_started
redis:
@@ -119,6 +118,7 @@ services:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_healthy
@@ -199,7 +199,7 @@ services:
working_dir: /app
node:
image: node:18
image: node:22
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -259,7 +259,7 @@ services:
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.2.11
image: registry.mim-libre.fr/dimail/dimail-api:v0.5.2
pull_policy: always
environment:
DIMAIL_MODE: FAKE
+27 -23
View File
@@ -17,48 +17,52 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
This script will ask you for the version you want to release and the kind of release (patch, minor, major). It will then:
1. Create a new branch named: `release/4.18.1`.
- Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend and frontend project.
- Bump the release number for backend and frontend project.
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
- Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
- Commit your changes with the following format: 🔖 release emoji, type of release (patch/minor/patch) and release version:
```text
🔖(minor) release version 4.18.1
```
3. Open a pull request ask you to wait for an approval from your peers and merge it.
- Triggers Crowdin translation action and open related PR
4. Ask you to tag and push your commit:
- Open release PR. Wait for an approval from your peers and merge it.
> [!NOTE]
> It also open the PR for pre-prod deployment, see following section.
3. Following release script instructions,
- merge translation and release pulls requests
- tag and push your commit:
```bash
git tag v4.18.1 && git push origin tag v4.18.1
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
This triggers the CI building new Docker images. You can ensure images were successfully built on Docker Hub [here for back-end](https://hub.docker.com/r/lasuite/people-backend/tags) and [here for front-end](https://hub.docker.com/r/lasuite/people-frontend/tags).
5. Ensure the new [backend](https://hub.docker.com/r/lasuite/people-backend/tags) and [frontend](https://hub.docker.com/r/lasuite/people-frontend/tags) image tags are on Docker Hub.
6. Create a PR on the [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement) repository to bump the preprod version.
# Deploying
7. The release is now done!
## Staging
# Deploying
The `staging` platform is deployed automatically with every update of the `main` branch.
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
## Pre-prod and production
Making a new release doesn't publish it automatically in production.
If you used the release script and had permission to push on [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement), a deployment branch has been created. You can skip step 1.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
Otherwise, for manual preprod and for production deployments :
1. Bump tag version for both front-end and back-end images in the `.gotmpl` file located in `manifests/<your-product>/env.d/<your-environment>/`,
2. Add optional new secrets and variables, if applicable
3. Create a pull request
4. Submit to approval and merge PR
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
The release is now done! 🎉
-1
View File
@@ -1,2 +1 @@
INSTALLED_PLUGINS=plugins.la_suite
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
-1
View File
@@ -1 +0,0 @@
"""People custom admin site."""
-9
View File
@@ -1,9 +0,0 @@
"""Custom Django admin site application configuration."""
from django.contrib.admin.apps import AdminConfig
class PeopleAdminConfig(AdminConfig):
"""Declare our custom Django admin site."""
default_site = "admin.sites.PeopleAdminSite"
-15
View File
@@ -1,15 +0,0 @@
"""Custom Django admin site for the People app."""
from django.conf import settings
from django.contrib import admin
class PeopleAdminSite(admin.AdminSite):
"""People custom admin site."""
def each_context(self, request):
"""Add custom context to the admin site."""
return super().each_context(request) | {
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
}
@@ -1,13 +0,0 @@
{% extends "admin/base_site.html" %}
{% block extrastyle %}{{ block.super }}
<style>
html[data-theme="light"], :root {
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
}
ul.messagelist li.info {
background-color: #EEEEEE;
}
</style>
{% endblock %}
+1
View File
@@ -621,6 +621,7 @@ class StatView(views.APIView):
status=enums.MailDomainStatusChoices.ENABLED
).count(),
"mailboxes": domains_models.Mailbox.objects.count(),
"aliases": domains_models.Alias.objects.count(),
}
return response.Response(context)
@@ -17,8 +17,6 @@ from core.models import (
AccountService,
Contact,
Organization,
OrganizationAccess,
OrganizationRoleChoices,
)
logger = logging.getLogger(__name__)
@@ -131,15 +129,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
user = super().create_user(claims | {"organization": organization})
if organization_created:
# Warning: we may remove this behavior in the near future when we
# add a feature to claim the organization ownership.
OrganizationAccess.objects.create(
organization=organization,
user=user,
role=OrganizationRoleChoices.ADMIN,
)
# Initiate the user's profile
Contact.objects.create(
owner=user,
+18
View File
@@ -0,0 +1,18 @@
"""
Custom exceptions for mailbox manager app
"""
from django.utils.translation import gettext_lazy as _
from rest_framework import status
from rest_framework.exceptions import APIException
class EmailAlreadyKnownException(APIException):
"""Exception raised when trying to create a user with an already existing email address."""
status_code = status.HTTP_201_CREATED
default_detail = _(
"Email already known. Invitation not sent but access created instead."
)
default_code = "email already known"
-1
View File
@@ -1,4 +1,3 @@
# ruff: noqa: S311
"""
Core application factories
"""
@@ -11,7 +11,7 @@ def update_team_paths(apps, schema_editor):
steplen = 5
# Initialize NumConv with the specified custom alphabet
converter = NumConv(len(alphabet), alphabet)
converter = NumConv(alphabet)
nodes = Team.objects.all().order_by("created_at")
for i, node in enumerate(nodes, 1):
+13 -9
View File
@@ -32,6 +32,7 @@ from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager
from core.enums import WebhookProtocolChoices, WebhookStatusChoices
from core.exceptions import EmailAlreadyKnownException
from core.plugins.registry import registry as plugin_hooks_registry
from core.utils.webhooks import webhooks_synchronizer
from core.validators import get_field_validators_from_setting
@@ -813,7 +814,7 @@ class Team(MP_Node, BaseModel):
except AttributeError:
try:
role = self.accesses.filter(user=user).values("role")[0]["role"]
except (TeamAccess.DoesNotExist, IndexError):
except TeamAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
@@ -900,7 +901,7 @@ class TeamAccess(BaseModel):
role = self._meta.model.objects.filter(
team=self.team_id, user=user
).values("role")[0]["role"]
except (self._meta.model.DoesNotExist, IndexError):
except self._meta.model.DoesNotExist, IndexError:
role = None
is_team_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
@@ -996,19 +997,22 @@ class BaseInvitation(BaseModel):
super().clean()
# Check if a user already exists for the provided email
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
if User.objects.filter(email__iexact=self.email).exists():
raise EmailAlreadyKnownException
def refresh(self):
"""A simple way to refresh invitation and move expiration date."""
self.clean()
self.updated_at = timezone.now()
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
if not self.created_at:
if not self.updated_at:
return None
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
return timezone.now() > (self.updated_at + validity_duration)
def _get_mail_subject(self):
"""Get the subject of the invitation."""
@@ -1096,7 +1100,7 @@ class Invitation(BaseInvitation):
role = self.team.accesses.filter(user=user).values("role")[0][
"role"
]
except (self._meta.model.DoesNotExist, IndexError):
except self._meta.model.DoesNotExist, IndexError:
role = None
can_delete = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
+2 -1
View File
@@ -18,6 +18,7 @@ class BasePluginAppConfigMixIn:
Initialize the hooks registry when the application is ready.
This is called by Django when the application is loaded.
"""
from .registry import registry # pylint: disable=import-outside-toplevel
# pylint: disable=import-outside-toplevel
from .registry import registry # noqa: PLC0415
registry.register_app(self.name) # pylint: disable=no-member
+1 -1
View File
@@ -9,7 +9,7 @@ plugins_urlpatterns = []
for app in settings.INSTALLED_PLUGINS:
try:
plugins_urlpatterns.append(path("", include(f"{app}.urls")))
except (ImportError, AttributeError):
except ImportError, AttributeError:
# Skip if app doesn't have urls.py
continue
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -100,7 +100,7 @@ def test_authentication_getter_existing_user_change_fields(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -160,7 +160,8 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(3): # user by email + user by sub + update sub
with django_assert_num_queries(5):
# 5 = user by email + user by sub + update sub + 2 from django-lasuite
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -390,6 +391,8 @@ def test_authentication_getter_new_user_with_registration_id_new_organization(
assert user.organization.domain_list == expected_domain_list
assert user.organization.registration_id_list == expected_registration_id_list
assert models.OrganizationAccess.objects.filter(user=user).exists() is False
def test_authentication_getter_existing_user_via_email_update_organization(
django_assert_num_queries, monkeypatch
+50 -2
View File
@@ -3,6 +3,54 @@
from rest_framework import status
# SEARCH
def mock_search_empty():
"""Mock response when no Matrix user has been found through search."""
return {
"message": {"limited": "false", "results": []},
"status_code": status.HTTP_200_OK,
}
def mock_search_successful(user):
"""Mock response when exactly one user has been found through search."""
return {
"message": {
"limited": "false",
"results": [
{
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"display_name": f"@{user.name} [Fake]",
"avatar_url": "null",
},
],
},
"status_code": status.HTTP_200_OK,
}
def mock_search_successful_multiple(user):
"""Mock response when more than one user has been found through search."""
return {
"message": {
"limited": "false",
"results": [
{
"user_id": f"@{user.email.replace('@', '-')}:user_server1.com",
"display_name": f"@{user.name} [Fake]",
"avatar_url": "null",
},
{
"user_id": f"@{user.email.replace('@', '-')}:user_server2.com",
"display_name": f"@{user.name} [Other Fake]",
"avatar_url": "null",
},
],
},
"status_code": status.HTTP_200_OK,
}
# JOIN ROOMS
def mock_join_room_successful(room_id):
"""Mock response when succesfully joining room. Same response if already in room."""
@@ -39,7 +87,7 @@ def mock_invite_user_already_in_room(user):
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"{user.email.replace('@', ':')} is already in the room.",
"error": f"{user.email.replace('@', '-')}:home_server.fr is already in the room.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
@@ -56,7 +104,7 @@ def mock_kick_user_forbidden(user):
return {
"message": {
"errcode": "M_FORBIDDEN",
"error": f"You cannot kick user @{user.email.replace('@', ':')}.",
"error": f"You cannot kick user @{user.email.replace('@', '-')}.",
},
"status_code": status.HTTP_403_FORBIDDEN,
}
@@ -9,7 +9,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.rfc7518.rsa_key import RSAKey
from joserfc.jwk import RSAKey
from jwt.utils import to_base64url_uint
from lasuite.oidc_resource_server.authentication import (
ResourceServerAuthentication,
@@ -236,6 +236,73 @@ def test_api_team_accesses_create__with_scim_webhook():
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__with_matrix_webhook():
"""
If a team has a Matrix webhook, creating a team access should fire a call
with the expected payload.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(
team=team,
url="https://server.fr/#/room/room_id:room_server.fr",
secret="some-secret-you-should-not-store-on-a-postit",
protocol=enums.WebhookProtocolChoices.MATRIX,
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
# Mock successful responses by matrix server
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
content_type="application/json",
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(other_user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert len(responses.calls) == 3
assert (
responses.calls[0].request.url
== "https://room_server.fr/_matrix/client/v3/rooms/room_id:room_server.fr/join"
)
# Payload sent to matrix server
assert webhook.secret in responses.calls[0].request.headers["Authorization"]
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{other_user.email.replace('@', '-')}:user_server.com",
"reason": f"User added to team {webhook.team} on People",
}
assert models.TeamAccess.objects.filter(user=other_user, team=team).exists()
@responses.activate
def test_api_team_accesses_create__multiple_webhooks_success(caplog):
"""
When the team has multiple webhooks, creating a team access should fire all the expected calls.
@@ -251,7 +318,7 @@ def test_api_team_accesses_create__multiple_webhooks_success(caplog):
)
webhook_matrix = factories.TeamWebhookFactory(
team=team,
url="https://www.webhookserver.fr/#/room/room_id:home_server/",
url="https://www.webhookserver.fr/#/room/room_id:home_server.fr/",
protocol=enums.WebhookProtocolChoices.MATRIX,
secret="yo",
)
@@ -261,39 +328,40 @@ def test_api_team_accesses_create__multiple_webhooks_success(caplog):
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
# Ensure successful response by scim provider using "responses":
responses.patch(
re.compile(r".*/Groups/.*"),
body="{}",
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
status=matrix.mock_invite_successful()["status_code"],
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
# Logger
log_messages = [msg.message for msg in caplog.records]
+10 -8
View File
@@ -11,7 +11,6 @@ from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories as domains_factories
from mailbox_manager import models as domains_models
pytestmark = pytest.mark.django_db
@@ -21,18 +20,20 @@ def test_api_stats__anonymous(django_assert_num_queries):
domains_factories.MailDomainEnabledFactory.create_batch(5)
core_factories.TeamFactory.create_batch(3)
domains_factories.AliasFactory.create_batch(2)
# clear cache to allow stats count
cache.clear()
with django_assert_num_queries(5):
with django_assert_num_queries(6):
response = APIClient().get("/api/v1.0/stats/")
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"total_users": 0,
"mau": 0,
"active_domains": 5,
"active_domains": 7,
"mailboxes": 0,
"teams": 3,
"aliases": 2,
}
# no new request made due to caching
with django_assert_num_queries(0):
@@ -41,9 +42,10 @@ def test_api_stats__anonymous(django_assert_num_queries):
assert response.json() == {
"total_users": 0,
"mau": 0,
"active_domains": 5,
"active_domains": 7,
"mailboxes": 0,
"teams": 3,
"aliases": 2,
}
@@ -58,10 +60,9 @@ def test_api_stats__expected_count():
core_factories.TeamFactory.create_batch(3)
domains_factories.MailDomainFactory.create_batch(1)
domains_factories.MailDomainEnabledFactory.create_batch(2)
domains_factories.MailboxFactory.create_batch(
10, domain=domains_models.MailDomain.objects.all()[1]
)
enabled_domain, _ = domains_factories.MailDomainEnabledFactory.create_batch(2)
domains_factories.MailboxFactory.create_batch(10, domain=enabled_domain)
domains_factories.AliasFactory.create_batch(2, domain=enabled_domain)
# clear cache to allow stats count
cache.clear()
@@ -73,4 +74,5 @@ def test_api_stats__expected_count():
"active_domains": 2,
"mailboxes": 10,
"teams": 3,
"aliases": 2,
}
@@ -171,10 +171,11 @@ def test_api_team_invitations__create__cannot_invite_existing_users():
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
def test_api_team_invitations__list__anonymous_user():
@@ -16,6 +16,7 @@ from faker import Faker
from freezegun import freeze_time
from core import factories, models
from core.exceptions import EmailAlreadyKnownException
pytestmark = pytest.mark.django_db
@@ -48,6 +49,17 @@ def test_models_invitations_team_required():
factories.InvitationFactory(team=None)
def test_models_invitations_email_case_insensitive_duplicate_check():
"""The email validation should be case-insensitive when checking for existing users."""
# Create a user with a lowercase email
factories.UserFactory(email="john.doe@example.com")
# Try to create an invitation with different case
# This should raise the same exception as if the email was exactly the same
with pytest.raises(EmailAlreadyKnownException):
factories.InvitationFactory(email="John.Doe@Example.COM")
def test_models_invitations_team_should_be_team_instance():
"""The "team" field should be a team instance."""
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
@@ -13,11 +13,63 @@ from rest_framework import status
from core import factories
from core.enums import WebhookProtocolChoices
from core.tests.fixtures import matrix
from core.utils.matrix import MatrixAPIClient
from core.utils.webhooks import webhooks_synchronizer
pytestmark = pytest.mark.django_db
## SEARCH
@responses.activate
def test_matrix_webhook__search_user_unknown(caplog):
"""When searching for a user in the Matrix federation but cannot find any,
we invite a (future ?) user using user's email and room's server."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server.au",
secret="secret-access-token",
)
client = MatrixAPIClient()
# Mock successful responses
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_empty()["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.get_user_id(user=user, webhook=webhook)
assert response == f"@{user.email.replace('@', '-')}:room_server.au"
@responses.activate
def test_matrix_webhook__search_multiple_ids(caplog):
"""When searching for a user in Matrix federation,
if user directory returns multiple ids, invite the first one."""
caplog.set_level(logging.INFO)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:room_server.au",
secret="secret-access-token",
)
client = MatrixAPIClient()
# Mock successful responses
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful_multiple(user)["message"]),
status=status.HTTP_200_OK,
content_type="application/json",
)
response = client.get_user_id(user=user, webhook=webhook)
assert response == f"@{user.email.replace('@', '-')}:user_server1.com"
## INVITE
@responses.activate
def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
@@ -38,6 +90,11 @@ def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(error["message"]),
@@ -64,6 +121,11 @@ def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_user_already_in_room(user)["message"]),
@@ -101,6 +163,11 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
@@ -113,8 +180,8 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
assert webhook.secret in headers["Authorization"]
# Check payloads sent to Matrix API
assert json.loads(responses.calls[1].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}",
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"reason": f"User added to team {webhook.team} on People",
}
@@ -131,7 +198,7 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
@responses.activate
@override_settings(TCHAP_ACCESS_TOKEN="TCHAP_TOKEN")
@override_settings(MATRIX_BOT_ACCESS_TOKEN="TCHAP_TOKEN")
def test_matrix_webhook__override_secret_for_tchap():
"""The user passed to the function should get invited."""
user = factories.UserFactory()
@@ -147,6 +214,11 @@ def test_matrix_webhook__override_secret_for_tchap():
body=str(matrix.mock_join_room_successful("room_id")["message"]),
status=matrix.mock_join_room_successful("room_id")["status_code"],
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/invite"),
body=str(matrix.mock_invite_successful()["message"]),
@@ -158,6 +230,9 @@ def test_matrix_webhook__override_secret_for_tchap():
headers = responses.calls[0].request.headers
assert "TCHAP_TOKEN" in headers["Authorization"]
# Check correctly inferred base url from room_id
assert "matrix.home_server" in responses.calls[0].request.url
## KICK
@responses.activate
@@ -178,6 +253,11 @@ def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_user_not_in_room()["message"]),
@@ -205,7 +285,7 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(
protocol=WebhookProtocolChoices.MATRIX,
url="https://www.matrix.org/#/room/room_id:home_server",
url="https://www.matrix.org/#/room/room_id:room_server",
secret="secret-access-token",
)
@@ -214,6 +294,11 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_successful),
@@ -222,8 +307,8 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
# Check payloads sent to Matrix API
assert json.loads(responses.calls[1].request.body) == {
"user_id": f"@{user.email.replace('@', ':')}",
assert json.loads(responses.calls[2].request.body) == {
"user_id": f"@{user.email.replace('@', '-')}:user_server.com",
"reason": f"User removed from team {webhook.team} on People",
}
@@ -258,6 +343,11 @@ def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
body=str(matrix.mock_join_room_successful),
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(error["message"]),
+27 -11
View File
@@ -29,13 +29,11 @@ session.mount("https://", adapter)
class MatrixAPIClient:
"""A client to interact with Matrix API"""
secret = settings.TCHAP_ACCESS_TOKEN
def get_headers(self, webhook):
"""Build header dict from webhook object."""
headers = {"Content-Type": "application/json"}
if "tchap.gouv.fr" in webhook.url:
token = settings.TCHAP_ACCESS_TOKEN
token = settings.MATRIX_BOT_ACCESS_TOKEN
elif webhook.secret:
token = webhook.secret
else:
@@ -47,16 +45,31 @@ class MatrixAPIClient:
"""Returns room id from webhook url."""
room_id = webhook_url.split("/room/")[1]
base_url = room_id.split(":")[1]
if "tchap.gouv.fr" in webhook_url:
if "tchap.gouv.fr" in webhook_url and "i.tchap.gouv.fr" not in webhook_url:
base_url = f"matrix.{base_url}"
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
def get_user_id(self, user):
def get_user_id(self, user, webhook):
"""Returns user id from email."""
if user.email is None:
raise ValueError("You must first set an email for the user.")
return f"@{user.email.replace('@', ':')}"
if settings.MATRIX_BASE_HOME_SERVER:
home_server = settings.MATRIX_BASE_HOME_SERVER
search = session.post(
f"{home_server}/_matrix/client/v3/user_directory/search",
json={"search_term": f"@{user.email.replace('@', '-')}"},
headers=self.get_headers(webhook),
verify=True,
timeout=3,
)
results = search.json()["results"]
if len(results) > 0:
return results[0]["user_id"]
# try and invite unknown user using room home server
room_home_server = webhook.url.split(":")[2]
return f"@{user.email.replace('@', '-')}:{room_home_server}"
def join_room(self, webhook):
"""Accept invitation to the room. As of today, it is a mandatory step
@@ -65,7 +78,7 @@ class MatrixAPIClient:
f"{self._get_room_url(webhook.url)}/join",
json={},
headers=self.get_headers(webhook),
verify=False,
verify=True,
timeout=3,
)
@@ -78,8 +91,11 @@ class MatrixAPIClient:
webhook.url,
)
return join_response, False
logger.info(
"Succesfully joined room",
)
user_id = self.get_user_id(user)
user_id = self.get_user_id(user, webhook)
response = session.post(
f"{self._get_room_url(webhook.url)}/invite",
json={
@@ -87,7 +103,7 @@ class MatrixAPIClient:
"reason": f"User added to team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=False,
verify=True,
timeout=3,
)
@@ -112,7 +128,7 @@ class MatrixAPIClient:
)
return join_response, False
user_id = self.get_user_id(user)
user_id = self.get_user_id(user, webhook)
response = session.post(
f"{self._get_room_url(webhook.url)}/kick",
json={
@@ -120,7 +136,7 @@ class MatrixAPIClient:
"reason": f"User removed from team {webhook.team} on People",
},
headers=self.get_headers(webhook),
verify=False,
verify=True,
timeout=3,
)
-1
View File
@@ -32,7 +32,6 @@ class WebhookClient:
response, webhook_succeeded = self._get_response_and_status(
name, webhook, user
)
if response is not None:
extra = {"response": response.content}
# pylint: disable=no-member
+3 -1
View File
@@ -4,5 +4,7 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 20,
"domains": 10,
"mailboxes_per_domain": 2,
"aliases_per_domain": 2,
}
@@ -181,7 +181,7 @@ def create_oidc_people_idp_client_user():
)
def create_demo(stdout): # pylint: disable=too-many-locals
def create_demo(stdout): # pylint: disable=too-many-branches too-many-statements too-many-locals # noqa: PLR0912, PLR0915
"""
Create a database with demo data for developers to work in a realistic environment.
The code is engineered to create a huge number of objects fast.
@@ -288,14 +288,12 @@ def create_demo(stdout): # pylint: disable=too-many-locals
)
queue.flush()
domains = mailbox_models.MailDomain.objects.all()
with Timeit(stdout, "Creating accesses to domains"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
for domain in domains:
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domain_id,
domain=domain,
user_id=random.choice(users_ids),
role=models.RoleChoices.OWNER,
)
@@ -303,6 +301,40 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
with Timeit(stdout, "Creating mailboxes"):
for domain in domains:
for i in range(defaults.NB_OBJECTS["mailboxes_per_domain"]):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}{i}"
queue.push(
mailbox_models.Mailbox(
first_name=first_name,
last_name=last_name,
local_part=local_part,
domain=domain,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
dn_email=local_part,
)
)
queue.flush()
with Timeit(stdout, "Creating aliases"):
for domain in domains:
for _i in range(defaults.NB_OBJECTS["aliases_per_domain"]):
queue.push(
mailbox_models.Alias(
local_part=fake.word(),
destination=fake.email(),
domain=domain,
)
)
queue.flush()
with Timeit(stdout, "Creating specific users"):
# ⚠️ Warning: this users also need to be created in the keycloak
# realm.json AND the OIDC setting to fallback on user email
@@ -337,7 +369,7 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.push(user_with_mail)
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domains_ids[0],
domain=domains[0],
user_id=user_with_mail.pk,
role=role,
)
@@ -363,7 +395,7 @@ def create_demo(stdout): # pylint: disable=too-many-locals
)
queue.push(
mailbox_models.MailDomainAccess(
domain_id=domains_ids[0],
domain=domains[0],
user_id=team_mail_user.pk,
role=domain_role,
)
@@ -371,17 +403,62 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=enabled_domain,
email="people@people.world",
role=MailDomainRoleChoices.ADMIN,
)
# Many objects domain
many_objects_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="many-objects-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@mbd.com",
)
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=many_objects_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=many_objects_domain,
email="people@people.world",
role=MailDomainRoleChoices.OWNER,
)
for _i in range(30):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}"
mailbox_models.Mailbox.objects.create(
domain=many_objects_domain,
first_name=first_name,
last_name=last_name,
local_part=local_part,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
password=make_password(None), # unusable password
dn_email=f"{local_part}@{many_objects_domain}",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
mailbox_models.Alias.objects.create(
local_part=fake.word(),
destination=fake.email(),
domain=many_objects_domain,
)
# OIDC configuration
@@ -19,7 +19,9 @@ TEST_NB_OBJECTS = {
"users": 100,
"teams": 100,
"max_users_per_team": 5,
"domains": 100,
"domains": 10,
"mailboxes_per_domain": 2,
"aliases_per_domain": 2,
}
@@ -33,25 +35,38 @@ def test_commands_create_demo(settings):
call_command("create_demo")
# Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
# 3 user with team rights
# 3 user with domain rights
# 3 users with team rights
# 3 users with domain rights
# 3 x 3 user with both rights
assert models.User.objects.count() == TEST_NB_OBJECTS["users"] + 3 + 3 + 3 + 9
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
# nb_domains + example.com + enabled + many-boxed-domain
assert (
mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1 + 1
mailbox_models.MailDomain.objects.count()
== TEST_NB_OBJECTS["domains"] + 1 + 1 + 1
)
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights
# 1 domain for E2E mail owner user
# 2 domains for E2E mail owner user
assert (
mailbox_models.MailDomainAccess.objects.count()
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 1
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 2
)
# TEST_NB_OBJECTS["domains"]*TEST_NB_OBJECTS["mailboxes_per_domain"] = 20
# + 30 in the many-object-domain
# + 1 mailbox for user-e2e@example.com
# = 51
assert mailbox_models.Mailbox.objects.count() == 51
# TEST_NB_OBJECTS["domains"]*TEST_NB_OBJECTS["mailboxes_per_alias"] = 20
# + 30 in the many-object-domain
assert mailbox_models.Alias.objects.count() == 50
def test_commands_createsuperuser():
"""
+327 -317
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
"PO-Revision-Date: 2025-06-11 09:29\n"
"POT-Creation-Date: 2026-03-13 15:21+0000\n"
"PO-Revision-Date: 2026-03-24 08:54\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,559 +17,336 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: build/lib/core/admin.py:62 core/admin.py:62
#: core/admin.py:62
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:73 core/admin.py:73
#: core/admin.py:73
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:85 core/admin.py:85
#: core/admin.py:85
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:124 core/admin.py:124
#: core/admin.py:124
msgid "User"
msgstr ""
#: build/lib/core/admin.py:226 core/admin.py:226
#: core/admin.py:226
msgid "Run post creation plugins"
msgstr ""
#: build/lib/core/admin.py:234 core/admin.py:234
#: core/admin.py:234
msgid "Post creation plugins have been run for the selected organizations."
msgstr ""
#: build/lib/core/apps.py:65 core/apps.py:65
#: core/apps.py:65
msgid "People core application"
msgstr ""
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
#: core/authentication/backends.py:102
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
#: core/authentication/backends.py:122
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
#: core/authentication/backends.py:170 core/authentication/backends.py:172
msgid "Invalid authorization header."
msgstr ""
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
#: core/authentication/backends.py:177
msgid "Invalid api key."
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
#: core/enums.py:24
msgid "Failure"
msgstr ""
#: build/lib/core/enums.py:25 build/lib/mailbox_manager/enums.py:21
#: build/lib/mailbox_manager/enums.py:31 core/enums.py:25
#: mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
#: core/enums.py:25 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr ""
#: build/lib/core/enums.py:26 core/enums.py:26
#: core/enums.py:26
msgid "Success"
msgstr ""
#: build/lib/core/models.py:76 core/models.py:76
#: core/exceptions.py:16
msgid "Email already known. Invitation not sent but access created instead."
msgstr ""
#: core/models.py:77
msgid "Member"
msgstr ""
#: build/lib/core/models.py:77 build/lib/core/models.py:89
#: build/lib/mailbox_manager/enums.py:14 core/models.py:77 core/models.py:89
#: mailbox_manager/enums.py:14
#: core/models.py:78 core/models.py:90 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
#: core/models.py:78 mailbox_manager/enums.py:15
#: core/models.py:79 mailbox_manager/enums.py:15
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:101 core/models.py:101
#: core/models.py:102
msgid "id"
msgstr ""
#: build/lib/core/models.py:102 core/models.py:102
#: core/models.py:103
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:108 core/models.py:108
#: core/models.py:109
msgid "created at"
msgstr ""
#: build/lib/core/models.py:109 core/models.py:109
#: core/models.py:110
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:114 core/models.py:114
#: core/models.py:115
msgid "updated at"
msgstr ""
#: build/lib/core/models.py:115 core/models.py:115
#: core/models.py:116
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:155
msgid "full name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: core/models.py:156
msgid "short name"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: core/models.py:159
msgid "notes"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: core/models.py:161
msgid "contact information"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:162
msgid "A JSON object containing the contact information"
msgstr ""
#: build/lib/core/models.py:175 core/models.py:175
#: core/models.py:176
msgid "contact"
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: core/models.py:177
msgid "contacts"
msgstr ""
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1106
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
#: core/models.py:251 core/models.py:365 core/models.py:511 core/models.py:1125
#: mailbox_manager/models.py:56
msgid "name"
msgstr ""
#: build/lib/core/models.py:252 core/models.py:252
#: core/models.py:253
msgid "audience id"
msgstr ""
#: build/lib/core/models.py:257 core/models.py:257
#: core/models.py:258
msgid "service provider"
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
#: core/models.py:259
msgid "service providers"
msgstr ""
#: build/lib/core/models.py:372 core/models.py:372
#: core/models.py:373
msgid "registration ID list"
msgstr ""
#: build/lib/core/models.py:379 core/models.py:379
#: core/models.py:380
msgid "domain list"
msgstr ""
#: build/lib/core/models.py:386 core/models.py:386
#: core/models.py:387
msgid "metadata"
msgstr ""
#: build/lib/core/models.py:387 core/models.py:387
#: core/models.py:388
msgid "A JSON object containing the organization metadata"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
#: core/models.py:535
#: core/models.py:398 core/models.py:536
msgid "active"
msgstr ""
#: build/lib/core/models.py:403 core/models.py:403
#: core/models.py:404
msgid "organization"
msgstr ""
#: build/lib/core/models.py:404 core/models.py:404
#: core/models.py:405
msgid "organizations"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: core/models.py:412
msgid "An organization must have at least a registration ID or a domain."
msgstr ""
#: build/lib/core/models.py:495 core/models.py:495
#: core/models.py:496
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: build/lib/core/models.py:501 core/models.py:501
#: core/models.py:502
msgid "sub"
msgstr ""
#: build/lib/core/models.py:503 core/models.py:503
#: core/models.py:504
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: build/lib/core/models.py:509 build/lib/core/models.py:958 core/models.py:509
#: core/models.py:958
#: core/models.py:510 core/models.py:974
msgid "email address"
msgstr ""
#: build/lib/core/models.py:515 core/models.py:515
#: core/models.py:516
msgid "language"
msgstr ""
#: build/lib/core/models.py:516 core/models.py:516
#: core/models.py:517
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:522 core/models.py:522
#: core/models.py:523
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:525 core/models.py:525
#: core/models.py:526
msgid "device"
msgstr ""
#: build/lib/core/models.py:527 core/models.py:527
#: core/models.py:528
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:530 core/models.py:530
#: core/models.py:531
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:532 core/models.py:532
#: core/models.py:533
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:538 core/models.py:538
#: core/models.py:539
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:557 core/models.py:557
#: core/models.py:558
msgid "user"
msgstr ""
#: build/lib/core/models.py:558 core/models.py:558
#: core/models.py:559
msgid "users"
msgstr ""
#: build/lib/core/models.py:696 core/models.py:696
#: core/models.py:697
msgid "Organization/user relation"
msgstr ""
#: build/lib/core/models.py:697 core/models.py:697
#: core/models.py:698
msgid "Organization/user relations"
msgstr ""
#: build/lib/core/models.py:702 core/models.py:702
#: core/models.py:703
msgid "This user is already in this organization."
msgstr ""
#: build/lib/core/models.py:774 core/models.py:774
#: core/models.py:762
msgid "external_id"
msgstr ""
#: core/models.py:763
msgid "Team external UUID for synchronization with external systems"
msgstr ""
#: core/models.py:788
msgid "is visible for all SP"
msgstr ""
#: build/lib/core/models.py:776 core/models.py:776
#: core/models.py:790
msgid "Whether this team is visible to all service providers."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: core/models.py:798
msgid "Team"
msgstr ""
#: build/lib/core/models.py:785 core/models.py:785
#: core/models.py:799
msgid "Teams"
msgstr ""
#: build/lib/core/models.py:836 core/models.py:836
#: core/models.py:850
msgid "Team/user relation"
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: core/models.py:851
msgid "Team/user relations"
msgstr ""
#: build/lib/core/models.py:842 core/models.py:842
#: core/models.py:856
msgid "This user is already in this team."
msgstr ""
#: build/lib/core/models.py:931 core/models.py:931
#: core/models.py:942
msgid "url"
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
#: core/models.py:943
msgid "secret"
msgstr ""
#: build/lib/core/models.py:941 core/models.py:941
#: core/models.py:957
msgid "Team webhook"
msgstr ""
#: build/lib/core/models.py:942 core/models.py:942
#: core/models.py:958
msgid "Team webhooks"
msgstr ""
#: build/lib/core/models.py:986 core/models.py:986
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: core/models.py:1019
msgid "Invitation to join La Régie!"
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: core/models.py:1065
msgid "Team invitation"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: core/models.py:1066
msgid "Team invitations"
msgstr ""
#: build/lib/core/models.py:1060 core/models.py:1060
#: core/models.py:1079
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr ""
#: build/lib/core/models.py:1108 core/models.py:1108
#: core/models.py:1127
msgid "api key"
msgstr ""
#: build/lib/core/models.py:1113 core/models.py:1113
#: core/models.py:1132
msgid "allowed scopes"
msgstr ""
#: build/lib/core/models.py:1114 core/models.py:1114
#: core/models.py:1133
msgid "Allowed scopes for this service"
msgstr ""
#: build/lib/core/models.py:1119 core/models.py:1119
#: core/models.py:1138
msgid "Account service"
msgstr ""
#: build/lib/core/models.py:1120 core/models.py:1120
#: core/models.py:1139
msgid "Account services"
msgstr ""
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
msgid "Import emails from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:42 mailbox_manager/admin.py:42
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:49 mailbox_manager/admin.py:49
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:54 mailbox_manager/admin.py:54
msgid "Check and update status from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:71 mailbox_manager/admin.py:71
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
msgid "Check domains done with success."
msgstr ""
#: build/lib/mailbox_manager/admin.py:85 mailbox_manager/admin.py:85
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
msgid "No domain updated."
msgstr ""
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
msgid "Check domain failed for:"
msgstr ""
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
msgid "Fetch domain expected config from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:133 mailbox_manager/admin.py:133
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
msgid "Send pending mailboxes to dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
#: build/lib/mailbox_manager/enums.py:13 mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
#: build/lib/mailbox_manager/enums.py:22 build/lib/mailbox_manager/enums.py:32
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr ""
#: build/lib/mailbox_manager/enums.py:23 build/lib/mailbox_manager/enums.py:33
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr ""
#: build/lib/mailbox_manager/enums.py:24 build/lib/mailbox_manager/enums.py:34
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr ""
#: build/lib/mailbox_manager/enums.py:25 mailbox_manager/enums.py:25
msgid "Action required"
msgstr ""
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
msgid "[La Suite] Your domain is ready"
msgstr ""
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
msgid "[La Suite] Your domain requires action"
msgstr ""
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
msgid "[La Suite] Your domain has failed"
msgstr ""
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
msgid "support email"
msgstr ""
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
msgid "last check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
msgid "A JSON object containing the last health check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
msgid "expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
msgid "A JSON object containing the expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
msgid "Mail domain"
msgstr ""
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
msgid "Mail domains"
msgstr ""
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
msgid "User/mail domain relation"
msgstr ""
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
msgid "User/mail domain relations"
msgstr ""
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
msgid "local_part"
msgstr ""
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
msgid "secondary email address"
msgstr ""
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
msgid "email"
msgstr ""
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
msgid "Mailbox"
msgstr ""
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
msgid "Mailboxes"
msgstr ""
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
msgid "Mail domain invitation"
msgstr ""
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
msgid "Mail domain invitations"
msgstr ""
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
msgid "Your new mailbox information"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
msgid "Your password has been updated"
msgstr ""
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr ""
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr ""
#: core/templates/mail/html/maildomain_action_required.html:195
#: core/templates/mail/text/maildomain_action_required.txt:5
msgid "Some actions are required on your domain"
@@ -861,3 +638,236 @@ msgstr ""
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
msgstr ""
#: mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr ""
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr ""
#: mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr ""
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr ""
#: mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr ""
#: mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr ""
#: mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr ""
#: mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr ""
#: mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr ""
#: mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr ""
#: mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr ""
#: mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
#: mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr ""
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr ""
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr ""
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr ""
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr ""
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr ""
#: mailbox_manager/models.py:35
msgid "[La Suite] Your domain is ready"
msgstr ""
#: mailbox_manager/models.py:40
msgid "[La Suite] Your domain requires action"
msgstr ""
#: mailbox_manager/models.py:45
msgid "[La Suite] Your domain has failed"
msgstr ""
#: mailbox_manager/models.py:71
msgid "support email"
msgstr ""
#: mailbox_manager/models.py:75
msgid "last check details"
msgstr ""
#: mailbox_manager/models.py:76
msgid "A JSON object containing the last health check details"
msgstr ""
#: mailbox_manager/models.py:81
msgid "expected config"
msgstr ""
#: mailbox_manager/models.py:82
msgid "A JSON object containing the expected config"
msgstr ""
#: mailbox_manager/models.py:87
msgid "Mail domain"
msgstr ""
#: mailbox_manager/models.py:88
msgid "Mail domains"
msgstr ""
#: mailbox_manager/models.py:204
msgid "User/mail domain relation"
msgstr ""
#: mailbox_manager/models.py:205
msgid "User/mail domain relations"
msgstr ""
#: mailbox_manager/models.py:278
msgid "local_part"
msgstr ""
#: mailbox_manager/models.py:292
msgid "secondary email address"
msgstr ""
#: mailbox_manager/models.py:303
msgid "email"
msgstr ""
#: mailbox_manager/models.py:309
msgid "Mailbox"
msgstr ""
#: mailbox_manager/models.py:310
msgid "Mailboxes"
msgstr ""
#: mailbox_manager/models.py:352
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: mailbox_manager/models.py:411
msgid "Mail domain invitation"
msgstr ""
#: mailbox_manager/models.py:412
msgid "Mail domain invitations"
msgstr ""
#: mailbox_manager/models.py:441
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: mailbox_manager/models.py:485
msgid "destination address"
msgstr ""
#: mailbox_manager/models.py:503
msgid "Alias"
msgstr ""
#: mailbox_manager/models.py:504
msgid "Aliases"
msgstr ""
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr ""
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr ""
#: people/settings.py:159
msgid "English"
msgstr ""
#: people/settings.py:160
msgid "French"
msgstr ""
+328 -318
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-10 16:13+0000\n"
"PO-Revision-Date: 2025-06-11 09:29\n"
"POT-Creation-Date: 2026-03-13 15:21+0000\n"
"PO-Revision-Date: 2026-03-24 08:54\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,559 +17,336 @@ msgstr ""
"X-Crowdin-File: backend.pot\n"
"X-Crowdin-File-ID: 2\n"
#: build/lib/core/admin.py:62 core/admin.py:62
#: core/admin.py:62
msgid "Personal info"
msgstr "Informations personnelles"
#: build/lib/core/admin.py:73 core/admin.py:73
#: core/admin.py:73
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:85 core/admin.py:85
#: core/admin.py:85
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:124 core/admin.py:124
#: core/admin.py:124
msgid "User"
msgstr "Utilisateur"
#: build/lib/core/admin.py:226 core/admin.py:226
#: core/admin.py:226
msgid "Run post creation plugins"
msgstr "Exécuter les plugins de post-création"
#: build/lib/core/admin.py:234 core/admin.py:234
#: core/admin.py:234
msgid "Post creation plugins have been run for the selected organizations."
msgstr "Les plugins de post-création ont été exécutés pour les organisations sélectionnées."
#: build/lib/core/apps.py:65 core/apps.py:65
#: core/apps.py:65
msgid "People core application"
msgstr "Application cœur de People"
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
#: core/authentication/backends.py:102
msgid "Claims contained no recognizable user identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
#: core/authentication/backends.py:122
msgid "Claims contained no recognizable organization identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
#: core/authentication/backends.py:170 core/authentication/backends.py:172
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
#: core/authentication/backends.py:177
msgid "Invalid api key."
msgstr "Clé API invalide."
#: build/lib/core/enums.py:24 core/enums.py:24
#: core/enums.py:24
msgid "Failure"
msgstr "En échec"
#: build/lib/core/enums.py:25 build/lib/mailbox_manager/enums.py:21
#: build/lib/mailbox_manager/enums.py:31 core/enums.py:25
#: mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
#: core/enums.py:25 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
msgid "Pending"
msgstr "En attente"
#: build/lib/core/enums.py:26 core/enums.py:26
#: core/enums.py:26
msgid "Success"
msgstr "Réussi"
#: build/lib/core/models.py:76 core/models.py:76
#: core/exceptions.py:16
msgid "Email already known. Invitation not sent but access created instead."
msgstr "Adresse email déjà connue. Aucune invitation envoyée mais accès créé à la place."
#: core/models.py:77
msgid "Member"
msgstr "Membre"
#: build/lib/core/models.py:77 build/lib/core/models.py:89
#: build/lib/mailbox_manager/enums.py:14 core/models.py:77 core/models.py:89
#: mailbox_manager/enums.py:14
#: core/models.py:78 core/models.py:90 mailbox_manager/enums.py:14
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/models.py:78 build/lib/mailbox_manager/enums.py:15
#: core/models.py:78 mailbox_manager/enums.py:15
#: core/models.py:79 mailbox_manager/enums.py:15
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/models.py:101 core/models.py:101
#: core/models.py:102
msgid "id"
msgstr "id"
#: build/lib/core/models.py:102 core/models.py:102
#: core/models.py:103
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: build/lib/core/models.py:108 core/models.py:108
#: core/models.py:109
msgid "created at"
msgstr "créé le"
#: build/lib/core/models.py:109 core/models.py:109
#: core/models.py:110
msgid "date and time at which a record was created"
msgstr "date et heure de création de l'enregistrement"
#: build/lib/core/models.py:114 core/models.py:114
#: core/models.py:115
msgid "updated at"
msgstr "mis à jour le"
#: build/lib/core/models.py:115 core/models.py:115
#: core/models.py:116
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:155
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:155 core/models.py:155
#: core/models.py:156
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:158 core/models.py:158
#: core/models.py:159
msgid "notes"
msgstr "notes"
#: build/lib/core/models.py:160 core/models.py:160
#: core/models.py:161
msgid "contact information"
msgstr "informations de contact"
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:162
msgid "A JSON object containing the contact information"
msgstr "Un objet JSON contenant les informations de contact"
#: build/lib/core/models.py:175 core/models.py:175
#: core/models.py:176
msgid "contact"
msgstr "contact"
#: build/lib/core/models.py:176 core/models.py:176
#: core/models.py:177
msgid "contacts"
msgstr "contacts"
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1106
#: build/lib/mailbox_manager/models.py:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1106 mailbox_manager/models.py:53
#: core/models.py:251 core/models.py:365 core/models.py:511 core/models.py:1125
#: mailbox_manager/models.py:56
msgid "name"
msgstr "nom"
#: build/lib/core/models.py:252 core/models.py:252
#: core/models.py:253
msgid "audience id"
msgstr "ID d'audience"
#: build/lib/core/models.py:257 core/models.py:257
#: core/models.py:258
msgid "service provider"
msgstr "fournisseur de services"
#: build/lib/core/models.py:258 core/models.py:258
#: core/models.py:259
msgid "service providers"
msgstr "fournisseurs de services"
#: build/lib/core/models.py:372 core/models.py:372
#: core/models.py:373
msgid "registration ID list"
msgstr "liste d'identifiants d'inscription"
#: build/lib/core/models.py:379 core/models.py:379
#: core/models.py:380
msgid "domain list"
msgstr "liste des domaines"
#: build/lib/core/models.py:386 core/models.py:386
#: core/models.py:387
msgid "metadata"
msgstr "métadonnées"
#: build/lib/core/models.py:387 core/models.py:387
#: core/models.py:388
msgid "A JSON object containing the organization metadata"
msgstr "Un objet JSON contenant les métadonnées de l'organisation"
#: build/lib/core/models.py:397 build/lib/core/models.py:535 core/models.py:397
#: core/models.py:535
#: core/models.py:398 core/models.py:536
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:403 core/models.py:403
#: core/models.py:404
msgid "organization"
msgstr "organisation"
#: build/lib/core/models.py:404 core/models.py:404
#: core/models.py:405
msgid "organizations"
msgstr "organisations"
#: build/lib/core/models.py:411 core/models.py:411
#: core/models.py:412
msgid "An organization must have at least a registration ID or a domain."
msgstr "Une organisation doit avoir au moins un ID d'enregistrement ou un domaine."
#: build/lib/core/models.py:495 core/models.py:495
#: core/models.py:496
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des chiffres et des caractères @/./+/-/_."
#: build/lib/core/models.py:501 core/models.py:501
#: core/models.py:502
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:503 core/models.py:503
#: core/models.py:504
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ seulement."
#: build/lib/core/models.py:509 build/lib/core/models.py:958 core/models.py:509
#: core/models.py:958
#: core/models.py:510 core/models.py:974
msgid "email address"
msgstr "adresse email"
#: build/lib/core/models.py:515 core/models.py:515
#: core/models.py:516
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:516 core/models.py:516
#: core/models.py:517
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:522 core/models.py:522
#: core/models.py:523
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:525 core/models.py:525
#: core/models.py:526
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:527 core/models.py:527
#: core/models.py:528
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:530 core/models.py:530
#: core/models.py:531
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:532 core/models.py:532
#: core/models.py:533
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:538 core/models.py:538
#: core/models.py:539
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:557 core/models.py:557
#: core/models.py:558
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:558 core/models.py:558
#: core/models.py:559
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:696 core/models.py:696
#: core/models.py:697
msgid "Organization/user relation"
msgstr "Relation organisation/utilisateur"
#: build/lib/core/models.py:697 core/models.py:697
#: core/models.py:698
msgid "Organization/user relations"
msgstr "Relations organisation/utilisateur"
#: build/lib/core/models.py:702 core/models.py:702
#: core/models.py:703
msgid "This user is already in this organization."
msgstr "Cet utilisateur est déjà dans cette organisation."
#: build/lib/core/models.py:774 core/models.py:774
#: core/models.py:762
msgid "external_id"
msgstr "ID externe"
#: core/models.py:763
msgid "Team external UUID for synchronization with external systems"
msgstr "UUID externe de l'équipe pour la synchronisation avec des systèmes externes"
#: core/models.py:788
msgid "is visible for all SP"
msgstr "est visible pour tous les fournisseurs de service"
#: build/lib/core/models.py:776 core/models.py:776
#: core/models.py:790
msgid "Whether this team is visible to all service providers."
msgstr "Si cette équipe est visible pour tous les fournisseurs de services."
#: build/lib/core/models.py:784 core/models.py:784
#: core/models.py:798
msgid "Team"
msgstr "Équipe"
#: build/lib/core/models.py:785 core/models.py:785
#: core/models.py:799
msgid "Teams"
msgstr "Équipes"
#: build/lib/core/models.py:836 core/models.py:836
#: core/models.py:850
msgid "Team/user relation"
msgstr "Relation équipe/utilisateur"
#: build/lib/core/models.py:837 core/models.py:837
#: core/models.py:851
msgid "Team/user relations"
msgstr "Relations équipe/utilisateur"
#: build/lib/core/models.py:842 core/models.py:842
#: core/models.py:856
msgid "This user is already in this team."
msgstr "Cet utilisateur est déjà dans cette équipe."
#: build/lib/core/models.py:931 core/models.py:931
#: core/models.py:942
msgid "url"
msgstr "url"
#: build/lib/core/models.py:932 core/models.py:932
#: core/models.py:943
msgid "secret"
msgstr "secret"
#: build/lib/core/models.py:941 core/models.py:941
#: core/models.py:957
msgid "Team webhook"
msgstr "Webhook d'équipe"
#: build/lib/core/models.py:942 core/models.py:942
#: core/models.py:958
msgid "Team webhooks"
msgstr "Webhooks d'équipe"
#: build/lib/core/models.py:986 core/models.py:986
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
#: build/lib/core/models.py:1000 core/models.py:1000
#: core/models.py:1019
msgid "Invitation to join La Régie!"
msgstr "Invitation à rejoindre La Régie !"
#: build/lib/core/models.py:1046 core/models.py:1046
#: core/models.py:1065
msgid "Team invitation"
msgstr "Invitation d'équipe"
#: build/lib/core/models.py:1047 core/models.py:1047
#: core/models.py:1066
msgid "Team invitations"
msgstr "Invitations d'équipe"
#: build/lib/core/models.py:1060 core/models.py:1060
#: core/models.py:1079
#, python-format
msgid "[La Suite] You have been invited to become a %(role)s of a group"
msgstr "[La Suite] Vous avez été invité(e) à être %(role)s d'un groupe"
#: build/lib/core/models.py:1108 core/models.py:1108
#: core/models.py:1127
msgid "api key"
msgstr "Clé d'API"
#: build/lib/core/models.py:1113 core/models.py:1113
#: core/models.py:1132
msgid "allowed scopes"
msgstr "périmètres autorisés"
#: build/lib/core/models.py:1114 core/models.py:1114
#: core/models.py:1133
msgid "Allowed scopes for this service"
msgstr "Périmètres d'application autorisés pour ce service"
#: build/lib/core/models.py:1119 core/models.py:1119
#: core/models.py:1138
msgid "Account service"
msgstr "Compte de service"
#: build/lib/core/models.py:1120 core/models.py:1120
#: core/models.py:1139
msgid "Account services"
msgstr "Comptes de service"
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
msgid "Import emails from dimail"
msgstr "Importer les emails depuis dimail"
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:42 mailbox_manager/admin.py:42
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
#: build/lib/mailbox_manager/admin.py:49 mailbox_manager/admin.py:49
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
#: build/lib/mailbox_manager/admin.py:54 mailbox_manager/admin.py:54
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
#: build/lib/mailbox_manager/admin.py:71 mailbox_manager/admin.py:71
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr "- %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
#: build/lib/mailbox_manager/admin.py:85 mailbox_manager/admin.py:85
#, python-format
msgid "Domains updated: %(domains)s"
msgstr "Domaines mis à jour : %(domains)s"
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:133 mailbox_manager/admin.py:133
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: build/lib/mailbox_manager/admin.py:138 mailbox_manager/admin.py:138
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: build/lib/mailbox_manager/admin.py:154 mailbox_manager/admin.py:154
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: build/lib/mailbox_manager/admin.py:160 mailbox_manager/admin.py:160
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: build/lib/mailbox_manager/admin.py:166 mailbox_manager/admin.py:166
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: build/lib/mailbox_manager/apps.py:11 mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
#: build/lib/mailbox_manager/enums.py:13 mailbox_manager/enums.py:13
msgid "Viewer"
msgstr "Lecteur"
#: build/lib/mailbox_manager/enums.py:22 build/lib/mailbox_manager/enums.py:32
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr "Actif"
#: build/lib/mailbox_manager/enums.py:23 build/lib/mailbox_manager/enums.py:33
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En erreur"
#: build/lib/mailbox_manager/enums.py:24 build/lib/mailbox_manager/enums.py:34
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr "Désactivé"
#: build/lib/mailbox_manager/enums.py:25 mailbox_manager/enums.py:25
msgid "Action required"
msgstr "Action requise"
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: build/lib/mailbox_manager/models.py:37 mailbox_manager/models.py:37
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: build/lib/mailbox_manager/models.py:42 mailbox_manager/models.py:42
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: build/lib/mailbox_manager/models.py:68 mailbox_manager/models.py:68
msgid "support email"
msgstr "adresse email du support"
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
msgid "last check details"
msgstr "détails de la dernière vérification"
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: build/lib/mailbox_manager/models.py:78 mailbox_manager/models.py:78
msgid "expected config"
msgstr "configuration attendue"
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: build/lib/mailbox_manager/models.py:84 mailbox_manager/models.py:84
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
msgid "local_part"
msgstr "local_part"
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
msgid "secondary email address"
msgstr "adresse email secondaire"
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
msgid "email"
msgstr "email"
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
msgid "Mailbox"
msgstr "Boîte mail"
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
msgid "Mailboxes"
msgstr "Boîtes mail"
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: build/lib/mailbox_manager/models.py:369 mailbox_manager/models.py:369
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: build/lib/mailbox_manager/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: build/lib/people/settings.py:159 people/settings.py:159
msgid "English"
msgstr "Anglais"
#: build/lib/people/settings.py:160 people/settings.py:160
msgid "French"
msgstr "Français"
#: core/templates/mail/html/maildomain_action_required.html:195
#: core/templates/mail/text/maildomain_action_required.txt:5
msgid "Some actions are required on your domain"
@@ -652,7 +429,7 @@ msgstr "Hourra !"
#: core/templates/mail/html/maildomain_enabled.html:206
#, python-format
msgid "Your domain <b>%(name)s</b> can be used now."
msgstr "Votre domaine <b>%(name)s</b> est prêt à être exploité."
msgstr "Votre domaine <b>%(name)s</b> est prêt à être utilisé."
#: core/templates/mail/html/maildomain_enabled.html:211
#: core/templates/mail/html/maildomain_invitation.html:211
@@ -861,3 +638,236 @@ msgstr "Le domaine %(name)s a rencontré une erreur. Tant que cette erreur persi
msgid "For more information: Visit the website for La Suite numérique [%(link)s] to discover what tools we offer."
msgstr "Pour en savoir plus : Visitez le site de la Suite numérique [%(link)s] pour découvrir lensemble des outils proposés."
#: mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr "Importer les emails depuis dimail"
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
#: mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
#: mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr "Importer des alias depuis dimail"
#: mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr "Synchronisation réussie pour %(domain)s. %(count_imported)s alias importés : %(aliases)s"
#: mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
#: mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr "- %(domain)s avec le message : %(err)s"
#: mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
#: mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr "Domaines mis à jour : %(domains)s"
#: mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
#: mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
#: mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
#: mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
#: mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
#: mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
#: mailbox_manager/apps.py:11
msgid "Mailbox manager"
msgstr "Gestion des boîtes mails"
#: mailbox_manager/enums.py:13
msgid "Viewer"
msgstr "Lecteur"
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
msgid "Enabled"
msgstr "Actif"
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
msgid "Failed"
msgstr "En erreur"
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
msgid "Disabled"
msgstr "Désactivé"
#: mailbox_manager/enums.py:25
msgid "Action required"
msgstr "Action requise"
#: mailbox_manager/models.py:35
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: mailbox_manager/models.py:40
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: mailbox_manager/models.py:45
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: mailbox_manager/models.py:71
msgid "support email"
msgstr "adresse email du support"
#: mailbox_manager/models.py:75
msgid "last check details"
msgstr "détails de la dernière vérification"
#: mailbox_manager/models.py:76
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: mailbox_manager/models.py:81
msgid "expected config"
msgstr "configuration attendue"
#: mailbox_manager/models.py:82
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: mailbox_manager/models.py:87
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: mailbox_manager/models.py:88
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: mailbox_manager/models.py:204
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: mailbox_manager/models.py:205
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: mailbox_manager/models.py:278
msgid "local_part"
msgstr "local_part"
#: mailbox_manager/models.py:292
msgid "secondary email address"
msgstr "adresse email secondaire"
#: mailbox_manager/models.py:303
msgid "email"
msgstr "email"
#: mailbox_manager/models.py:309
msgid "Mailbox"
msgstr "Boîte mail"
#: mailbox_manager/models.py:310
msgid "Mailboxes"
msgstr "Boîtes mail"
#: mailbox_manager/models.py:352
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: mailbox_manager/models.py:411
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: mailbox_manager/models.py:412
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: mailbox_manager/models.py:441
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: mailbox_manager/models.py:485
msgid "destination address"
msgstr "adresse de destination"
#: mailbox_manager/models.py:503
msgid "Alias"
msgstr "Alias"
#: mailbox_manager/models.py:504
msgid "Aliases"
msgstr "Alias"
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
msgstr "Informations sur votre nouvelle boîte mail"
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
#: people/settings.py:159
msgid "English"
msgstr "Anglais"
#: people/settings.py:160
msgid "French"
msgstr "Français"
+58 -5
View File
@@ -10,9 +10,6 @@ from requests import exceptions
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
# Prevent Ruff complaining about mark_safe below
# ruff: noqa: S308
@admin.action(description=_("Import emails from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
@@ -51,6 +48,51 @@ def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disabl
)
@admin.action(description=_("Import aliases from dimail"))
def sync_aliases_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""
Admin action to import existing aliases from dimail.
Checks alias is not a duplicate and that usernames don't clash with existing mailboxes.
"""
excluded_domains = []
client = DimailAPIClient()
for domain in queryset:
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
try:
imported_aliases = client.import_aliases(domain)
except exceptions.HTTPError as err:
messages.error(
request,
_("Synchronisation failed for %(domain)s with message: %(err)s")
% {"domain": domain.name, "err": err},
)
else:
messages.success(
request,
_(
"Synchronisation succeed for %(domain)s.\
Imported %(count_imported)s aliases: %(aliases)s"
)
% {
"domain": domain.name,
"count_imported": len(imported_aliases),
"aliases": ", ".join(imported_aliases),
},
)
if excluded_domains:
messages.warning(
request,
_("Sync require enabled domains. Excluded domains: %(domains)s")
% {"domains": ", ".join(excluded_domains)},
)
@admin.action(description=_("Check and update status from dimail"))
def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to check domain health with dimail and update domain status."""
@@ -194,6 +236,7 @@ class MailDomainAdmin(admin.ModelAdmin):
inlines = (UserMailDomainAccessInline,)
actions = (
sync_mailboxes_from_dimail,
sync_aliases_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
send_pending_mailboxes,
@@ -207,8 +250,8 @@ class MailboxAdmin(UserAdmin):
list_display = ("__str__", "domain", "status", "updated_at")
list_filter = ("status",)
search_fields = ("local_part", "domain__name")
readonly_fields = ["updated_at", "local_part", "domain"]
search_fields = ("local_part", "domain__name", "first_name", "last_name")
readonly_fields = ["updated_at"]
fieldsets = None
add_fieldsets = (
@@ -267,3 +310,13 @@ class MailDomainInvitationAdmin(admin.ModelAdmin):
def is_expired(self, obj):
"""Return the expiration date of the invitation."""
return obj.is_expired
@admin.register(models.Alias)
class AliasAdmin(admin.ModelAdmin):
"""Admin for alias model."""
list_display = ("local_part", "domain", "destination", "updated_at")
list_filter = ("domain",)
search_fields = ("local_part", "domain__name", "destination")
readonly_fields = ["updated_at"]
@@ -3,6 +3,8 @@
from logging import getLogger
from django.contrib.auth.hashers import make_password
from django.core import exceptions as django_exceptions
from django.shortcuts import get_object_or_404
from requests.exceptions import HTTPError
from rest_framework import exceptions, serializers
@@ -29,7 +31,6 @@ class MailboxSerializer(serializers.ModelSerializer):
"secondary_email",
"status",
]
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id", "status"]
def create(self, validated_data):
@@ -45,12 +46,21 @@ class MailboxSerializer(serializers.ModelSerializer):
"password": make_password(None), # generate an unusable password
}
)
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
client = DimailAPIClient()
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
# send new mailbox request to dimail
response = client.create_mailbox(mailbox, self.context["request"].user.sub)
client = DimailAPIClient()
try:
response = client.create_mailbox(
mailbox, self.context["request"].user.sub
)
except django_exceptions.ValidationError as exc:
mailbox.delete()
raise exc
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox_data = response.json()
mailbox.set_password(mailbox_data["password"])
mailbox.save()
if mailbox.secondary_email:
@@ -67,10 +77,25 @@ class MailboxSerializer(serializers.ModelSerializer):
mailbox,
)
# actually save mailbox on our database
return mailbox
class MailboxUpdateSerializer(MailboxSerializer):
"""A more restrictive serializer when updating mailboxes"""
class Meta:
model = models.Mailbox
fields = [
"id",
"first_name",
"last_name",
"local_part",
"secondary_email",
"status",
]
read_only_fields = ("id", "local_part", "status")
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
@@ -203,23 +228,10 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
"You must set a domain slug in kwargs to create a new domain access."
) from exc
try:
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
except models.MailDomainAccess.DoesNotExist as exc:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
) from exc
# Authenticated user must be owner or admin of current domain to set new roles
if access.role not in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
)
# we're sure that an access exists. otherwise it would have failed at permissions.
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
# only an owner can set an owner role to another user
if (
@@ -277,11 +289,41 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer):
) from exc
domain = models.MailDomain.objects.get(slug=domain_slug)
if not domain.get_abilities(user)["manage_accesses"]:
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this domain."
)
attrs["domain"] = domain
attrs["issuer"] = user
return attrs
class AliasSerializer(serializers.ModelSerializer):
"""Serialize aliases."""
domain = MailDomainSerializer(default="")
class Meta:
model = models.Alias
fields = [
"id",
"local_part",
"destination",
"domain",
]
read_only_fields = ["id", "domain"]
def validate_domain(self, value): # pylint: disable=unused-argument
"""Forcefully set domain field to url domain."""
return get_object_or_404(models.MailDomain, slug=self.context["domain_slug"])
def create(self, validated_data):
"""
Override create function to fire a request to dimail on alias creation.
"""
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
alias = models.Alias(**validated_data)
# send new alias request to dimail
client = DimailAPIClient()
client.create_alias(alias, self.context["request"].user.sub)
return super().create(validated_data)
return None
@@ -1,13 +1,16 @@
"""API endpoints"""
from django.db.models import Q, Subquery
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import exceptions, filters, mixins, viewsets
from rest_framework import exceptions, filters, mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from core import models as core_models
from core.api.client.serializers import UserSerializer
from core.exceptions import EmailAlreadyKnownException
from mailbox_manager import enums, models
from mailbox_manager.api import permissions
@@ -40,7 +43,10 @@ class MailDomainViewSet(
Fetch domain status and expected config from dimail.
"""
permission_classes = [permissions.AccessPermission]
permission_classes = [
permissions.DomainPermission,
permissions.DomainResourcePermission,
]
serializer_class = serializers.MailDomainSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "name"]
@@ -112,7 +118,7 @@ class MailDomainAccessViewSet(
Delete targeted domain access
"""
permission_classes = [permissions.MailDomainAccessRolePermission]
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["role", "user__email", "user__name"]
@@ -228,9 +234,11 @@ class MailDomainAccessViewSet(
class MailBoxViewSet(
viewsets.GenericViewSet,
mixins.CreateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
mixins.UpdateModelMixin,
mixins.RetrieveModelMixin,
):
"""MailBox ViewSet
@@ -252,9 +260,15 @@ class MailBoxViewSet(
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/reset/
Send a request to mail-provider to reset password.
PUT /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to update mailbox. Cannot modify domain or local_part.
PATCH /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to partially update mailbox. Cannot modify domain or local_part.
"""
permission_classes = [permissions.MailBoxPermission]
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.MailboxSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["-created_at"]
@@ -267,6 +281,32 @@ class MailBoxViewSet(
return self.queryset.filter(domain__slug=domain_slug)
return self.queryset
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
return context
def get_permissions(self):
"""Add a specific permission for domain viewers to update their own mailbox."""
if self.action in ["list", "retrieve"]:
permission_classes = [permissions.DomainPermission]
elif self.action in ["update", "partial_update"]:
permission_classes = [
permissions.DomainResourcePermission
| permissions.IsMailboxOwnerPermission
]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_class(self):
"""Chooses list or detail serializer according to the action."""
if self.action in {"update", "partial_update"}:
return serializers.MailboxUpdateSerializer
return self.serializer_class
def perform_create(self, serializer):
"""Create new mailbox."""
domain_slug = self.kwargs.get("domain_slug", "")
@@ -310,6 +350,7 @@ class MailDomainInvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to domain management.
@@ -324,13 +365,17 @@ class MailDomainInvitationViewset(
- issuer : User, automatically added from user making query, if allowed
- domain : Domain, automatically added from requested URI
Return a newly created invitation
or an access if email is already linked to an existing user
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/<version>/mail-domains/<domain_slug>/invitations/:<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.DomainResourcePermission]
queryset = (
models.MailDomainInvitation.objects.all()
.select_related("domain")
@@ -367,3 +412,126 @@ class MailDomainInvitationViewset(
)
return queryset
def create(self, request, *args, **kwargs):
"""Attempt to create invitation. If user is already registered,
they don't need an invitation but an access, which we create here."""
email = request.data["email"]
try:
return super().create(request, *args, **kwargs)
except EmailAlreadyKnownException as exc:
user = models.User.objects.get(email__iexact=email)
models.MailDomainAccess.objects.create(
user=user,
domain=models.MailDomain.objects.get(slug=kwargs["domain_slug"]),
role=request.data["role"],
)
raise exc
@action(detail=True, methods=["post"])
def refresh(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""Enable mailbox. Send a request to dimail and change status in our DB"""
invitation = get_object_or_404(models.MailDomainInvitation, id=kwargs["id"])
invitation.refresh()
invitation.email_invitation()
return Response(serializers.MailDomainInvitationSerializer(invitation).data)
class AliasViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for aliases.
GET /api/<version>/mail-domains/<domain_slug>/aliases/
Return list of aliases related to that domain
POST /api/<version>/mail-domains/<domain_slug>/aliases/ with expected data:
- local_part: str
- destination: str
Return a newly created alias
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/<alias_pk>/
Delete targeted alias
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/?local_part=<local_part>/
Delete all aliases of targeted local_part
"""
lookup_field = "pk"
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.AliasSerializer
queryset = models.Alias.objects.all().select_related("domain")
filter_backends = [filters.OrderingFilter]
ordering_fields = ["local_part"]
ordering = ["local_part"]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
if self.action == "list":
# Determine which role the logged-in user has in the domain
user_role_query = models.MailDomainAccess.objects.filter(
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
).values("role")
queryset = (
queryset
# Abilities are computed based on logged-in user's role and
# the user role on each domain access
.annotate(user_role=Subquery(user_role_query)).distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""
Override destroy method to delete specific alias and send request to dimail.
"""
instance = self.get_object()
self.perform_destroy(instance)
client = DimailAPIClient()
dimail_response = client.delete_alias(instance)
if dimail_response.status_code == status.HTTP_404_NOT_FOUND:
return Response(
"Domain out of sync with mailbox provider, please contact our support.",
status=status.HTTP_200_OK,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["DELETE"], detail=False)
def delete(self, request, *args, **kwargs):
"""Bulk delete aliases. Filtering is required and accepted filter is local_part."""
if "local_part" not in self.request.query_params:
return Response(status=status.HTTP_400_BAD_REQUEST)
local_part = self.request.query_params["local_part"]
queryset = self.get_queryset().filter(
local_part=local_part
) # Manually call get_queryset to filter by domain and role
if not queryset:
raise Http404("No Alias matches the given query.")
# view is bounded to a domain, fetch is from the queryset to spare a dedicated DB request"
domain_name = queryset[0].domain.name
queryset.delete()
client = DimailAPIClient()
client.delete_multiple_alias(local_part, domain_name)
return Response(status=status.HTTP_204_NO_CONTENT)
+37 -13
View File
@@ -1,11 +1,40 @@
"""Permission handlers for the People mailbox manager app."""
from core.api import permissions as core_permissions
from django.shortcuts import get_object_or_404
from rest_framework import permissions
from core.api.permissions import IsAuthenticated
from mailbox_manager import models
class AccessPermission(core_permissions.IsAuthenticated):
class DomainPermission(IsAuthenticated):
"""Permission class to manage mailboxes and aliases for a mail domain"""
def has_permission(self, request, view):
"""Check permission based on domain."""
if not super().has_permission(request, view):
return False
if not view.kwargs.get("domain_slug"):
return True
domain = get_object_or_404(
models.MailDomain,
slug=view.kwargs.get("domain_slug", ""),
accesses__user=request.user,
)
abilities = domain.get_abilities(request.user)
if request.method.lower() == "delete":
return abilities.get("manage_accesses", False)
return abilities.get(request.method.lower(), False)
class DomainResourcePermission(DomainPermission):
"""Permission class for access objects."""
def has_object_permission(self, request, view, obj):
@@ -14,20 +43,15 @@ class AccessPermission(core_permissions.IsAuthenticated):
return abilities.get(request.method.lower(), False)
class MailBoxPermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
class IsMailboxOwnerPermission(permissions.BasePermission):
"""Authorize update for domain viewers on their own mailbox."""
def has_permission(self, request, view):
"""Check permission based on domain."""
"""This permission is specifically about updates"""
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
return abilities["get"]
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
"""If the user is trying to update their own mailbox."""
return obj.get_email() == request.user.email
+2 -1
View File
@@ -15,4 +15,5 @@ class MailboxManagerConfig(AppConfig):
"""
Import signals when the app is ready.
"""
import mailbox_manager.signals # pylint: disable=import-outside-toplevel, unused-import
# pylint: disable=import-outside-toplevel, unused-import
import mailbox_manager.signals # noqa: PLC0415
+11
View File
@@ -98,3 +98,14 @@ class MailDomainInvitationFactory(factory.django.DjangoModelFactory):
[role[0] for role in enums.MailDomainRoleChoices.choices]
)
issuer = factory.SubFactory(core_factories.UserFactory)
class AliasFactory(factory.django.DjangoModelFactory):
"""A factory to create aliases."""
class Meta:
model = models.Alias
domain = factory.SubFactory(MailDomainEnabledFactory)
local_part = factory.Faker("word")
destination = factory.Faker("email")
@@ -0,0 +1,25 @@
# Generated by Django 5.2.5 on 2025-09-08 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0025_alter_mailbox_secondary_email'),
]
operations = [
migrations.AlterUniqueTogether(
name='mailbox',
unique_together=set(),
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(fields=('local_part', 'domain'), name='unique_username'),
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(fields=('first_name', 'last_name', 'domain'), name='unique_ox_display_name'),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-10-21 15:49
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0026_alter_mailbox_unique_together_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='mailbox',
name='unique_ox_display_name',
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('first_name'), django.db.models.functions.text.Lower('last_name'), models.F('domain'), name='unique_ox_display_name', violation_error_message='Mailbox with this First name, Last name and Domain already exists.'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.6 on 2025-10-13 12:55
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0027_remove_mailbox_unique_ox_display_name_and_more'),
]
operations = [
migrations.CreateModel(
name='Alias',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('local_part', models.CharField(max_length=100)),
('destination', models.CharField(max_length=254, validators=[django.core.validators.EmailValidator(allowlist=['localhost', 'devnull'])], verbose_name='destination address')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='mailbox_manager.maildomain')),
],
options={
'verbose_name': 'Alias',
'verbose_name_plural': 'Aliases',
'db_table': 'people_aliases',
'ordering': ['-created_at'],
'unique_together': {('local_part', 'destination')},
},
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.2.9 on 2025-12-17 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0028_alias'),
]
operations = [
migrations.AlterUniqueTogether(
name='alias',
unique_together=set(),
),
migrations.AddConstraint(
model_name='alias',
constraint=models.UniqueConstraint(fields=('domain', 'local_part', 'destination'), name='no_duplicate'),
),
]
+123 -5
View File
@@ -9,12 +9,15 @@ from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.validators import EmailValidator
from django.db import models
from django.db.models.functions import Lower
from django.template.loader import render_to_string
from django.utils.text import slugify
from django.utils.translation import get_language, gettext, override
from django.utils.translation import gettext_lazy as _
from core.exceptions import EmailAlreadyKnownException
from core.models import BaseInvitation, BaseModel, Organization, User
from mailbox_manager.enums import (
@@ -88,6 +91,8 @@ class MailDomain(BaseModel):
def __str__(self):
return self.name
objects = models.Manager()
def save(self, *args, **kwargs):
"""Override save function to compute the slug."""
self.slug = self.get_slug()
@@ -106,7 +111,7 @@ class MailDomain(BaseModel):
if user.is_authenticated:
try:
role = self.accesses.filter(user=user).values("role")[0]["role"]
except (MailDomainAccess.DoesNotExist, IndexError):
except MailDomainAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [
@@ -219,7 +224,7 @@ class MailDomainAccess(BaseModel):
authenticated_user_role = user.mail_domain_accesses.get(
domain=self.domain
).role
except (MailDomainAccess.DoesNotExist, IndexError):
except MailDomainAccess.DoesNotExist, IndexError:
return []
# only an owner can set an owner role
@@ -247,7 +252,7 @@ class MailDomainAccess(BaseModel):
if user.is_authenticated:
try:
role = user.mail_domain_accesses.filter(domain=self.domain).get().role
except (MailDomainAccess.DoesNotExist, IndexError):
except MailDomainAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [
@@ -303,7 +308,23 @@ class Mailbox(AbstractBaseUser, BaseModel):
db_table = "people_mail_box"
verbose_name = _("Mailbox")
verbose_name_plural = _("Mailboxes")
unique_together = ("local_part", "domain")
constraints = [
models.UniqueConstraint(
fields=["local_part", "domain"], name="unique_username"
),
models.UniqueConstraint(
Lower("first_name"),
Lower("last_name"),
"domain",
name="unique_ox_display_name",
violation_error_message="Mailbox with this First name, \
Last name and Domain already exists.",
),
# Display name in OpenXChange must be unique
# To avoid sending failing requests to dimail,
# we impose uniqueness here too
# And compare to lowercase to enforce case-insensitive uniqueness
]
ordering = ["-created_at"]
def __str__(self):
@@ -330,6 +351,8 @@ class Mailbox(AbstractBaseUser, BaseModel):
raise exceptions.ValidationError(
_("You can't create or update a mailbox for a disabled domain.")
)
self.local_part = self.local_part.lower()
return super().save(*args, **kwargs)
@property
@@ -341,6 +364,25 @@ class Mailbox(AbstractBaseUser, BaseModel):
"""Return the email address of the mailbox."""
return f"{self.local_part}@{self.domain.name}"
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
role = user.mail_domain_accesses.get(domain=self.domain).role
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
is_self = self.get_email() == user.email
return {
"get": bool(role),
"post": is_owner_or_admin,
"patch": is_owner_or_admin or is_self,
"put": is_owner_or_admin or is_self,
"delete": False,
}
class MailDomainInvitation(BaseInvitation):
"""User invitation to mail domains."""
@@ -374,6 +416,23 @@ class MailDomainInvitation(BaseInvitation):
)
]
def refresh(self):
"""Create domain access if refresh fail because user was created
after invitation expiration"""
try:
super().refresh()
except EmailAlreadyKnownException as exc:
user = User.objects.get(email__iexact=self.email)
MailDomainAccess.objects.create(
user=user,
domain=self.domain,
role=self.role,
)
self.delete() # delete related invitation
raise exc
def __str__(self):
return f"{self.email} invited to {self.domain}"
@@ -402,7 +461,7 @@ class MailDomainInvitation(BaseInvitation):
role = self.domain.accesses.filter(user=user).values("role")[0][
"role"
]
except (self._meta.model.DoesNotExist, IndexError):
except self._meta.model.DoesNotExist, IndexError:
role = None
can_delete = role in [
@@ -416,3 +475,62 @@ class MailDomainInvitation(BaseInvitation):
"patch": False,
"put": False,
}
class Alias(BaseModel):
"""Model for aliases."""
local_part = models.CharField(max_length=100, blank=False)
destination = models.CharField(
_("destination address"),
max_length=254,
null=False,
blank=False,
validators=[
EmailValidator(allowlist=["localhost", "devnull"]),
],
)
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
related_name="aliases",
null=False,
blank=False,
)
class Meta:
db_table = "people_aliases"
verbose_name = _("Alias")
verbose_name_plural = _("Aliases")
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["domain", "local_part", "destination"], name="no_duplicate"
)
]
def __str__(self):
return f"{self.local_part} to {self.destination}"
def get_abilities(self, user):
"""Compute and return abilities for a given user. Admin and owners can
edit aliases, but also viewer if the alias points to their email."""
try:
role = user.mail_domain_accesses.get(domain=self.domain).role
except MailDomainAccess.DoesNotExist, IndexError:
role = None
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
is_self = self.destination == user.email
return {
"get": bool(role),
"post": is_owner_or_admin,
"patch": False,
"put": False,
"delete": is_owner_or_admin or is_self,
}
@@ -0,0 +1,129 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "bulk delete" action.
"""
# pylint: disable=W0613
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_bulk_delete__anonymous_get_401():
"""Anonymous user should not be able to bulk delete."""
mail_domain = factories.MailDomainFactory()
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
client = APIClient()
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 3
def test_api_aliases_bulk_delete__no_access_get_404():
"""User with no access to domain should not be able to bulk delete."""
mail_domain = factories.MailDomainFactory()
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Alias.objects.count() == 3
def test_api_aliases_bulk_delete__viewer_get_403():
"""Viewer user should not be able to bulk delete."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 3
@responses.activate
def test_api_aliases_bulk_delete__administrators_allowed_all_destination(
dimail_token_ok,
):
"""
Administrators of a domain should be allowed to bulk delete all aliases
of a given local_part.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.Alias.objects.count() == 3
assert not models.Alias.objects.filter(
domain=mail_domain, local_part=alias_.local_part
).exists()
def test_api_aliases_bulk_delete__no_local_part_bad_request():
"""Filtering by local part is mandatory when bulk deleting aliases."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.Alias.objects.count() == 3
@@ -0,0 +1,214 @@
"""
Tests for aliases API endpoint.
Focus on "create" action.
"""
# pylint: disable=W0613
import json
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_create__anonymous():
"""Anonymous user should not create aliases"""
domain = factories.MailDomainEnabledFactory()
response = APIClient().post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"whatever": "this should not be updated"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not models.Alias.objects.exists()
def test_api_aliases_create__no_access_forbidden_not_found():
"""Unrelated user not having domain permission should not create aliases."""
domain = factories.MailDomainEnabledFactory()
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert not models.Alias.objects.exists()
def test_api_aliases_create__viewer_forbidden():
"""Domain viewers should not create aliases."""
domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(role="viewer", domain=domain)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__duplicate_forbidden():
"""Cannot create alias if existing alias same domain + local part + destination."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
existing_alias = factories.AliasFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{
"local_part": existing_alias.local_part,
"destination": existing_alias.destination,
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.Alias.objects.filter(domain=access.domain).count() == 1
@responses.activate
def test_api_aliases_create__async_alias_bad_request(dimail_token_ok):
"""
If People fall out of sync with dimail, return a clear error if alias cannot be created
because it already exists on dimail.
"""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
# Mock dimail response
responses.add(
responses.POST,
re.compile(r".*/aliases/"),
body=json.dumps({"detail": "Alias already exists"}),
status=status.HTTP_409_CONFLICT,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{
"local_part": "already_existing_alias",
"destination": "someone@outsidedomain.com",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"NON_FIELD_ERRORS": [
"Alias already exists. Your domain is out of sync, please contact our support."
]
}
assert not models.Alias.objects.exists()
@responses.activate
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN],
)
def test_api_aliases_create__admins_ok(role, dimail_token_ok):
"""Domain admins should be able to create aliases."""
access = factories.MailDomainAccessFactory(role=role)
client = APIClient()
client.force_login(access.user)
# Prepare responses
# token response in fixtures
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "contact",
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": True,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "contact", "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_201_CREATED
alias = models.Alias.objects.get()
assert alias.local_part == "contact"
assert alias.destination == "someone@outsidedomain.com"
@responses.activate
def test_api_aliases_create__existing_mailbox_ok(dimail_token_ok):
"""Can create alias even if local_part is already used by a mailbox."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": mailbox.local_part,
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": mailbox.local_part, "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__devnull_destination_ok(dimail_token_ok):
"""Can create alias where destination is devnull@devnull."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "spammy-address",
"domain": access.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "spammy-address", "destination": "devnull@devnull"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@@ -0,0 +1,139 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "delete" action.
"""
# pylint: disable=W0613
import re
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_aliases_delete__anonymous():
"""Anonymous user should not be able to delete aliases."""
alias_ = factories.AliasFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 1
def test_api_aliases_delete__no_access_forbidden_not_found():
"""
Authenticated users should not be allowed to delete an alias in a
mail domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
alias_ = factories.AliasFactory()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Alias.objects.count() == 1
def test_api_aliases_delete__viewer_forbidden():
"""
Authenticated users should not be allowed to delete aliases for a
mail domain in which they are a simple viewer.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 1
@responses.activate
def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
"""
Administrators of a mail domain should be allowed to delete aliases.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.Alias.objects.count() == 5
@responses.activate
def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
"""
Should return a clear message when dimail returns 404 not found.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
json={"detail": "Not found"},
status=status.HTTP_404_NOT_FOUND,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert (
response.json()
== "Domain out of sync with mailbox provider, please contact our support."
)
assert not models.Alias.objects.exists()
@@ -0,0 +1,65 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "list" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_aliases_list__anonymous():
"""Anonymous user should not be able to list aliases"""
domain = factories.MailDomainEnabledFactory()
factories.AliasFactory.create_batch(3, domain=domain)
response = APIClient().get(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_aliases_list__no_access_forbidden_not_found():
"""User authenticated but not having domain permission should not list aliases."""
factories.MailDomainAccessFactory() # access to another domain
domain = factories.MailDomainEnabledFactory()
factories.AliasFactory.create_batch(3, domain=domain)
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
],
)
def test_api_aliases_list__authorized_ok(role):
"""Domain viewers and admins should be able to list aliases."""
access = factories.MailDomainAccessFactory(role=role)
factories.AliasFactory.create_batch(2, local_part="support", domain=access.domain)
factories.AliasFactory.create_batch(3, domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.get(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["results"] == sorted(
response.json()["results"], key=lambda x: x["local_part"]
)
assert response.json()["count"] == 5
@@ -11,8 +11,7 @@ from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
from mailbox_manager.api.client import serializers
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
@@ -20,13 +19,12 @@ pytestmark = pytest.mark.django_db
def test_api_domain_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
domain = factories.MailDomainEnabledFactory()
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
response = APIClient().post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
{
"email": "some.email@domain.com",
"role": "admin",
},
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -35,24 +33,23 @@ def test_api_domain_invitations__create__anonymous():
}
def test_api_domain_invitations__create__authenticated_outsider():
def test_api_domain_invitations__create__no_access_forbidden_not_found():
"""Users should not be permitted to send domain management invitations
for a domain they don't manage."""
user = core_factories.UserFactory()
domain = factories.MailDomainEnabledFactory()
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
{
"email": "some.email@domain.com",
"role": "viewer",
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
@@ -65,28 +62,25 @@ def test_api_domain_invitations__admin_should_create_invites(role):
domain = factories.MailDomainEnabledFactory()
factories.MailDomainAccessFactory(domain=domain, user=user, role=role)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
{
"email": "some.email@domain.com",
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [invitation_values["email"]]
assert email.to == ["some.email@domain.com"]
assert email.subject == "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
def test_api_domain_invitations__no_access_forbidden_not_found():
"""
Domain viewers should not be able to invite new domain managers.
"""
@@ -96,21 +90,19 @@ def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
domain=domain, user=user, role=enums.MailDomainRoleChoices.VIEWER
)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build()
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
invitation_values,
{
"email": "some.email@domain.com",
"role": "viewer",
},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage invitations for this domain."
"detail": "You do not have permission to perform this action."
}
@@ -125,42 +117,81 @@ def test_api_domain_invitations__should_not_create_duplicate_invitations():
domain=domain, user=user, role=enums.MailDomainRoleChoices.OWNER
)
# Create a new invitation to the same domain with the exact same email address
duplicated_invitation = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build(email=existing_invitation.email)
).data
# New invitation to the same domain with the exact same email address
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/",
duplicated_invitation,
{
"email": existing_invitation.email,
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Mail domain invitation with this Email address and Domain already exists."
]
assert models.MailDomainInvitation.objects.count() == 1 # and specifically, not 2
def test_api_domain_invitations__should_not_invite_when_user_already_exists():
def test_api_domain_invitations__inviting_known_email_should_create_access():
"""Already existing users should not be invited but given access directly."""
existing_user = core_factories.UserFactory()
# Grant privileged role on the domain to the user
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
invitation_values = serializers.MailDomainInvitationSerializer(
factories.MailDomainInvitationFactory.build(email=existing_user.email)
).data
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
invitation_values,
{
"email": existing_user.email,
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
assert not models.MailDomainInvitation.objects.exists()
assert models.MailDomainAccess.objects.filter(user=existing_user).exists()
def test_api_domain_invitations__inviting_known_email_case_insensitive():
"""Email matching should be case-insensitive when creating access for existing users."""
# Create a user with lowercase email
existing_user = core_factories.UserFactory(email="john.doe@example.com")
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.OWNER)
client = APIClient()
client.force_login(access.user)
# Try to invite with same email different case - should create access for existing user
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/",
{
"email": "John.Doe@Example.COM",
"role": "owner",
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
# No invitation should be created
assert not models.MailDomainInvitation.objects.exists()
# Access should be created for the existing user (not a new user)
assert models.MailDomainAccess.objects.filter(user=existing_user).exists()
assert models.MailDomainAccess.objects.filter(
user=existing_user, domain=access.domain
).exists()
# Ensure only one user exists (no duplicate user created)
assert models.User.objects.filter(email__iexact="john.doe@example.com").count() == 1
@@ -0,0 +1,76 @@
"""
Tests for MailDomainInvitation API endpoint in People's app mailbox_manager.
Focus on "delete" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__delete__anonymous():
"""Anonymous users should not be able to delete invitations."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__no_access_not_found():
"""Users should not be permitted to delete invitations
on domains they don't manage."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
other_access = factories.MailDomainAccessFactory(role="owner") # unrelated access
client = APIClient()
client.force_login(other_access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__viewers_forbidden():
"""Domain viewers should not be permitted to delete invitations."""
access = factories.MailDomainAccessFactory(role="viewer")
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainInvitation.objects.count() == 1
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_domain_invitations__delete_admins_ok(role):
"""Domain owners and admins should be able to delete invitations."""
access = factories.MailDomainAccessFactory(role=role)
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.MailDomainInvitation.objects.exists()
@@ -0,0 +1,112 @@
"""
Tests for MailDomainInvitations API endpoint in People's app mailbox_manager.
Focus on "refresh" action.
"""
import pytest
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__anonymous_cannot_refresh():
"""Anonymous are not allowed to refresh."""
invitation = factories.MailDomainInvitationFactory()
response = APIClient().post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__no_access_cannot_see_invitation():
"""Users with no permission on the domain should be returned a 404 not found."""
invitation = factories.MailDomainInvitationFactory()
otro_access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.ADMIN
)
client = APIClient()
client.force_login(otro_access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_domain_invitations__viewer_cannot_refresh():
"""Viewers cannot refresh invitations."""
invitation = factories.MailDomainInvitationFactory()
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_domain_invitations__admins_can_refresh_expired_invitations():
"""Admins can refresh invitations."""
with freeze_time("2025-12-16"):
invitation = factories.MailDomainInvitationFactory()
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER
)
assert invitation.is_expired is True # check invitation is correctly expired
client = APIClient()
client.force_login(access.user)
# can refresh expired invitations
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_expired"] is False
assert models.MailDomainInvitation.objects.count() == 1
invitation.refresh_from_db()
# can also refresh valid
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_expired"] is False
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__refreshing_invitation_can_convert_to_access():
"""In the event of someone creating their account after their invitation expired,
said invitation has not been converted to access.
We don't want to create a new invitation but create an access instead."""
with freeze_time("2025-12-16"):
invitation = factories.MailDomainInvitationFactory()
core_factories.UserFactory(email=invitation.email)
access = factories.MailDomainAccessFactory(
domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER
)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/"
)
assert response.status_code == status.HTTP_201_CREATED
assert (
response.json()["detail"]
== "Email already known. Invitation not sent but access created instead."
)
assert models.MailDomainAccess.objects.count() == 2
assert not models.MailDomainInvitation.objects.exists()
@@ -26,7 +26,7 @@ def test_api_domain_invitations__anonymous_user_should_not_retrieve_invitations(
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations():
def test_api_domain_invitations__no_access_forbidden_not_found():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
@@ -40,8 +40,8 @@ def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 0
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
def test_api_domain_invitations__domain_managers_should_list_invitations():
@@ -41,17 +41,14 @@ def test_api_mail_domains__create_name_unique():
"""
Creating domain should raise an error if already existing name.
"""
factories.MailDomainFactory(name="existing_domain.com")
user = core_factories.UserFactory()
existing_domain = factories.MailDomainFactory()
client = APIClient()
client.force_login(user)
client.force_login(core_factories.UserFactory())
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": "existing_domain.com",
},
{"name": existing_domain.name},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -2,7 +2,6 @@
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "fetch" action.
"""
import json
import re
import pytest
@@ -82,7 +81,7 @@ def test_api_mail_domains__fetch_from_dimail__viewer():
],
)
@responses.activate
def test_api_mail_domains__fetch_from_dimail(role):
def test_api_mail_domains__fetch_from_dimail_admin_successful(role):
"""
Authenticated users should be allowed to fetch a domain
from dimail if they are an owner or admin.
@@ -69,7 +69,7 @@ def test_api_mail_domains__retrieve_authenticated_unrelated():
@responses.activate
def test_api_mail_domains__retrieve_authenticated_related():
def test_api_mail_domains__retrieve_authenticated_related_successful():
"""
Authenticated users should be allowed to retrieve a domain
to which they have access.
@@ -35,7 +35,7 @@ def test_api_mail_domain__accesses_create_anonymous():
assert models.MailDomainAccess.objects.exists() is False
def test_api_mail_domain__accesses_create_authenticated_unrelated():
def test_api_mail_domain__accesses_create_no_access_forbidden_not_found():
"""
Authenticated users should not be allowed to create domain accesses for a domain to
which they are not related.
@@ -56,14 +56,14 @@ def test_api_mail_domain__accesses_create_authenticated_unrelated():
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
"detail": "No MailDomain matches the given query.",
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_viewer():
def test_api_mail_domain__accesses_create_viewer_forbidden():
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
@@ -85,7 +85,7 @@ def test_api_mail_domain__accesses_create_authenticated_viewer():
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
"detail": "You do not have permission to perform this action.",
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
@@ -13,7 +13,7 @@ from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_delete_anonymous():
def test_api_domain_accesses_delete__anonymous_forbidden():
"""Anonymous users should not be allowed to destroy a mail domain access."""
access = factories.MailDomainAccessFactory()
@@ -25,7 +25,7 @@ def test_api_mail_domain__accesses_delete_anonymous():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_authenticated():
def test_api_domain_accesses_delete__unrelated_notfound():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain to which they are not related.
@@ -39,11 +39,11 @@ def test_api_mail_domain__accesses_delete_authenticated():
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_viewer():
def test_api_domain_accesses_delete__viewer_forbidden():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain in which they are a simple viewer.
@@ -65,7 +65,7 @@ def test_api_mail_domain__accesses_delete_viewer():
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
def test_api_mail_domain__accesses_delete_administrators():
def test_api_domain_accesses_delete__administrators_successful():
"""
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
"""
@@ -89,7 +89,7 @@ def test_api_mail_domain__accesses_delete_administrators():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners():
def test_api_domain_accesses_delete__owners_successful():
"""
An owner should be able to delete the mail domain access of another user including
a owner access.
@@ -114,7 +114,7 @@ def test_api_mail_domain__accesses_delete_owners():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners_last_owner():
def test_api_domain_accesses_delete__last_owner_forbidden():
"""
It should not be possible to delete the last owner access from a mail domain
"""
@@ -27,8 +27,7 @@ def test_api_mail_domain__accesses_list_anonymous():
def test_api_mail_domain__accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
to which they are not related.
User with no access should not be allowed to list domain accesses.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
@@ -43,12 +42,9 @@ def test_api_mail_domain__accesses_list_authenticated_unrelated():
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
"detail": "No MailDomain matches the given query.",
}
@@ -178,8 +174,12 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
# related users :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - filter on slug and domain on permission (to return 404 instead of 403 when no access)
# - count ?
# - distinct from viewset
with django_assert_num_queries(3):
with django_assert_num_queries(5):
client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
@@ -189,7 +189,7 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
factories.MailDomainAccessFactory(domain=mail_domain)
# num queries should still be the same
with django_assert_num_queries(3):
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
@@ -43,7 +43,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomainAccess matches the given query."}
assert response.json() == {"detail": "No MailDomain matches the given query."}
# Accesses related to another mail_domain should be excluded even if the user is related to it
for other_access in [
@@ -55,9 +55,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "No MailDomainAccess matches the given query."
}
assert response.json() == {"detail": "No MailDomain matches the given query."}
def test_api_mail_domain__accesses_retrieve_authenticated_related():
@@ -27,7 +27,7 @@ def test_api_mail_domain__accesses_update_anonymous():
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_unrelated():
def test_api_mail_domain__accesses_update_no_access_forbidden_not_found():
"""
An authenticated user should not be allowed to update a mail domain access
for a mail_domain to which they are not related.
@@ -42,7 +42,7 @@ def test_api_mail_domain__accesses_update_authenticated_unrelated():
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
@@ -30,9 +30,7 @@ def test_api_mail_domain__available_users_anonymous():
def test_api_mail_domain__available_users_forbidden():
"""Authenticated user without accesses on maildomain should not be able to see available
users.
"""
"""Users with no access should not be able to list available users."""
authenticated_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
@@ -40,7 +38,7 @@ def test_api_mail_domain__available_users_forbidden():
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
File diff suppressed because it is too large Load Diff
@@ -26,7 +26,7 @@ def test_api_mailboxes__disable_anonymous_forbidden():
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
def test_api_mailboxes__disable_authenticated_failure():
def test_api_mailboxes__disable_no_access_forbidden_not_found():
"""Authenticated users should not be able to disable mailbox
without specific role on mail domain."""
user = core_factories.UserFactory()
@@ -39,7 +39,7 @@ def test_api_mailboxes__disable_authenticated_failure():
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/disable/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
@@ -26,9 +26,8 @@ def test_api_mailboxes__enable_anonymous_forbidden():
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
def test_api_mailboxes__enable_authenticated_failure():
"""Authenticated users should not be able to enable mailbox
without specific role on mail domain."""
def test_api_mailboxes__enable_unrelated_not_found():
"""Unrelated users should not be able to enable mailbox."""
user = core_factories.UserFactory()
client = APIClient()
@@ -39,7 +38,7 @@ def test_api_mailboxes__enable_authenticated_failure():
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/enable/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
@@ -25,8 +25,8 @@ def test_api_mailboxes__list_anonymous():
}
def test_api_mailboxes__list_authenticated():
"""Authenticated users should not be able to list mailboxes"""
def test_api_mailboxes__list_unrelated_not_found():
"""Unrelated users should not be able to list mailboxes"""
user = core_factories.UserFactory()
client = APIClient()
@@ -36,10 +36,8 @@ def test_api_mailboxes__list_authenticated():
factories.MailboxFactory.create_batch(2, domain=mail_domain)
response = client.get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
@pytest.mark.parametrize(
@@ -0,0 +1,190 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mailboxes_patch__anonymous_forbidden():
"""Anonymous users should not be able to update a mailbox."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = APIClient().patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"secondary_email": "updated@example.com"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_patch__no_access_forbidden_not_found():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.local_part}/",
{"secondary_email": "updated@example.com"},
format="json",
)
# permission denied at domain level
assert response.status_code == status.HTTP_404_NOT_FOUND
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_patch__viewer_forbidden():
"""User having viewer access on a domain should not be able to update
anything on a mailbox that's not theirs."""
viewer = core_factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailDomainAccessFactory(
user=viewer,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(viewer)
# should not be able to update any field
# we only try one field as 403 is global in our implementation
old_value = mailbox.secondary_email
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"secondary_email": "updated@example.com"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mailbox.refresh_from_db()
assert mailbox.secondary_email == old_value
def test_api_mailboxes_patch__unauthorized_no_mailbox():
"""No mailbox returns a 404."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.ADMIN)
client = APIClient()
client.force_login(access.user)
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/nonexistent_mailbox_pk/",
{"secondary_email": "updated@example.com"},
format="json",
)
# permission denied at domain level
# the existence of the mailbox is not checked
assert response.status_code == status.HTTP_404_NOT_FOUND
# UPDATABLE FIELDS : SECONDARY EMAIL FIRST NAME AND LAST NAME
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_mailboxes_patch__admins_can_update_mailboxes(role):
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
valid_new_values = {
"secondary_email": "updated_mail@validformat.com",
"first_name": "Marsha",
"last_name": "Johnson",
}
for field_name in ["first_name", "last_name", "secondary_email"]:
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{field_name: valid_new_values[field_name]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert getattr(mailbox, field_name) == valid_new_values[field_name]
def test_api_mailboxes_patch__viewer_can_update_own_mailbox():
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(user)
valid_new_values = {
"secondary_email": "updated_mail@validformat.com",
"first_name": "Marsha",
"last_name": "Johnson",
}
for field_name in ["first_name", "last_name", "secondary_email"]:
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{field_name: valid_new_values[field_name]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert getattr(mailbox, field_name) == valid_new_values[field_name]
# DOMAIN AND LOCAL PART
@pytest.mark.parametrize(
"role",
["viewer", "owner", "administrator"],
)
def test_api_mailboxes_patch__no_updating_domain_or_local_part(role):
"""Domain and local parts should not be updated."""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
if role == "viewer":
# viewer has similar privileges as admins on own mailbox
user = core_factories.UserFactory(
email=f"{mailbox.local_part}@{mailbox.domain}"
)
access = factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
other_domain = factories.MailDomainEnabledFactory()
local_part = mailbox.local_part
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"local_part": "new.local.part", "domain": other_domain.name},
format="json",
)
assert response.status_code == status.HTTP_200_OK # a 400 would be better
mailbox.refresh_from_db()
assert mailbox.local_part == local_part
assert mailbox.domain == access.domain
@@ -0,0 +1,192 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
# PUT expects all the object's updatable fields.
# We'll use this dict on all the following tests
NEW_VALUES = {
"secondary_email": "marsha.p@social.us",
"first_name": "Marsha",
"last_name": "Johnson",
}
def test_api_mailboxes_put__anonymous_forbidden():
"""Anonymous users should not be able to update a mailbox."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = APIClient().put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_put__no_access_forbidden_not_found():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
client = APIClient()
client.force_login(core_factories.UserFactory())
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
# permission denied at domain level
assert response.status_code == status.HTTP_404_NOT_FOUND
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_put__unauthorized_no_mailbox():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
client = APIClient()
client.force_login(core_factories.UserFactory())
domain = factories.MailDomainEnabledFactory()
response = client.put(
f"/api/v1.0/mail-domains/{domain.slug}/mailboxes/nonexistent_mailbox_pk/",
NEW_VALUES,
format="json",
)
# permission denied at domain level
# the existence of the mailbox is not checked
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mailboxes_put__viewer_forbidden():
"""User having viewer access on a domain should not be able to update
anything on a mailbox that's not theirs."""
viewer = core_factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailDomainAccessFactory(
user=viewer,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(viewer)
# should not be able to update any field
# we only try one field as 403 is global in our implementation
old_value = mailbox.secondary_email
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mailbox.refresh_from_db()
assert mailbox.secondary_email == old_value
# UPDATABLE FIELDS : SECONDARY EMAIL, FIRST NAME AND LAST NAME
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_mailboxes_put__admins_can_update_mailboxes(role):
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
result = response.json()
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert result["first_name"] == NEW_VALUES["first_name"]
assert result["last_name"] == NEW_VALUES["last_name"]
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
def test_api_mailboxes_put__viewer_can_update_own_mailbox():
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
result = response.json()
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert result["first_name"] == NEW_VALUES["first_name"]
assert result["last_name"] == NEW_VALUES["last_name"]
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
# DOMAIN AND LOCAL PART
@pytest.mark.parametrize(
"role",
["viewer", "owner", "administrator"],
)
def test_api_mailboxes_put__no_updating_domain_or_local_part(role):
"""Domain and local parts should not be updated."""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
if role == "viewer":
# viewer has similar privileges as admins on own mailbox
user = core_factories.UserFactory(
email=f"{mailbox.local_part}@{mailbox.domain}"
)
access = factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
other_domain = factories.MailDomainEnabledFactory()
old_local_part = mailbox.local_part
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{**NEW_VALUES, "local_part": "new.local.part", "domain": other_domain.name},
format="json",
)
assert response.status_code == status.HTTP_200_OK # This should not be a 200
mailbox.refresh_from_db()
assert mailbox.local_part == old_local_part
assert mailbox.domain == access.domain
@@ -28,7 +28,7 @@ def test_api_mailboxes__reset_password_anonymous_unauthorized():
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__reset_password_unrelated_forbidden():
def test_api_mailboxes__reset_password_no_access_forbidden_not_found():
"""Authenticated users not managing the domain
should not be able to reset its mailboxes password."""
user = core_factories.UserFactory()
@@ -41,9 +41,9 @@ def test_api_mailboxes__reset_password_unrelated_forbidden():
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "You do not have permission to perform this action."
"detail": "No MailDomain matches the given query.",
}
@@ -113,7 +113,7 @@ Please add a valid secondary email before trying again."
],
)
@responses.activate
def test_api_mailboxes__reset_password_admin_successful(role):
def test_api_mailboxes__reset_password_admin_successful(role, dimail_token_ok): # pylint: disable=W0613
"""Owner and admin users should be able to reset password on mailboxes.
New password should be sent to secondary email."""
mail_domain = factories.MailDomainEnabledFactory()
@@ -124,12 +124,7 @@ def test_api_mailboxes__reset_password_admin_successful(role):
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
# token response in fixtures
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
@@ -161,7 +156,7 @@ def test_api_mailboxes__reset_password_non_existing():
@responses.activate
def test_api_mailboxes__reset_password_connexion_failed():
def test_api_mailboxes__reset_password_connexion_failed(dimail_token_ok): # pylint: disable=W0613
"""
No mail is sent when password reset failed because of connexion error.
"""
@@ -175,12 +170,7 @@ def test_api_mailboxes__reset_password_connexion_failed():
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
# token response in fixtures
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
@@ -0,0 +1,68 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories
pytestmark = pytest.mark.django_db
def test_api_mailboxes__retrieve_anonymous_forbidden():
"""Anonymous users should not be able to retrieve a new mailbox via the API."""
mailbox = factories.MailboxFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__retrieve_no_access_forbidden_not_found():
"""Authenticated but unauthorized users should not be able to
retrieve mailbox. Response should be indistinguisable from normal 404"""
client = APIClient()
client.force_login(core_factories.UserFactory())
mailbox = factories.MailboxFactory()
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
# even if they have a mailbox on this domain
mailbox = factories.MailboxFactory()
client.force_login(core_factories.UserFactory(email=str(mailbox)))
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mailboxes__retrieve_authorized_ok():
"""Authorized users should be able to retrieve mailboxes."""
access = factories.MailDomainAccessFactory()
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(mailbox.id),
"first_name": mailbox.first_name,
"last_name": mailbox.last_name,
"local_part": mailbox.local_part,
"secondary_email": mailbox.secondary_email,
"status": mailbox.status,
}
@@ -0,0 +1,35 @@
"""
Fixtures for mailbox manager tests
"""
import re
import pytest
import responses
from rest_framework import status
from mailbox_manager import factories
## DIMAIL RESPONSES
@pytest.fixture(name="dimail_token_ok")
def fixture_dimail_token_ok():
"""Mock dimail response when /token/ endpoit is given valid credentials."""
responses.get(
re.compile(r".*/token/"),
json={"access_token": "token", "token_type": "bearer"},
status=status.HTTP_200_OK,
content_type="application/json",
)
@pytest.fixture(name="mailbox_data")
def fixture_mailbox_data():
"""Provides valid mailbox data for tests."""
example = factories.MailboxFactory.build()
return {
"first_name": example.first_name,
"last_name": example.last_name,
"local_part": example.local_part,
"secondary_email": example.secondary_email,
}
+2 -12
View File
@@ -3,9 +3,8 @@
import json
## USERS
def response_user_created(user_sub):
"""mimic dimail response upon successful user creation."""
return json.dumps(
@@ -19,7 +18,6 @@ def response_user_created(user_sub):
## DOMAINS
CHECK_DOMAIN_BROKEN = {
"name": "example.fr",
"state": "broken",
@@ -289,13 +287,7 @@ DOMAIN_SPEC = [
]
## TOKEN
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
## ALLOWS
def response_allows_created(user_name, domain_name):
"""mimic dimail response upon successful allows creation.
Dimail expects a name but our names are ProConnect's uuids."""
@@ -303,8 +295,6 @@ def response_allows_created(user_name, domain_name):
## MAILBOXES
def response_mailbox_created(email_address):
"""mimic dimail response upon successful mailbox creation."""
return json.dumps({"email": email_address, "password": "password"})
return json.dumps({"email": email_address.lower(), "password": "password"})
@@ -0,0 +1,19 @@
"""
Unit tests for the Alias model
"""
import pytest
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_models_aliases__devnull_destination_ok():
"""Can create alias where destination is devnull@devnull."""
models.Alias.objects.create(
local_part="spam",
domain=factories.MailDomainEnabledFactory(),
destination="devnull@devnull",
)
@@ -19,7 +19,6 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
TOKEN_OK,
response_mailbox_created,
)
@@ -101,7 +100,9 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(
client, dimail_token_ok
): # pylint: disable=W0613
"""Test admin action should switch domain state to ENABLED
when dimail's response is "ok". It should also activate any pending mailbox."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
@@ -127,13 +128,7 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
content_type="application/json",
)
# we need to get a token to create mailboxes
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
@@ -208,7 +203,7 @@ def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(clie
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes(client):
def test_send_pending_mailboxes(client, dimail_token_ok): # pylint: disable=W0613
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -223,13 +218,7 @@ def test_send_pending_mailboxes(client):
url = reverse("admin:mailbox_manager_maildomain_changelist")
for mailbox in mailboxes:
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
@@ -247,7 +236,7 @@ def test_send_pending_mailboxes(client):
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes__listing_failed_mailboxes(client):
def test_send_pending_mailboxes__listing_failed_mailboxes(client, dimail_token_ok): # pylint: disable=W0613
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -261,13 +250,7 @@ def test_send_pending_mailboxes__listing_failed_mailboxes(client):
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
@@ -2,12 +2,10 @@
Unit tests for dimail client
"""
import json
# pylint: disable=W0613
import logging
import re
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from logging import Logger
from unittest import mock
import pytest
import responses
@@ -21,14 +19,14 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN_EXTERNAL,
CHECK_DOMAIN_BROKEN_INTERNAL,
CHECK_DOMAIN_OK,
TOKEN_OK,
response_mailbox_created,
)
pytestmark = pytest.mark.django_db
def test_dimail_synchronization__already_sync():
@responses.activate
def test_dimail_synchronization__already_sync(dimail_token_ok):
"""
No mailbox should be created when everything is already synced.
"""
@@ -39,35 +37,26 @@ def test_dimail_synchronization__already_sync():
assert pre_sync_mailboxes.count() == 3
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "dimail_people_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
[
{
"type": "mailbox",
"status": "broken",
"email": f"{mailbox.local_part}@{domain.name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
for mailbox in pre_sync_mailboxes
]
),
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
# Ensure successful response using "responses":
# token response in fixtures
responses.get(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
json=[
{
"type": "mailbox",
"status": "broken",
"email": f"{mailbox.local_part}@{domain.name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
for mailbox in pre_sync_mailboxes
],
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
post_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
assert post_sync_mailboxes.count() == 3
@@ -75,97 +64,159 @@ def test_dimail_synchronization__already_sync():
assert set(models.Mailbox.objects.filter(domain=domain)) == set(pre_sync_mailboxes)
@mock.patch.object(Logger, "warning")
def test_dimail_synchronization__synchronize_mailboxes(mock_warning):
"""A mailbox existing solely on dimail should be synchronized
upon calling sync function on its domain"""
@responses.activate
def test_dimail_synchronization__synchronize_mailboxes(caplog, dimail_token_ok): # pylint: disable=W0613, R0914
"""Importing mailboxes from dimail should synchronize valid mailboxes
and log errors for invalid ones."""
caplog.set_level(logging.INFO)
domain = factories.MailDomainEnabledFactory()
assert not models.Mailbox.objects.exists()
existing_alias = factories.AliasFactory(domain=domain)
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "dimail_people_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
# Ensure successful response using "responses":
# successful token in fixtures
mailbox_valid = {
"type": "mailbox",
"status": "ok",
"email": f"validmailbox@{domain.name}",
"givenName": "Michael",
"surName": "Roch",
"displayName": "Michael Roch",
}
mailbox_oxadmin = {
"type": "mailbox",
"status": "broken",
"email": f"oxadmin@{domain.name}",
"givenName": "Admin",
"surName": "Context",
"displayName": "Context Admin",
}
mailbox_with_wrong_domain = {
"type": "mailbox",
"status": "broken",
"email": "johndoe@wrongdomain.com",
"givenName": "John",
"surName": "Doe",
"displayName": "John Doe",
}
mailbox_with_invalid_domain = {
"type": "mailbox",
"status": "broken",
"email": f"naw@ake@{domain.name}",
"givenName": "Joe",
"surName": "Doe",
"displayName": "Joe Doe",
}
mailbox_with_invalid_local_part = {
"type": "mailbox",
"status": "broken",
"email": f"obalmaské@{domain.name}",
"givenName": "Jean",
"surName": "Vang",
"displayName": "Jean Vang",
}
mailbox_existing_alias = {
"type": "mailbox",
"status": "broken",
"email": f"{existing_alias.local_part}@{domain.name}",
"givenName": "Support",
"surName": "email",
"displayName": "Support email",
}
mailbox_valid = {
"type": "mailbox",
"status": "broken",
"email": f"oxadmin@{domain.name}",
"givenName": "Admin",
"surName": "Context",
"displayName": "Context Admin",
}
mailbox_with_wrong_domain = {
"type": "mailbox",
"status": "broken",
"email": "johndoe@wrongdomain.com",
"givenName": "John",
"surName": "Doe",
"displayName": "John Doe",
}
mailbox_with_invalid_domain = {
"type": "mailbox",
"status": "broken",
"email": f"naw@ake@{domain.name}",
"givenName": "Joe",
"surName": "Doe",
"displayName": "Joe Doe",
}
mailbox_with_invalid_local_part = {
"type": "mailbox",
"status": "broken",
"email": f"obalmaské@{domain.name}",
"givenName": "Jean",
"surName": "Vang",
"displayName": "Jean Vang",
}
responses.get(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
json=[
mailbox_valid,
mailbox_oxadmin,
mailbox_with_wrong_domain,
mailbox_with_invalid_domain,
mailbox_with_invalid_local_part,
mailbox_existing_alias,
],
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
[
mailbox_valid,
mailbox_with_wrong_domain,
mailbox_with_invalid_domain,
mailbox_with_invalid_local_part,
]
),
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
imported_mailboxes = dimail_client.import_mailboxes(domain)
# 4 imports failed: oxadmin, wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert len(caplog.records) == 5
log_messages = [record.message for record in caplog.records]
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert mock_warning.call_count == 3
expected_messages = [
f"Not importing OX technical address: oxadmin@{domain.name}",
f"Import of email {mailbox_with_wrong_domain['email']} failed because of a wrong domain",
f"Import of email {mailbox_with_invalid_domain['email']} failed with error Invalid Domain",
f"Import of email {mailbox_with_invalid_local_part['email']} failed with error local-part \
contains non-ASCII characters)",
]
for message in expected_messages:
assert message in log_messages
# first we try to import email with a wrong domain
assert mock_warning.call_args_list[0][0] == (
"Import of email %s failed because of a wrong domain",
mailbox_with_wrong_domain["email"],
)
assert models.Mailbox.objects.count() == 2
assert imported_mailboxes == [
mailbox_valid["email"],
mailbox_existing_alias["email"],
]
# then we try to import email with invalid domain
invalid_mailbox_log = mock_warning.call_args_list[1][0]
assert invalid_mailbox_log[1] == mailbox_with_invalid_domain["email"]
assert isinstance(invalid_mailbox_log[2], HeaderParseError)
# finally we try to import email with non ascii local part
non_ascii_mailbox_log = mock_warning.call_args_list[2][0]
assert non_ascii_mailbox_log[1] == mailbox_with_invalid_local_part["email"]
assert isinstance(non_ascii_mailbox_log[2], NonASCIILocalPartDefect)
@responses.activate
def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint: disable=unused-argument
"""Importing aliases from dimail should synchronize valid aliases
and log errors for invalid ones."""
alias = factories.AliasFactory()
dimail_client = DimailAPIClient()
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == "oxadmin"
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
assert imported_mailboxes == [mailbox_valid["email"]]
existing_mailbox = factories.MailboxFactory(domain=alias.domain)
# Ensure successful response using "responses":
# token response in fixtures
incoming_aliases = [
{
"username": "contact",
"domain": alias.domain.name,
"destination": alias.destination, # same destination = ok
"allow_to_send": False,
},
{
"username": alias.local_part, # same username = ok
"domain": alias.domain.name,
"destination": "maheius.endorecles@somethingelse.com",
"allow_to_send": False,
},
{ # same username + same destination = big nono
"username": alias.local_part,
"domain": alias.domain.name,
"destination": alias.destination,
"allow_to_send": False,
},
{ # mailbox with same username = ok
"username": existing_mailbox.local_part,
"domain": alias.domain.name,
"destination": existing_mailbox.secondary_email,
"allow_to_send": False,
},
{ # alias to devnull@devnull
"username": "spam",
"domain": alias.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
]
responses.get(
re.compile(rf".*/domains/{alias.domain.name}/aliases/"),
json=incoming_aliases,
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_aliases = dimail_client.import_aliases(alias.domain)
assert len(imported_aliases) == 4
assert models.Alias.objects.count() == 5
@pytest.mark.parametrize(
@@ -183,10 +234,9 @@ def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
domain = factories.MailDomainFactory(status=domain_status)
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
json=body_content,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -221,10 +271,9 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
domain = factories.MailDomainFactory(status=domain_status)
body_domain_broken = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -238,10 +287,9 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
# Now domain is OK again
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_ok),
json=body_domain_ok,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -267,18 +315,16 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
# nothing can be done by support team, domain should be in failed
body_domain_broken = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and still returns KO for internal checks
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -305,10 +351,9 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
# with all checks KO, domain should be in action required
body_domain_broken = CHECK_DOMAIN_BROKEN.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -324,20 +369,18 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
# the fetch_domain_status call
body_domain_broken_internal = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken_internal["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken_internal),
json=body_domain_broken_internal,
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and returns OK. Hooray!
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_domain_ok),
json=body_domain_ok,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -348,7 +391,8 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
assert domain.last_check_details == body_domain_ok
def test_dimail__send_pending_mailboxes(caplog):
@responses.activate
def test_dimail__send_pending_mailboxes(caplog, dimail_token_ok):
"""Status of pending mailboxes should switch to "enabled"
when calling send_pending_mailboxes."""
caplog.set_level(logging.INFO)
@@ -365,22 +409,16 @@ def test_dimail__send_pending_mailboxes(caplog):
)
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.send_pending_mailboxes(domain=domain)
# Ensure successful response using "responses":
# token response in fixtures
responses.post(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.send_pending_mailboxes(domain=domain)
mailbox1.refresh_from_db()
mailbox2.refresh_from_db()
+5 -1
View File
@@ -29,7 +29,11 @@ maildomain_related_router.register(
viewsets.MailDomainInvitationViewset,
basename="invitations",
)
maildomain_related_router.register(
"aliases",
viewsets.AliasViewSet,
basename="aliases",
)
urlpatterns = [
path(
+261 -49
View File
@@ -2,7 +2,6 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
import ast
import json
import smtplib
from email.errors import HeaderParseError, NonASCIILocalPartDefect
@@ -46,7 +45,7 @@ class DimailAPIClient:
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
API_TIMEOUT = settings.MAIL_PROVISIONING_API_TIMEOUT
def get_headers(self):
def _get_headers(self):
"""
Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
to get a token from dimail /token/ endpoint.
@@ -83,7 +82,7 @@ class DimailAPIClient:
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_domain(self, domain_name, request_user):
"""Send a domain creation request to dimail API."""
@@ -118,7 +117,7 @@ class DimailAPIClient:
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_mailbox(self, mailbox, request_user=None):
"""Send a CREATE mailbox request to mail provisioning API."""
@@ -131,7 +130,7 @@ class DimailAPIClient:
# displayName value has to be unique
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
headers = self.get_headers()
headers = self._get_headers()
try:
response = session.post(
@@ -166,7 +165,36 @@ class DimailAPIClient:
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
return self.raise_exception_for_unexpected_response(response)
# Dimail doesn't return a clear error for duplicate yet
# but a 500 internal error
if response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
try:
address = session.get(
f"{self.API_URL}/domains/{mailbox.domain.name}/address/{mailbox.local_part}/",
json=payload,
headers=headers,
verify=True,
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if address.status_code == status.HTTP_200_OK:
primary = address.json()["ox_primary_email"]
raise exceptions.ValidationError(
{
"NON_FIELD_ERRORS": [
f"First name + last name combination already in use in this context : {primary}."
]
}
)
return self._raise_exception_for_unexpected_response(response)
def create_user(self, user_id):
"""Send a request to dimail, to create a new user there. In dimail, user ids are subs."""
@@ -203,7 +231,7 @@ class DimailAPIClient:
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_allow(self, user_id, domain_name):
"""Send a request to dimail for a new 'allow' between user and the domain."""
@@ -245,9 +273,9 @@ class DimailAPIClient:
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def raise_exception_for_unexpected_response(self, response):
def _raise_exception_for_unexpected_response(self, response):
"""Raise error when encountering an unexpected error in dimail."""
try:
error_content = json.loads(
@@ -271,7 +299,7 @@ class DimailAPIClient:
title, template_name, recipient, mailbox_data, issuer
)
def notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
def _notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
"""
Send email to notify of password reset
and send new password.
@@ -331,7 +359,7 @@ class DimailAPIClient:
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/mailboxes/",
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -344,43 +372,48 @@ class DimailAPIClient:
raise error
if response.status_code != status.HTTP_200_OK:
return self.raise_exception_for_unexpected_response(response)
dimail_mailboxes = ast.literal_eval(
response.content.decode("utf-8")
) # format output str to proper list
return self._raise_exception_for_unexpected_response(response)
dimail_mailboxes = response.json()
people_mailboxes = models.Mailbox.objects.filter(domain=domain)
imported_mailboxes = []
for dimail_mailbox in dimail_mailboxes:
if not dimail_mailbox["email"] in [
try:
address = Address(addr_spec=dimail_mailbox["email"])
except (HeaderParseError, NonASCIILocalPartDefect) as error:
logger.warning(
"Import of email %s failed with error %s",
dimail_mailbox["email"],
error,
)
continue
if address.username == "oxadmin":
logger.warning(
"Not importing OX technical address: %s", dimail_mailbox["email"]
)
continue
if str(address) not in [
str(people_mailbox) for people_mailbox in people_mailboxes
]:
try:
# sometimes dimail api returns email from another domain,
# so we decide to exclude this kind of email
address = Address(addr_spec=dimail_mailbox["email"])
if address.domain == domain.name:
# creates a mailbox on our end
mailbox = models.Mailbox.objects.create(
first_name=dimail_mailbox["givenName"],
last_name=dimail_mailbox["surName"],
local_part=address.username,
domain=domain,
status=enums.MailboxStatusChoices.ENABLED,
password=make_password(None), # unusable password
)
imported_mailboxes.append(str(mailbox))
else:
logger.warning(
"Import of email %s failed because of a wrong domain",
dimail_mailbox["email"],
)
except (HeaderParseError, NonASCIILocalPartDefect) as err:
# sometimes dimail api returns email from another domain,
# so we decide to exclude this kind of email
if address.domain == domain.name:
# creates a mailbox on our end
mailbox = models.Mailbox.objects.create(
first_name=dimail_mailbox["givenName"],
last_name=dimail_mailbox["surName"],
local_part=address.username,
domain=domain,
status=enums.MailboxStatusChoices.ENABLED,
password=make_password(None), # unusable password
)
imported_mailboxes.append(str(mailbox))
else:
logger.warning(
"Import of email %s failed with error %s",
"Import of email %s failed because of a wrong domain",
dimail_mailbox["email"],
err,
)
return imported_mailboxes
@@ -389,7 +422,7 @@ class DimailAPIClient:
response = session.patch(
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}",
json={"active": "no"},
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -401,7 +434,7 @@ class DimailAPIClient:
request_user,
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def enable_mailbox(self, mailbox, request_user=None):
"""Send a request to enable a mailbox to dimail API"""
@@ -413,7 +446,7 @@ class DimailAPIClient:
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
},
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -425,7 +458,7 @@ class DimailAPIClient:
request_user,
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def send_pending_mailboxes(self, domain):
"""Send requests for all pending mailboxes of a domain. Returns a list of failed mailboxes for this domain."""
@@ -474,7 +507,7 @@ class DimailAPIClient:
raise error
if response.status_code == status.HTTP_200_OK:
return response.json()
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def fix_domain(self, domain):
"""Send a request to dimail to fix a domain.
@@ -491,7 +524,7 @@ class DimailAPIClient:
str(domain),
)
return response.json()
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def fetch_domain_status(self, domain):
"""Send a request to check and update status of a domain."""
@@ -616,7 +649,7 @@ class DimailAPIClient:
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -630,7 +663,7 @@ class DimailAPIClient:
if response.status_code == status.HTTP_200_OK:
# send new password to secondary email
self.notify_mailbox_password_reset(
self._notify_mailbox_password_reset(
recipient=mailbox.secondary_email,
mailbox_data={
"email": response.json()["email"],
@@ -642,4 +675,183 @@ class DimailAPIClient:
mailbox,
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_alias(self, alias, request_user=None):
"""Send a Create alias request to mail provisioning API."""
payload = {
"user_name": alias.local_part,
"destination": alias.destination,
}
headers = self._get_headers()
try:
response = session.post(
f"{self.API_URL}/domains/{alias.domain.name}/aliases/",
json=payload,
headers=headers,
verify=True,
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if response.status_code == status.HTTP_201_CREATED:
logger.info(
"User %s linked alias %s to a new email.",
request_user,
f"{alias.local_part}@{alias.domain}",
)
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(alias.domain),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
if response.status_code == status.HTTP_409_CONFLICT:
logger.error(
"[DIMAIL] Out of sync with dimail. Admin, please import aliases for domain %s",
str(alias.domain),
)
raise exceptions.ValidationError(
{
"NON_FIELD_ERRORS": [
"Alias already exists. Your domain is out of sync, please contact our support."
]
}
)
return self._raise_exception_for_unexpected_response(response)
def delete_alias(self, alias, request_user=None):
"""Send a Delete alias request to mail provisioning API."""
headers = self._get_headers()
try:
response = session.delete(
f"{self.API_URL}/domains/{alias.domain.name}/aliases/{alias.local_part}/{alias.destination}",
json={},
headers=headers,
verify=True,
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if response.status_code == status.HTTP_204_NO_CONTENT:
logger.info(
"User %s removed destination %s from alias %s.",
request_user,
alias.destination,
f"{alias.local_part}@{alias.domain}",
)
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(alias.domain),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
if response.status_code == status.HTTP_404_NOT_FOUND:
logger.error(
"[DIMAIL] 404, alias %s not found. Domain out of sync with dimail. Admin, please import aliases for domain %s",
str(alias.local_part),
str(alias.domain),
)
# we don't raise error because we actually want this alias to be deleted
return response
return self._raise_exception_for_unexpected_response(response)
def delete_multiple_alias(self, local_part, domain_name):
"""Send a Delete alias request to mail provisioning API."""
try:
response = session.delete(
f"{self.API_URL}/domains/{domain_name}/aliases/{local_part}/all",
json={},
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
# response.raise_for_status()
return response
def import_aliases(self, domain):
"""Import aliases from dimail. Useful if people fall out of sync with dimail."""
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/aliases/",
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
except requests.exceptions.ConnectionError as error:
logger.error(
"Connection error while trying to reach %s.",
self.API_URL,
exc_info=error,
)
raise error
if response.status_code != status.HTTP_200_OK:
return self._raise_exception_for_unexpected_response(response)
incoming_aliases = response.json()
known_aliases = [
(known_alias.local_part, known_alias.destination)
for known_alias in models.Alias.objects.filter(domain=domain)
]
imported_aliases = []
for incoming_alias in incoming_aliases:
if (
incoming_alias["username"],
incoming_alias["destination"],
) not in known_aliases:
try:
new_alias = models.Alias.objects.create(
local_part=incoming_alias["username"],
destination=incoming_alias["destination"],
domain=domain,
)
except (HeaderParseError, NonASCIILocalPartDefect) as err:
logger.warning(
"Import of alias %s to %s failed with error %s",
incoming_alias["username"],
incoming_alias["destination"],
err,
)
else:
imported_aliases.append(str(new_alias))
return imported_aliases
+10 -31
View File
@@ -74,7 +74,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* DJANGO_SENTRY_DSN
* SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -102,7 +102,7 @@ class Base(Configuration):
DATABASES = {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
"django.db.backends.postgresql",
environ_name="DB_ENGINE",
environ_prefix=None,
),
@@ -173,9 +173,6 @@ class Base(Configuration):
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"),
os.path.join(
BASE_DIR, "admin", "templates"
), # enforce load before Django's admin
],
"OPTIONS": {
"context_processors": [
@@ -227,7 +224,6 @@ class Base(Configuration):
INSTALLED_APPS = [
# People
"admin.apps.PeopleAdminConfig", # replaces 'django.contrib.admin'
"core",
"demo",
"mailbox_manager.apps.MailboxManagerConfig",
@@ -246,6 +242,7 @@ class Base(Configuration):
"parler",
"rest_framework",
"treebeard",
"lasuite.admin", # must be before django.contrib.admin
# Django
"django.contrib.auth",
"django.contrib.contenttypes",
@@ -254,6 +251,7 @@ class Base(Configuration):
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.admin",
# OIDC third party
"mozilla_django_oidc",
]
@@ -341,7 +339,7 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -581,29 +579,14 @@ class Base(Configuration):
environ_name="MAIL_CHECK_DOMAIN_INTERVAL",
environ_prefix=None,
)
DNS_PROVISIONING_TARGET_ZONE = values.Value(
default=None,
environ_name="DNS_PROVISIONING_TARGET_ZONE",
MATRIX_BASE_HOME_SERVER = values.Value(
default="https://matrix.agent.dinum.tchap.gouv.fr",
environ_name="MATRIX_BASE_HOME_SERVER",
environ_prefix=None,
)
DNS_PROVISIONING_API_URL = values.Value(
default="https://api.scaleway.com",
environ_name="DNS_PROVISIONING_API_URL",
environ_prefix=None,
)
DNS_PROVISIONING_RESOURCE_ID = values.Value(
MATRIX_BOT_ACCESS_TOKEN = values.Value(
default=None,
environ_name="DNS_PROVISIONING_RESOURCE_ID",
environ_prefix=None,
)
DNS_PROVISIONING_API_CREDENTIALS = values.Value(
default=None,
environ_name="DNS_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
TCHAP_ACCESS_TOKEN = values.Value(
default=None,
environ_name="TCHAP_ACCESS_TOKEN",
environ_name="MATRIX_BOT_ACCESS_TOKEN",
environ_prefix=None,
)
@@ -1061,10 +1044,6 @@ class Production(Base):
},
},
}
SENTRY_DSN = values.Value(
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
environ_name="SENTRY_DSN",
)
class Feature(Production):
-13
View File
@@ -11,22 +11,9 @@ from core.plugins.registry import register_hook
from plugins.la_suite.hooks_utils.all_organizations import (
get_organization_name_and_metadata_from_siret,
)
from plugins.la_suite.hooks_utils.communes import CommuneCreation
@register_hook("organization_created")
def get_organization_name_and_metadata_from_siret_hook(organization):
"""After creating an organization, update the organization name & metadata."""
get_organization_name_and_metadata_from_siret(organization)
@register_hook("organization_created")
def commune_organization_created(organization):
"""After creating an organization, update the organization name."""
CommuneCreation().run_after_create(organization)
@register_hook("organization_access_granted")
def commune_organization_access_granted(organization_access):
"""After granting an organization access, check for needed domain access grant."""
CommuneCreation().run_after_grant_access(organization_access)
@@ -21,25 +21,45 @@ API_URL = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def _get_organization_name_and_metadata_from_results(data, siret):
"""Return the organization name and metadata from the results of a SIRET search."""
org_metadata = {}
for result in data["results"]:
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
org_metadata["is_public_service"] = result.get("complements", {}).get(
"est_service_public", False
)
org_metadata["is_commune"] = (
str(result.get("nature_juridique", "")) == "7210"
)
# Find matching organization
match = next(
(
(res, org)
for res in data.get("results", [])
for org in res.get("matching_etablissements", [])
if org.get("siret") == siret
),
None,
)
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title(), org_metadata
if name := result.get("nom_raison_sociale"):
return name.title(), org_metadata
if not match:
logger.warning("No organization name found for SIRET %s", siret)
return None, {}
result, organization = match
# Extract metadata
is_commune = str(result.get("nature_juridique", "")) == "7210"
metadata = {
"is_public_service": result.get("complements", {}).get(
"est_service_public", False
),
"is_commune": is_commune,
}
# Extract name (priority: commune name > store signs > business name)
name = None
if is_commune:
name = result.get("siege", {}).get("libelle_commune")
if not name: # Fallback for non-communes OR if commune has no libelle_commune
store_signs = organization.get("liste_enseignes") or []
name = store_signs[0] if store_signs else result.get("nom_raison_sociale")
if name:
return name.title(), metadata
logger.warning("No organization name found for SIRET %s", siret)
return None, org_metadata
return None, metadata
def get_organization_name_and_metadata_from_siret(organization):
@@ -1,243 +0,0 @@
"""Organization related plugins."""
import logging
import re
from django.conf import settings
from django.utils.text import slugify
import requests
from requests.adapters import HTTPAdapter, Retry
from mailbox_manager.enums import MailDomainRoleChoices
from mailbox_manager.models import MailDomain, MailDomainAccess
logger = logging.getLogger(__name__)
class ApiCall:
"""Encapsulates a call to an external API"""
inputs: dict = {}
method: str = "GET"
base: str = ""
url: str = ""
params: dict = {}
headers: dict = {}
response_data = None
def execute(self):
"""Call the specified API endpoint with supplied parameters and record response"""
if self.method in ("POST", "PATCH"):
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
json=self.params,
headers=self.headers,
timeout=20,
)
else:
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
params=self.params,
headers=self.headers,
timeout=20,
)
self.response_data = response.json()
logger.info(
"API call: %s %s %s %s",
self.method,
self.url,
self.params,
self.response_data,
)
class CommuneCreation:
"""
This plugin handles setup tasks for French communes.
"""
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def get_organization_name_from_results(self, data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
nature = "nature_juridique"
commune = nature in result and result[nature] == "7210"
if commune:
return result["siege"]["libelle_commune"].title()
logger.warning("Not a commune: SIRET %s", siret)
return None
def dns_call(self, spec):
"""Call to add a DNS record"""
zone_name = self.zone_name(spec.inputs["name"])
records = [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec.response_data
]
result = ApiCall()
result.method = "PATCH"
result.base = "https://api.scaleway.com"
result.url = f"/domain/v2beta1/dns-zones/{zone_name}/records"
result.params = {"changes": [{"add": {"records": records}}]}
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
return result
def normalize_name(self, name: str) -> str:
"""Map the name to a standard form"""
name = re.sub("'", "-", name)
return slugify(name)
def zone_name(self, name: str) -> str:
"""Derive the zone name from the commune name"""
normalized = self.normalize_name(name)
return f"{normalized}.{settings.DNS_PROVISIONING_TARGET_ZONE}"
def complete_commune_creation(self, name: str) -> ApiCall:
"""Specify the tasks to be completed after a commune is created."""
inputs = {"name": self.normalize_name(name)}
create_zone = ApiCall()
create_zone.method = "POST"
create_zone.base = "https://api.scaleway.com"
create_zone.url = "/domain/v2beta1/dns-zones"
create_zone.params = {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": settings.DNS_PROVISIONING_TARGET_ZONE,
"subdomain": inputs["name"],
}
create_zone.headers = {
"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS
}
zone_name = self.zone_name(inputs["name"])
create_domain = ApiCall()
create_domain.method = "POST"
create_domain.base = settings.MAIL_PROVISIONING_API_URL
create_domain.url = "/domains/"
create_domain.params = {
"name": zone_name,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": zone_name,
}
create_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
spec_domain = ApiCall()
spec_domain.inputs = inputs
spec_domain.base = settings.MAIL_PROVISIONING_API_URL
spec_domain.url = f"/domains/{zone_name}/spec"
spec_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_zone, create_domain, spec_domain]
def complete_zone_creation(self, spec_call):
"""Specify the tasks to be performed to set up the zone."""
return self.dns_call(spec_call)
def run_after_create(self, organization):
"""After creating an organization, update the organization name."""
logger.info("In CommuneCreation")
if not organization.registration_id_list:
# No registration ID to convert...
return
# In the nominal case, there is only one registration ID because
# the organization has been created from it.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.get(self._api_url.format(siret=siret), timeout=10)
response.raise_for_status()
data = response.json()
name = self.get_organization_name_from_results(data, siret)
# Not a commune ?
if not name:
return
except requests.RequestException as exc:
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
return
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
zone_name = self.zone_name(name)
support = "support-regie@numerique.gouv.fr"
MailDomain.objects.get_or_create(name=zone_name, support_email=support)
# Compute and execute the rest of the process
tasks = self.complete_commune_creation(name)
for task in tasks:
task.execute()
last_task = self.complete_zone_creation(tasks[-1])
last_task.execute()
def complete_grant_access(self, sub, zone_name):
"""Specify the tasks to be completed after making a user admin"""
create_user = ApiCall()
create_user.method = "POST"
create_user.base = settings.MAIL_PROVISIONING_API_URL
create_user.url = "/users/"
create_user.params = {
"name": sub,
"password": "no",
"is_admin": False,
"perms": [],
}
create_user.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
grant_access = ApiCall()
grant_access.method = "POST"
grant_access.base = settings.MAIL_PROVISIONING_API_URL
grant_access.url = "/allows/"
grant_access.params = {
"user": sub,
"domain": zone_name,
}
grant_access.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_user, grant_access]
def run_after_grant_access(self, organization_access):
"""After granting an organization access, check for needed domain access grant."""
orga = organization_access.organization
user = organization_access.user
zone_name = self.zone_name(orga.name)
try:
domain = MailDomain.objects.get(name=zone_name)
except MailDomain.DoesNotExist:
domain = None
if domain:
MailDomainAccess.objects.create(
domain=domain, user=user, role=MailDomainRoleChoices.OWNER
)
tasks = self.complete_grant_access(user.sub, zone_name)
for task in tasks:
task.execute()
@@ -1,231 +0,0 @@
"""Tests for the CommuneCreation plugin."""
from django.conf import settings
from django.test.utils import override_settings
import pytest
import responses
from plugins.la_suite.hooks_utils.communes import ApiCall, CommuneCreation
pytestmark = pytest.mark.django_db
def test_extract_name_from_org_data_when_commune():
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
plugin = CommuneCreation()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
def test_api_call_execution():
"""Test that calling execute() faithfully executes the API call"""
task = ApiCall()
task.method = "POST"
task.base = "https://some_host"
task.url = "some_url"
task.params = {"some_key": "some_value"}
task.headers = {"Some-Header": "Some-Header-Value"}
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
url="https://some_host/some_url",
body='{"some_key": "some_value"}',
content_type="application/json",
headers={"Some-Header": "Some-Header-Value"},
)
task.execute()
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_zone_creation():
"""Test the first task in commune creation: creating the DNS sub-zone"""
plugin = CommuneCreation()
name = "Varzy"
tasks = plugin.complete_commune_creation(name)
assert tasks[0].base == "https://api.scaleway.com"
assert tasks[0].url == "/domain/v2beta1/dns-zones"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": "collectivite.fr",
"subdomain": "varzy",
}
assert tasks[0].headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_dimail_domain_creation():
"""Test the second task in commune creation: creating the domain in Dimail"""
plugin = CommuneCreation()
name = "Merlaut"
tasks = plugin.complete_commune_creation(name)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/domains/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"name": "merlaut.collectivite.fr",
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": "merlaut.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_fetching_spec():
"""Test the third task in commune creation: asking Dimail for the spec"""
plugin = CommuneCreation()
name = "Loc-Eguiner"
tasks = plugin.complete_commune_creation(name)
assert tasks[2].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[2].url == "/domains/loc-eguiner.collectivite.fr/spec"
assert tasks[2].method == "GET"
assert (
tasks[2].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_dns_records():
"""Test the next several tasks in commune creation: creating records"""
plugin = CommuneCreation()
name = "Abidos"
spec_response = [
{"target": "", "type": "mx", "value": "mx.dev.ox.numerique.gouv.fr."},
{
"target": "dimail._domainkey",
"type": "txt",
"value": "v=DKIM1; h=sha256; k=rsa; p=MIICIjANB<truncated>AAQ==",
},
{"target": "imap", "type": "cname", "value": "imap.dev.ox.numerique.gouv.fr."},
{"target": "smtp", "type": "cname", "value": "smtp.dev.ox.numerique.gouv.fr."},
{
"target": "",
"type": "txt",
"value": "v=spf1 include:_spf.dev.ox.numerique.gouv.fr -all",
},
{
"target": "webmail",
"type": "cname",
"value": "webmail.dev.ox.numerique.gouv.fr.",
},
]
tasks = plugin.complete_commune_creation(name)
tasks[2].response_data = spec_response
expected = {
"changes": [
{
"add": {
"records": [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec_response
]
}
}
]
}
zone_call = plugin.complete_zone_creation(tasks[2])
assert zone_call.params == expected
assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records"
assert (
zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_grant_access():
"""Test the final tasks after making user admin of an org"""
plugin = CommuneCreation()
tasks = plugin.complete_grant_access("some-sub", "mezos.collectivite.fr")
assert tasks[0].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[0].url == "/users/"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"name": "some-sub",
"password": "no",
"is_admin": False,
"perms": [],
}
assert (
tasks[0].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/allows/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"user": "some-sub",
"domain": "mezos.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_normalize_name():
"""Test name normalization"""
plugin = CommuneCreation()
assert plugin.normalize_name("Asnières-sur-Saône") == "asnieres-sur-saone"
assert plugin.normalize_name("Bâgé-le-Châtel") == "bage-le-chatel"
assert plugin.normalize_name("Courçais") == "courcais"
assert plugin.normalize_name("Moÿ-de-l'Aisne") == "moy-de-l-aisne"
assert plugin.normalize_name("Salouël") == "salouel"
assert (
plugin.normalize_name("Bors (Canton de Tude-et-Lavalette)")
== "bors-canton-de-tude-et-lavalette"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_zone_name():
"""Test transforming a commune name to a sub-zone of collectivite.fr"""
plugin = CommuneCreation()
assert plugin.zone_name("Bâgé-le-Châtel") == "bage-le-chatel.collectivite.fr"
@@ -26,15 +26,6 @@ def test_hooks_loaded():
]
assert organization_created_hook_names == [
"get_organization_name_and_metadata_from_siret_hook",
"commune_organization_created",
]
organization_access_granted_hook_names = [
callback.__name__
for callback in registry.get_callbacks("organization_access_granted")
]
assert organization_access_granted_hook_names == [
"commune_organization_access_granted"
]
# cleanup the hooks
+46 -45
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.17.0"
version = "1.24.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,50 +17,51 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.13",
]
description = "An application to handle contacts and teams."
keywords = ["Django", "Contacts", "Teams", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.11"
requires-python = "~=3.14.2"
dependencies = [
"Brotli==1.1.0",
"PyJWT==2.10.1",
"boto3==1.38.33",
"celery[redis]==5.5.3",
"django-celery-beat==2.8.1",
"Brotli==1.2.0",
"PyJWT==2.12.0",
"boto3==1.42.62",
"celery[redis]==5.6.2",
"django-celery-beat==2.9.0",
"django-celery-results==2.6.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-extensions==4.1",
"django-lasuite==0.0.9",
"django-oauth-toolkit==3.0.1",
"django-lasuite==0.0.25",
"django-oauth-toolkit==3.2.0",
"django-parler==2.3",
"django-redis==5.4.0",
"django-redis==6.0.0",
"django-storages==1.14.6",
"django-timezone-field>=5.1",
"django-treebeard==4.7.1",
"django-zxcvbn-password-validator==1.4.5",
"django==5.2.3",
"djangorestframework==3.16.0",
"dockerflow==2024.4.2",
"drf_spectacular==0.28.0",
"drf_spectacular[sidecar]==0.28.0",
"easy_thumbnails==2.10",
"django-treebeard==5.0.5",
"django-zxcvbn-password-validator==1.5.3",
"django==6.0.3",
"djangorestframework==3.16.1",
"dockerflow==2026.3.4",
"drf_spectacular==0.29.0",
"drf_spectacular[sidecar]==0.29.0",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"flower==2.0.1",
"gunicorn==23.0.0",
"joserfc==1.1.0",
"jsonschema==4.24.0",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.9",
"redis==5.2.1",
"requests==2.32.4",
"sentry-sdk[django]==2.29.1",
"whitenoise==6.9.0",
"gunicorn==25.1.0",
"jaraco.context>=6.1.0",
"joserfc==1.6.3",
"jsonschema==4.26.0",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.3",
"redis<=7.2.1",
"requests==2.32.5",
"sentry-sdk[django]==2.54.0",
"whitenoise==6.12.0",
]
[project.urls]
@@ -71,22 +72,22 @@ dependencies = [
[project.optional-dependencies]
dev = [
"drf-spectacular-sidecar==2025.6.1",
"drf-spectacular-sidecar==2026.3.1",
"ipdb==0.13.13",
"ipython==9.3.0",
"jq==1.8.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.1.1",
"pytest-django==4.11.1",
"pytest==8.4.0",
"ipython==9.11.0",
"jq==1.11.0",
"pyfakefs==6.1.4",
"pylint-django==2.7.0",
"pylint==4.0.5",
"pytest-cov==7.0.0",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.11.13",
"types-requests==2.32.0.20250602",
"freezegun==1.5.2",
"pytest-xdist==3.8.0",
"responses==0.26.0",
"ruff==0.15.5",
"types-requests==2.32.4.20260107",
"freezegun==1.5.5",
]
[tool.setuptools]
+1803
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -47,6 +47,13 @@ RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
# Upgrade system packages to install security updates
USER root
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
USER -
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+1 -1
View File
@@ -1 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
+862 -3
View File
@@ -1,5 +1,864 @@
import { cunninghamConfig } from '@gouvfr-lasuite/ui-kit';
import { getThemesFromGlobals } from '@gouvfr-lasuite/cunningham-tokens';
export default {
...cunninghamConfig,
export const commonTokenOverrides = {
components: {
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
modal: {
'width-small': '342px',
},
tooltip: {
padding: '4px 8px',
},
button: {
'medium-height': '40px',
'medium-text-height': '40px',
'border-radius': '4px',
'border-radius--active': '4px',
'border-radius--focus': '4px',
},
datagrid: {
'header--color': 'ref(contextuals.content.semantic.neutral.primary)',
'header--size': '12px',
'header--weight': '500',
'body--background-color-hover':
'ref(contextuals.background.semantic.neutral.tertiary)',
},
'forms-checkbox': {
'font-size': 'ref(globals.font.sizes.sm)',
},
badge: {
'font-size': 'ref(globals.font.sizes.xs)',
'border-radius': '12px',
'padding-inline': 'ref(globals.spacings.xs)',
'padding-block': 'ref(globals.spacings.2xs)',
accent: {
'background-color':
'ref(contextuals.background.semantic.brand.secondary)',
color: 'ref(contextuals.content.semantic.brand.secondary)',
},
neutral: {
'background-color':
'ref(contextuals.background.semantic.neutral.secondary)',
color: 'ref(contextuals.content.semantic.neutral.secondary)',
},
danger: {
'background-color':
'ref(contextuals.background.semantic.error.secondary)',
color: 'ref(contextuals.content.semantic.error.secondary)',
},
success: {
'background-color':
'ref(contextuals.background.semantic.success.secondary)',
color: 'ref(contextuals.content.semantic.success.secondary)',
},
warning: {
'background-color':
'ref(contextuals.background.semantic.warning.secondary)',
color: 'ref(contextuals.content.semantic.warning.secondary)',
},
info: {
'background-color':
'ref(contextuals.background.semantic.info.secondary)',
color: 'ref(contextuals.content.semantic.info.secondary)',
},
},
},
};
export const commonGlobals = {
components: {
'la-gaufre': false,
'home-proconnect': false,
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
'xl-alt': '5rem',
'lg-alt': '4.5rem',
'md-alt': '4rem',
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: {
thin: 100,
extrabold: 800,
black: 900,
},
families: {
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
spacings: {
'0': '0',
none: '0',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
'2xl': '3rem',
xxxl: '3.5rem',
'3xl': '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
xs: '480px',
mobile: '768px',
tablet: '1024px',
},
};
export const whiteLabelGlobals = {
colors: {
'logo-1-light': '#4844AD',
'logo-2-light': '#4844AD',
'logo-1-dark': '#BEC5F0',
'logo-2-dark': '#BEC5F0',
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
'brand-200': '#BEC5F0',
'brand-250': '#AFB5F1',
'brand-300': '#A0A5F6',
'brand-350': '#8F94FD',
'brand-400': '#8184FC',
'brand-450': '#7576EE',
'brand-500': '#6969DF',
'brand-550': '#5E5CD0',
'brand-600': '#534FC2',
'brand-650': '#4844AD',
'brand-700': '#3E3B98',
'brand-750': '#36347D',
'brand-800': '#2D2F5F',
'brand-850': '#262848',
'brand-900': '#1C1E32',
'brand-950': '#11131F',
'gray-000': '#FFFFFF',
'gray-025': '#F8F8F9',
'gray-050': '#F0F0F3',
'gray-100': '#E2E2EA',
'gray-150': '#D3D4E0',
'gray-200': '#C5C6D5',
'gray-250': '#B7B7CB',
'gray-300': '#A9A9BF',
'gray-350': '#9C9CB2',
'gray-400': '#8F8FA4',
'gray-450': '#828297',
'gray-500': '#75758A',
'gray-550': '#69697D',
'gray-600': '#5D5D70',
'gray-650': '#515164',
'gray-700': '#454558',
'gray-750': '#3A3A4C',
'gray-800': '#2F303D',
'gray-850': '#25252F',
'gray-900': '#1B1B23',
'gray-950': '#111114',
'gray-1000': '#000000',
'info-050': '#EAF2F9',
'info-100': '#D5E4F3',
'info-150': '#BFD7F0',
'info-200': '#A7CAEE',
'info-250': '#8DBDEF',
'info-300': '#6EB0F2',
'info-350': '#50A2F5',
'info-400': '#3593F4',
'info-450': '#1185ED',
'info-500': '#0077DE',
'info-550': '#0069CF',
'info-600': '#005BC0',
'info-650': '#0D4EAA',
'info-700': '#124394',
'info-750': '#163878',
'info-800': '#192F5A',
'info-850': '#192541',
'info-900': '#141B2D',
'info-950': '#0C111C',
'success-050': '#E8F1EA',
'success-100': '#CFE4D4',
'success-150': '#BAD9C1',
'success-200': '#A2CFAD',
'success-250': '#86C597',
'success-300': '#6CBA83',
'success-350': '#4FB070',
'success-400': '#40A363',
'success-450': '#309556',
'success-500': '#1E884A',
'success-550': '#027B3E',
'success-600': '#016D31',
'success-650': '#006024',
'success-700': '#005317',
'success-750': '#0D4511',
'success-800': '#11380E',
'success-850': '#132A11',
'success-900': '#101E0F',
'success-950': '#091209',
'warning-050': '#F8F0E9',
'warning-100': '#F1E0D3',
'warning-150': '#ECD0BC',
'warning-200': '#E8C0A4',
'warning-250': '#E8AE8A',
'warning-300': '#EB9970',
'warning-350': '#E98456',
'warning-400': '#E57036',
'warning-450': '#DA5E18',
'warning-500': '#CB5000',
'warning-550': '#BC4200',
'warning-600': '#AD3300',
'warning-650': '#9E2300',
'warning-700': '#882011',
'warning-750': '#731E16',
'warning-800': '#58201A',
'warning-850': '#401D18',
'warning-900': '#2E1714',
'warning-950': '#1D0F0D',
'error-050': '#F9EFEC',
'error-100': '#F4DFD9',
'error-150': '#F0CEC6',
'error-200': '#EEBCB2',
'error-250': '#EEA99D',
'error-300': '#EF9486',
'error-350': '#F37C6E',
'error-400': '#F65F53',
'error-450': '#F0463D',
'error-500': '#E82322',
'error-550': '#D7010E',
'error-600': '#C00100',
'error-650': '#AA0000',
'error-700': '#910C06',
'error-750': '#731E16',
'error-800': '#58201A',
'error-850': '#401D18',
'error-900': '#2E1714',
'error-950': '#1D0F0D',
'red-050': '#FAEFEE',
'red-100': '#F4DEDD',
'red-150': '#F1CDCB',
'red-200': '#EFBBBA',
'red-250': '#EEA8A8',
'red-300': '#F09394',
'red-350': '#F37B7E',
'red-400': '#EF6569',
'red-450': '#E94A55',
'red-500': '#DA3B49',
'red-550': '#CA2A3C',
'red-600': '#BB1330',
'red-650': '#A90021',
'red-700': '#910A13',
'red-750': '#731E16',
'red-800': '#58201A',
'red-850': '#411D18',
'red-900': '#2E1714',
'red-950': '#1D0F0D',
'orange-050': '#F8F0E9',
'orange-100': '#F1E0D3',
'orange-150': '#ECD0BD',
'orange-200': '#EABFA6',
'orange-250': '#EBAC90',
'orange-300': '#EC9772',
'orange-350': '#E5845A',
'orange-400': '#D6774D',
'orange-450': '#C86A40',
'orange-500': '#B95D33',
'orange-550': '#AB5025',
'orange-600': '#9D4315',
'orange-650': '#8F3600',
'orange-700': '#812900',
'orange-750': '#6C2511',
'orange-800': '#572017',
'orange-850': '#401D18',
'orange-900': '#2E1714',
'orange-950': '#1D0F0D',
'brown-050': '#F6F0E8',
'brown-100': '#F1E0D3',
'brown-150': '#EBD0BA',
'brown-200': '#E2C0A6',
'brown-250': '#D4B398',
'brown-300': '#C6A58B',
'brown-350': '#B8987E',
'brown-400': '#AA8B71',
'brown-450': '#9D7E65',
'brown-500': '#8F7158',
'brown-550': '#82654C',
'brown-600': '#765841',
'brown-650': '#694C35',
'brown-700': '#5D412A',
'brown-750': '#51361E',
'brown-800': '#452A13',
'brown-850': '#392008',
'brown-900': '#29180A',
'brown-950': '#1B0F08',
'yellow-050': '#F3F0E7',
'yellow-100': '#E9E2CF',
'yellow-150': '#E1D4B7',
'yellow-200': '#D9C599',
'yellow-250': '#D2B677',
'yellow-300': '#CAA756',
'yellow-350': '#C2972E',
'yellow-400': '#B98900',
'yellow-450': '#AB7B00',
'yellow-500': '#9D6E00',
'yellow-550': '#916100',
'yellow-600': '#855400',
'yellow-650': '#784700',
'yellow-700': '#6C3A00',
'yellow-750': '#5F2E00',
'yellow-800': '#512302',
'yellow-850': '#3E1D10',
'yellow-900': '#2D1711',
'yellow-950': '#1D0F0D',
'green-050': '#E6F1E9',
'green-100': '#CFE4D5',
'green-150': '#B8D8C1',
'green-200': '#A0CFAE',
'green-250': '#84C59A',
'green-300': '#65BA86',
'green-350': '#45B173',
'green-400': '#23A562',
'green-450': '#029755',
'green-500': '#008948',
'green-550': '#017B3B',
'green-600': '#006E2E',
'green-650': '#006022',
'green-700': '#005314',
'green-750': '#0D4510',
'green-800': '#11380E',
'green-850': '#132A11',
'green-900': '#101E0F',
'green-950': '#091209',
'blue-1-050': '#EBF1F9',
'blue-1-100': '#D6E4F4',
'blue-1-150': '#C1D7F0',
'blue-1-200': '#AACAEF',
'blue-1-250': '#8FBCEF',
'blue-1-300': '#7CAFEB',
'blue-1-350': '#68A1E4',
'blue-1-400': '#5B94D6',
'blue-1-450': '#4E86C7',
'blue-1-500': '#4279B9',
'blue-1-550': '#356CAC',
'blue-1-600': '#28609E',
'blue-1-650': '#1B5390',
'blue-1-700': '#0B4783',
'blue-1-750': '#0F3C6E',
'blue-1-800': '#133059',
'blue-1-850': '#152641',
'blue-1-900': '#121C2D',
'blue-1-950': '#0B111C',
'blue-2-050': '#E7F3F4',
'blue-2-100': '#CEE7E9',
'blue-2-150': '#B2DCE0',
'blue-2-200': '#91D1D7',
'blue-2-250': '#68C7D0',
'blue-2-300': '#43BBC5',
'blue-2-350': '#00AFBA',
'blue-2-400': '#01A0AA',
'blue-2-450': '#00929D',
'blue-2-500': '#00848F',
'blue-2-550': '#007682',
'blue-2-600': '#016874',
'blue-2-650': '#005B67',
'blue-2-700': '#004E5A',
'blue-2-750': '#00424E',
'blue-2-800': '#003642',
'blue-2-850': '#002A38',
'blue-2-900': '#061E28',
'blue-2-950': '#071219',
'purple-050': '#F7F0F6',
'purple-100': '#EEE0EE',
'purple-150': '#E7D1E7',
'purple-200': '#DBBFE4',
'purple-250': '#D3AEE2',
'purple-300': '#CB99E1',
'purple-350': '#C188D9',
'purple-400': '#B47BCB',
'purple-450': '#A66EBD',
'purple-500': '#9961AF',
'purple-550': '#8B55A1',
'purple-600': '#7E4894',
'purple-650': '#723C87',
'purple-700': '#633376',
'purple-750': '#552A65',
'purple-800': '#452551',
'purple-850': '#35213D',
'purple-900': '#261A2C',
'purple-950': '#17111C',
'pink-050': '#F8EFF4',
'pink-100': '#F0DFEA',
'pink-150': '#EACEDF',
'pink-200': '#E9BBD1',
'pink-250': '#E9A7C2',
'pink-300': '#E095B4',
'pink-350': '#D685A8',
'pink-400': '#C7799B',
'pink-450': '#B86C8D',
'pink-500': '#AA5F80',
'pink-550': '#9C5374',
'pink-600': '#8E4767',
'pink-650': '#813B5B',
'pink-700': '#732E4F',
'pink-750': '#632643',
'pink-800': '#521F38',
'pink-850': '#3E1C2B',
'pink-900': '#2D171F',
'pink-950': '#1C0E12',
'black-000': '#1B1B2300',
'black-050': '#1B1B230D',
'black-100': '#1B1B231A',
'black-150': '#1B1B2326',
'black-200': '#1B1B2333',
'black-250': '#1B1B2340',
'black-300': '#1B1B234D',
'black-350': '#1B1B2359',
'black-400': '#1B1B2366',
'black-450': '#1B1B2373',
'black-500': '#1B1B2380',
'black-550': '#1B1B238C',
'black-600': '#1B1B2399',
'black-650': '#1B1B23A6',
'black-700': '#1B1B23B2',
'black-750': '#1B1B23BF',
'black-800': '#1B1B23CC',
'black-850': '#1B1B23D9',
'black-900': '#1B1B23E5',
'black-950': '#111114F2',
'white-000': '#F8F8F900',
'white-050': '#F8F8F90D',
'white-100': '#F8F8F91A',
'white-150': '#F8F8F926',
'white-200': '#F8F8F933',
'white-250': '#F8F8F940',
'white-300': '#F8F8F94D',
'white-350': '#F8F8F959',
'white-400': '#F8F8F966',
'white-450': '#F8F8F973',
'white-500': '#F8F8F980',
'white-550': '#F8F8F98C',
'white-600': '#F8F8F999',
'white-650': '#F8F8F9A6',
'white-700': '#F8F8F9B2',
'white-750': '#F8F8F9BF',
'white-800': '#F8F8F9CC',
'white-850': '#F8F8F9D9',
'white-900': '#F8F8F9E5',
'white-950': '#F8F8F9F2',
'white-975': '#F8F8F9F9',
},
...commonGlobals,
font: {
...commonGlobals.font,
families: {
base: 'Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif',
accent: 'Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif',
},
},
};
export const dsfrGlobals = {
...commonGlobals,
components: {
...commonGlobals.components,
'la-gaufre': true,
'home-proconnect': true,
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
},
colors: {
'logo-1': '#2845C1',
'logo-2': '#C83F49',
'brand-050': '#EDF0FF',
'brand-100': '#DAE2FF',
'brand-150': '#C8D3FF',
'brand-200': '#B5C4FF',
'brand-250': '#A2B6FF',
'brand-300': '#90A7FF',
'brand-350': '#7E98FF',
'brand-400': '#6C89FE',
'brand-450': '#5C7AF7',
'brand-500': '#4C6CEF',
'brand-550': '#3E5DE7',
'brand-600': '#304DDF',
'brand-650': '#2845C1',
'brand-700': '#223E9E',
'brand-750': '#1F367D',
'brand-800': '#1B2E5F',
'brand-850': '#172446',
'brand-900': '#121B30',
'brand-950': '#0C111A',
'gray-100': '#DFE2EA',
'gray-150': '#CFD5DE',
'gray-200': '#C1C7D3',
'gray-250': '#B2B9C7',
'gray-300': '#A4ABBC',
'gray-350': '#969EB0',
'gray-400': '#8891A4',
'gray-450': '#7B8498',
'gray-500': '#6D778C',
'gray-550': '#626A80',
'gray-600': '#555E74',
'gray-650': '#4A5267',
'gray-700': '#3F4759',
'gray-750': '#363B4C',
'gray-800': '#2B303D',
'gray-850': '#222631',
'gray-900': '#181B24',
'gray-950': '#0F1117',
'gray-1000': '#000000',
'info-050': '#E7F2FF',
'info-100': '#CFE5FF',
'info-150': '#B7D7FF',
'info-200': '#A0CAFE',
'info-250': '#8CBCF9',
'info-300': '#77AEF4',
'info-350': '#63A0EE',
'info-400': '#5092E7',
'info-450': '#4185DC',
'info-500': '#3677CC',
'info-550': '#2F6ABB',
'info-600': '#265EAA',
'info-650': '#28528F',
'info-700': '#274775',
'info-750': '#243C5E',
'info-800': '#20314A',
'info-850': '#1B2637',
'info-900': '#141C27',
'info-950': '#0D1118',
'success-050': '#DEF7E6',
'success-100': '#BAEECF',
'success-150': '#A5E2C0',
'success-200': '#95D4B3',
'success-250': '#85C6A7',
'success-300': '#74B99B',
'success-350': '#65AB8F',
'success-400': '#579E84',
'success-450': '#4B9079',
'success-500': '#40836F',
'success-550': '#367664',
'success-600': '#2B695A',
'success-650': '#2C5A50',
'success-700': '#2A4D45',
'success-750': '#26403C',
'success-800': '#213430',
'success-850': '#1B2826',
'success-900': '#151D1C',
'success-950': '#0D1212',
'warning-050': '#FFEEDF',
'warning-100': '#FFDCBE',
'warning-150': '#FFCA9C',
'warning-200': '#FFB778',
'warning-250': '#FDA54F',
'warning-300': '#F59425',
'warning-350': '#E78613',
'warning-400': '#D7790C',
'warning-450': '#C86C08',
'warning-500': '#B85F03',
'warning-550': '#A75400',
'warning-600': '#984800',
'warning-650': '#814112',
'warning-700': '#6C3A19',
'warning-750': '#58321C',
'warning-800': '#452A1A',
'warning-850': '#352117',
'warning-900': '#261813',
'warning-950': '#170F0C',
'error-050': '#FFEDEB',
'error-100': '#FFDAD7',
'error-150': '#FFC7C2',
'error-200': '#FFB3AD',
'error-250': '#FF9F99',
'error-300': '#FF8984',
'error-350': '#FF706E',
'error-400': '#FB5759',
'error-450': '#F63A45',
'error-500': '#E32C39',
'error-550': '#CF202D',
'error-600': '#BD0F23',
'error-650': '#9D2227',
'error-700': '#812727',
'error-750': '#672624',
'error-800': '#512220',
'error-850': '#3D1C1B',
'error-900': '#2A1614',
'error-950': '#190E0D',
'red-050': '#FFEDEB',
'red-100': '#FFDAD7',
'red-150': '#FFC7C2',
'red-200': '#FFB3AD',
'red-250': '#FF9F99',
'red-300': '#FF8984',
'red-350': '#FF706E',
'red-400': '#FB5759',
'red-450': '#F63A45',
'red-500': '#E32C39',
'red-550': '#CF202D',
'red-600': '#BD0F23',
'red-650': '#9D2227',
'red-700': '#812727',
'red-750': '#672624',
'red-800': '#512220',
'red-850': '#3D1C1B',
'red-900': '#410003',
'red-950': '#190E0D',
'orange-050': '#FCEDEB',
'orange-100': '#F8DCD7',
'orange-150': '#F1CCC5',
'orange-200': '#EABCB4',
'orange-250': '#E2ACA2',
'orange-300': '#DA9C92',
'orange-350': '#D28C81',
'orange-400': '#CA7C70',
'orange-450': '#BE6E62',
'orange-500': '#AE6257',
'orange-550': '#9E564D',
'orange-600': '#8F4B42',
'orange-650': '#79443D',
'orange-700': '#643C37',
'orange-750': '#513430',
'orange-800': '#412B28',
'orange-850': '#312220',
'orange-900': '#231918',
'orange-950': '#150F0F',
'brown-050': '#F9EFEA',
'brown-100': '#F3DFD3',
'brown-150': '#EACFC1',
'brown-200': '#E2BFAE',
'brown-250': '#D8B19C',
'brown-300': '#D0A189',
'brown-350': '#C3937B',
'brown-400': '#B5866D',
'brown-450': '#A77A62',
'brown-500': '#996D57',
'brown-550': '#8B614D',
'brown-600': '#7C5542',
'brown-650': '#6A4C3C',
'brown-700': '#594236',
'brown-750': '#49382F',
'brown-800': '#3B2E28',
'brown-850': '#2D2420',
'brown-900': '#201A18',
'brown-950': '#13100F',
'yellow-050': '#FDF1C5',
'yellow-100': '#FBE18E',
'yellow-150': '#F4D261',
'yellow-200': '#EAC244',
'yellow-250': '#DFB41B',
'yellow-300': '#D1A516',
'yellow-350': '#C49711',
'yellow-400': '#B78A0C',
'yellow-450': '#A87D07',
'yellow-500': '#9B6F02',
'yellow-550': '#8D6300',
'yellow-600': '#7F5600',
'yellow-650': '#6E4C11',
'yellow-700': '#5D4219',
'yellow-750': '#4D371B',
'yellow-800': '#3D2E1A',
'yellow-850': '#2F2417',
'yellow-900': '#221A12',
'yellow-950': '#14100C',
'green-050': '#E7F9B3',
'green-100': '#D5EC98',
'green-150': '#C5DE86',
'green-200': '#B5D174',
'green-250': '#A5C464',
'green-300': '#95B755',
'green-350': '#85AA45',
'green-400': '#769D39',
'green-450': '#688F30',
'green-500': '#5A8228',
'green-550': '#4D7621',
'green-600': '#416919',
'green-650': '#3A5B20',
'green-700': '#324E22',
'green-750': '#2C4122',
'green-800': '#24351D',
'green-850': '#1D2919',
'green-900': '#161E13',
'green-950': '#0E120C',
'blue-1-050': '#E7F2FF',
'blue-1-100': '#CFE5FF',
'blue-1-150': '#B7D7FF',
'blue-1-200': '#A0CAFE',
'blue-1-250': '#8CBCF9',
'blue-1-300': '#77AEF4',
'blue-1-350': '#63A0EE',
'blue-1-400': '#5092E7',
'blue-1-450': '#4185DC',
'blue-1-500': '#3677CC',
'blue-1-550': '#2F6ABB',
'blue-1-600': '#265EAA',
'blue-1-650': '#28528F',
'blue-1-700': '#274775',
'blue-1-750': '#243C5E',
'blue-1-800': '#20314A',
'blue-1-850': '#1B2637',
'blue-1-900': '#141C27',
'blue-1-950': '#0D1118',
'blue-2-050': '#E2F4FD',
'blue-2-100': '#C4E8F8',
'blue-2-150': '#AADCF2',
'blue-2-200': '#93CFEB',
'blue-2-250': '#7CC2E2',
'blue-2-300': '#6CB4D6',
'blue-2-350': '#5CA7C9',
'blue-2-400': '#5099BC',
'blue-2-450': '#458BAE',
'blue-2-500': '#3A7EA0',
'blue-2-550': '#327191',
'blue-2-600': '#286483',
'blue-2-650': '#2B5770',
'blue-2-700': '#294A5E',
'blue-2-750': '#263E4D',
'blue-2-800': '#22323D',
'blue-2-850': '#1C272E',
'blue-2-900': '#151D21',
'blue-2-950': '#0E1114',
'purple-050': '#F5EEFF',
'purple-100': '#ECDCFF',
'purple-150': '#E2CBFF',
'purple-200': '#D9B9FF',
'purple-250': '#D0A7FF',
'purple-300': '#C894FE',
'purple-350': '#BE83FA',
'purple-400': '#B570F5',
'purple-450': '#AB5EF0',
'purple-500': '#A04BE8',
'purple-550': '#933CDB',
'purple-600': '#8530C8',
'purple-650': '#7033A5',
'purple-700': '#5D3185',
'purple-750': '#4C2C6A',
'purple-800': '#3C2652',
'purple-850': '#2D203C',
'purple-900': '#21182A',
'purple-950': '#130F19',
'pink-050': '#FFEBF6',
'pink-100': '#FFD8ED',
'pink-150': '#FCC4E3',
'pink-200': '#F7B2D9',
'pink-250': '#F29FCE',
'pink-300': '#ED8CC3',
'pink-350': '#E779B8',
'pink-400': '#E264AD',
'pink-450': '#D2579E',
'pink-500': '#C24B8E',
'pink-550': '#B0417F',
'pink-600': '#9F3670',
'pink-650': '#873560',
'pink-700': '#6F3250',
'pink-750': '#5A2C43',
'pink-800': '#472635',
'pink-850': '#351F29',
'pink-900': '#26171D',
'pink-950': '#170F12',
'black-000': '#181B2400',
'black-050': '#181B240D',
'black-100': '#181B241A',
'black-150': '#181B2426',
'black-200': '#181B2433',
'black-250': '#181B2440',
'black-300': '#181B244D',
'black-350': '#181B2459',
'black-400': '#181B2466',
'black-450': '#181B2473',
'black-500': '#181B2480',
'black-550': '#181B248C',
'black-600': '#181B2499',
'black-650': '#181B24A6',
'black-700': '#181B24B2',
'black-750': '#181B24BF',
'black-800': '#181B24CC',
'black-850': '#181B24D9',
'black-900': '#181B24E5',
'black-950': '#0F1117F2',
'white-000': '#F6F8F900',
'white-050': '#F6F8F90D',
'white-100': '#F6F8F91A',
'white-150': '#F6F8F926',
'white-200': '#F6F8F933',
'white-250': '#F6F8F940',
'white-300': '#F6F8F94D',
'white-350': '#F6F8F959',
'white-400': '#F6F8F966',
'white-450': '#F6F8F973',
'white-500': '#F6F8F980',
'white-550': '#F6F8F98C',
'white-600': '#F6F8F999',
'white-650': '#F6F8F9A6',
'white-700': '#F6F8F9B2',
'white-750': '#F6F8F9BF',
'white-800': '#F6F8F9CC',
'white-850': '#F6F8F9D9',
'white-900': '#F6F8F9E5',
'white-950': '#F6F8F9F2',
'white-975': '#F6F8F9F9',
},
};
const whiteLabelThemes = getThemesFromGlobals(whiteLabelGlobals, {
overrides: commonTokenOverrides,
});
const dsfrThemes = getThemesFromGlobals(dsfrGlobals, {
overrides: commonTokenOverrides,
});
if (dsfrThemes.dark) {
dsfrThemes.dark.globals.components.logo.src =
'/assets/logo-gouv-darkmode.svg';
dsfrThemes.dark.globals.colors['logo-1'] = '#95ABFF';
dsfrThemes.dark.globals.colors['logo-2'] = '#E78087';
}
console.log(dsfrThemes);
const themes = {
themes: {
default: whiteLabelThemes.light,
dark: whiteLabelThemes.dark,
dsfr: dsfrThemes.light,
'dsfr-dark': dsfrThemes.dark,
},
};
const config = { ...themes };
export default config;
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+1
View File
@@ -41,6 +41,7 @@ const nextConfig = {
return config;
},
turbopack: {},
};
module.exports = nextConfig;

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