Compare commits

...

36 Commits

Author SHA1 Message Date
Sabrina Demagny 8f30264445 🔖(minor) release version 1.5.0
Update all version files and changelog for minor release.
2024-11-14 15:42:54 +01:00
Laurent Bossavit bde91d55da (ci) separate security scan for frontend too
Separate security scan from build-and-push, so we can make it optional
in CI; this was the case for the backend but frontend was overlooked…
2024-11-13 15:02:50 +01:00
renovate[bot] a328e16e53 ⬆️(dependencies) update js dependencies 2024-11-12 14:48:33 +01:00
Marie PUPO JEAMMET edde9c8d15 (dimail) synchronize mailboxes from dimail to our db
Synchronize mailboxes existing on dimail's api and not on our side,
on domains we are administrating.
2024-11-08 16:40:06 +01:00
Sabrina Demagny a18f06ed27 🐛(mail) fix button display on outlook
In confirmation email of mailbox creation,
button "Go to La Messagerie" disappears on Outlook.
We try to fix here this display bug.
2024-11-08 16:32:18 +01:00
Quentin BEY 72abe04c72 🗃️(teams) remove slug field
After some reflexion, the use of a slug field raises to many
problems without being really needed.

One problem is the slug is made from the group name, but we
don't have unicity on this, so a user might be blocked without
any clue.

We also want to allow group names to be reused (which is already
allowed except for the automatic slug).

The unique ID that will be shared with Service Providers will be
the PK/UUID.
2024-11-06 18:10:02 +01:00
Jacques ROUSSEL 79e92214ab 🔐(secret) add qbey age key
Welcome qbey, allow to run tilt locally
2024-11-06 14:45:08 +01:00
Quentin BEY e5f1151f58 🔧(helm) update settings after previous commit
This adds `siret`to the requested OIDC scopes.
This defines a validator for the Organization
registration ID, to enforce SIRET format.
2024-11-06 14:45:08 +01:00
Quentin BEY ca886c19b0 👔(backend) add Organization model
We introduce the Organization model has a "hat" for all
users and team.

Each User must have a "default" organization.
Each Team must have an organization.

When a User creates a new Team, the team is linked to their
default Organization.

For now the Organization should not be visible to end users
this is a purely technical aspect as it.

The models are also adding a permission to allow User to edit
an Organization, but for now there are no endpoints for that.

Next steps:
- Add an Organization to each User and Team on all environments
  to mark Organization as mandatory in database.
- Add scope to Organization to list the Service Provider list
  allowed for a User in an Organization.
- Add endpoints + frontend to manage Organization's scopes
2024-11-06 14:45:08 +01:00
Laurent Bossavit b602478406 ⬆️(dependencies) remove unneeded dependencies
Remove url-normalize as it is not referenced anywhere
2024-11-05 16:41:13 +01:00
Laurent Bossavit 8b0f942f9b ⬆️(dependencies) update js dependencies
Get react-aria-components only from Cunningham to avoid version clashes
2024-11-05 16:12:42 +01:00
renovate[bot] bd6fe584c6 ⬆️(dependencies) update js dependencies 2024-11-05 16:12:42 +01:00
Laurent Bossavit 821db276bc (ci) add security scan
Separate security scan from build-and-push, so we can make it optional in CI
2024-11-05 15:21:02 +01:00
Jacques ROUSSEL 639490d41e 🚑️(backend) fixe CVEs in backend image
Use alpine version for production image instead of debian in order
to have less CVEs.
2024-11-05 15:21:02 +01:00
Jacques ROUSSEL 748e24cab6 🚑️(frontend) fixe CVEs in frontend image
Use alpine version for production image instead of debian in order
tohave less CVEs.
2024-11-05 15:21:02 +01:00
Jacques ROUSSEL 55c0815c31 (ci) add security scan
Add a security scan for CVE with trivy
2024-11-05 15:21:02 +01:00
Marie PUPO JEAMMET 989239082e (tests) test dimail setup command
Test (and slightly improve) dimail setup command,
which populate database on local dimail container
2024-11-05 11:22:02 +01:00
renovate[bot] 988a091e53 ⬆️(dependencies) update python dependencies 2024-11-04 10:13:48 +01:00
Nathan Panchout 20a19d36b5 ♻️(frontend) delete infinite scroll
In order to be in agreement with the back, we must modify the front
so as to no longer have infinite scroll in the team part.
It is also necessary to modify the tests accordingly
2024-10-31 17:59:14 +01:00
Sabrina Demagny 7d695ab81c 🔥(teams) remove pagination of teams listing
For frontend pagination is useless for teams,
so we remove it.
2024-10-31 17:59:14 +01:00
Nathan Panchout a42e7a10db (frontend) adapt test with new list
After the modifications to the back, some adjustments are expected
on the front side to remove the random aspect of the user search
2024-10-30 19:32:46 +01:00
Sabrina Demagny ad4065e682 🚧(demo) modify data set to fix e2e tests
This part needs more refactoring.
Demo data set needs to be no totally random.
2024-10-30 19:32:46 +01:00
Sabrina Demagny ababcde0d6 🔥(teams) remove search users by trigram
This feature is not necessary for our users now
and we got some strange results so we decided
to remove this feature.
2024-10-30 19:32:46 +01:00
Sabrina Demagny faf8dcc7e5 (teams) register contacts on admin views
Allow to manage contact on admin interface
2024-10-30 15:33:29 +01:00
renovate[bot] 2bbed8de0c ⬆️(dependencies) update python dependencies 2024-10-30 11:31:12 +01:00
Laurent Bossavit a736a9143f 💚(ci) use list reporter in addition to html
This will output lines to the testing jobs' logs so we know how many
tests are run even when jobs are eventually cancelled.
2024-10-30 09:35:49 +01:00
Laurent Bossavit c4ea62dc1f 💚(ci) improve E2E tests
Disable retries and save trace for failed tests.

💚(ci) preserve server logs

Save server logs to the same place as Playwright reports to aid debugging.

💚(ci) move back to 1 worker on CI

At least three reasons
- seems redundant with sharding
- strong suspicion it's the reason for the ValidationError issue
- that way the comment no longer tells a lie ;)

💚(ci) improve E2E tests

Log into CHANGELOG to ensure the new test results impact PR status 🤷

💚(ci) make dummy data creation more robust

This is a QR (Quick Response) fix for the failures in the "Add dummy
data" step in E2E testing. Proper QC (Quality Control) needs a bit
more thought.
2024-10-29 14:58:25 +01:00
Laurent Bossavit 1d1f5cfbb6 🚨(linter) add missing docstrings
Title says all there is to say…
2024-10-29 09:08:35 +01:00
Laurent Bossavit 3934a0bc28 🚨(linter) configure exclusions
See PR for rationale.
2024-10-29 09:08:35 +01:00
Laurent Bossavit fd3ac00ea7 🚨(linter) add D1 pydocstyle rule
See PR for rationale.
2024-10-29 09:08:35 +01:00
Quentin BEY 2d46b7d504 🔧(backend) fix sentry deprecated scope
`sentry_sdk.configure_scope` is deprecated and will
be removed in the next major version.
2024-10-25 15:15:39 +02:00
Quentin BEY 54e5be81e2 🔇(backend) remove Sentry duplicated warning/errors
The `DockerflowMiddleware` job is to add logs, sadly the
Sentry LoggingIntegration also catch these logs (at
level WARNING and above) which results in an extra alert
for the same purpose (the exception + the logger.error).

see https://github.com/mozilla-services/Dockerflow/blob/main/docs/mozlog.md

The fix is to ask Sentry to ignore this specific loggger
(`request.summary`).
2024-10-25 15:15:39 +02:00
Marie PUPO JEAMMET 31944e8083 🔧(backend) restore Sentry default integrations
This reverts 30229e11f9
2024-10-25 15:15:39 +02:00
Sabrina Demagny d1421dbe8c ✏️(scripts) add some details for manual steps
Some instructions are missing to explain manual steps
2024-10-24 17:27:07 +02:00
Marie PUPO JEAMMET c8800259ae 🐛(changelog) move e2e sharding to unreleased
e2e sharding was merged without updating it to unreleased. Fixes that
2024-10-24 17:11:29 +02:00
NathanPanchout bfc2462103 👷(ci) add sharding e2e tests
e2e tests take too long to run. We can easily reduce this time by 2 by adding
shards
2024-10-24 16:35:07 +02:00
86 changed files with 11231 additions and 10347 deletions
+41 -2
View File
@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -15,6 +16,44 @@ env:
DOCKER_USER: 1001:127
jobs:
trivy-scan:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
lasuite/people-backend
lasuite/people-frontend
-
name: Run trivy scan (backend)
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/people-backend:${{ github.sha }}'
-
name: Run trivy scan (frontend)
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target frontend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
build-and-push-backend:
runs-on: ubuntu-latest
steps:
@@ -50,7 +89,7 @@ jobs:
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
@@ -94,7 +133,7 @@ jobs:
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: frontend-production
+28 -7
View File
@@ -16,7 +16,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
@@ -39,7 +39,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
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
@@ -139,7 +139,7 @@ jobs:
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Check linting
run: cd src/frontend/ && yarn lint
@@ -147,6 +147,11 @@ jobs:
runs-on: ubuntu-latest
needs: [build-mails, build-front]
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -196,15 +201,31 @@ jobs:
run: cd src/frontend/apps/e2e && yarn install
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Save logs
if: always()
run: docker compose logs > src/frontend/apps/e2e/report/logs.txt
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
name: playwright-report-${{ matrix.shardIndex }}
path: src/frontend/apps/e2e/report/
retention-days: 7
tests-e2e-feedback:
runs-on: ubuntu-latest
needs: [test-e2e]
if: always()
steps:
- name: All tests ok
if: ${{ !(contains(needs.*.result, 'failure')) }}
run: exit 0
- name: Some tests failed
if: ${{ contains(needs.*.result, 'failure') }}
run: exit 1
build-mails:
runs-on: ubuntu-latest
defaults:
@@ -226,8 +247,8 @@ jobs:
- name: Persist mails' templates
uses: actions/upload-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
name: mails-templates
path: src/backend/core/templates/mail
lint-back:
runs-on: ubuntu-latest
+1
View File
@@ -8,3 +8,4 @@ creation_rules:
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
+29 -1
View File
@@ -8,6 +8,32 @@ and this project adheres to
## [Unreleased]
## [1.5.0] - 2024-11-14
### Removed
- ⬆️(dependencies) remove unneeded dependencies
- 🔥(teams) remove pagination of teams listing
- 🔥(teams) remove search users by trigram
### Added
- ✨(dimail) synchronize mailboxes from dimail to our db #453
- ✨(ci) add security scan #429
- ✨(teams) register contacts on admin views
### Fixed
- 🐛(mail) fix display button on outlook
- 💚(ci) improve E2E tests #492
- 🔧(sentry) restore default integrations
- 🔇(backend) remove Sentry duplicated warning/errors
- 👷(ci) add sharding e2e tests #467
### Removed
- 🗃️(teams) remove `slug` field
## [1.4.1] - 2024-10-23
### Fixed
@@ -35,6 +61,7 @@ and this project adheres to
- ✨(api) add RELEASE version on config endpoint #459
- ✨(backend) manage roles on domain admin view
- ✨(frontend) show version number in footer #369
- 👔(backend) add Organization model
### Changed
@@ -124,7 +151,8 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.4.1...main
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.5.0...main
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
[1.4.1]: https://github.com/numerique-gouv/people/releases/v1.4.1
[1.4.0]: https://github.com/numerique-gouv/people/releases/v1.4.0
[1.3.1]: https://github.com/numerique-gouv/people/releases/v1.3.1
+16 -24
View File
@@ -1,15 +1,14 @@
# Django People
# ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base
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
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
RUN apk update && \
apk upgrade
### ---- Front-end dependencies image ----
FROM node:20 as frontend-deps
@@ -40,7 +39,7 @@ FROM frontend-builder-dev as frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
@@ -88,11 +87,9 @@ FROM base as link-collector
ARG PEOPLE_STATIC_ROOT=/data/static
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -116,16 +113,13 @@ FROM base as core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -155,9 +149,7 @@ FROM core as backend-development
USER root:root
# Install psql
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
RUN apk add postgresql-client
# Uninstall people and re-install it in editable mode along with development
# dependencies
+4 -5
View File
@@ -131,11 +131,6 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
reset-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
@$(MAKE) setup-dimail-db
.PHONY: reset-dimail-container
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
@@ -283,6 +278,10 @@ i18n-generate-and-upload: \
# -- INTEROPERABILTY
# -- Dimail configuration
recreate-dimail-container:
@$(COMPOSE) up --force-recreate -d dimail
.PHONY: recreate-dimail-container
dimail-setup-db:
@echo "$(BOLD)Populating database of local dimail API container$(RESET)"
@$(MANAGE) setup_dimail_db
+7 -2
View File
@@ -1,3 +1,5 @@
# pylint: disable=line-too-long
import datetime
import os
import re
@@ -95,10 +97,13 @@ def prepare_release(version, kind):
run_command(f"git push origin {branch_to_release}", shell=True)
sys.stdout.write(f"""
PLEASE DO THE FOLLOWING INSTRUCTIONS:
--> Please submit PR and merge code to main than tag version
--> Please submit PR and merge code to main
--> Then do:
>> git checkout main
>> git pull
>> git tag -a v{version}
>> git push origin v{version}
--> Please check docker image: https://hub.docker.com/r/lasuite/people-backend/tags
--> Please check and wait for the docker image v{version} to be published here: https://hub.docker.com/r/lasuite/people-backend/tags
>> git tag -d preprod
>> git tag preprod
>> git push -f origin preprod
+2 -1
View File
@@ -363,7 +363,8 @@ name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# Ignore: private stuff and `Params` class from FactoryBoy
no-docstring-rgx=(^_|^Params$)
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
+1
View File
@@ -0,0 +1 @@
"""Root module."""
+1
View File
@@ -0,0 +1 @@
"""Core root module."""
+52 -3
View File
@@ -18,6 +18,15 @@ class TeamAccessInline(admin.TabularInline):
readonly_fields = ("created_at", "updated_at")
class OrganizationAccessInline(admin.TabularInline):
"""Inline admin class for organization accesses."""
autocomplete_fields = ["user", "organization"]
extra = 0
model = models.OrganizationAccess
readonly_fields = ("created_at", "updated_at")
class TeamWebhookInline(admin.TabularInline):
"""Inline admin class for team webhooks."""
@@ -31,6 +40,7 @@ class TeamWebhookInline(admin.TabularInline):
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
autocomplete_fields = ["organization"]
fieldsets = (
(
None,
@@ -67,9 +77,10 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
)
inlines = (TeamAccessInline, MailDomainAccessInline)
inlines = (TeamAccessInline, MailDomainAccessInline, OrganizationAccessInline)
list_display = (
"get_user",
"organization",
"created_at",
"updated_at",
"is_active",
@@ -104,7 +115,6 @@ class TeamAdmin(admin.ModelAdmin):
inlines = (TeamAccessInline, TeamWebhookInline)
list_display = (
"name",
"slug",
"created_at",
"updated_at",
)
@@ -148,8 +158,8 @@ class InvitationAdmin(admin.ModelAdmin):
)
def get_readonly_fields(self, request, obj=None):
"""Mark all fields read only, i.e. disable update."""
if obj:
# all fields read only = disable update
return self.fields
return self.readonly_fields
@@ -162,5 +172,44 @@ class InvitationAdmin(admin.ModelAdmin):
return super().change_view(request, object_id, extra_context=extra_context)
def save_model(self, request, obj, form, change):
"""Fill in current logged-in user as issuer."""
obj.issuer = request.user
obj.save()
@admin.register(models.Contact)
class ContactAdmin(admin.ModelAdmin):
"""Contact admin interface declaration."""
list_display = (
"full_name",
"owner",
"base",
)
@admin.register(models.Organization)
class OrganizationAdmin(admin.ModelAdmin):
"""Admin interface for organizations."""
list_display = (
"name",
"created_at",
"updated_at",
)
search_fields = ("name",)
inlines = (OrganizationAccessInline,)
@admin.register(models.OrganizationAccess)
class OrganizationAccessAdmin(admin.ModelAdmin):
"""Organization access admin interface declaration."""
autocomplete_fields = ("user", "organization")
list_display = (
"user",
"organization",
"role",
"created_at",
"updated_at",
)
+2 -2
View File
@@ -12,13 +12,13 @@ class IsAuthenticated(permissions.BasePermission):
"""
def has_permission(self, request, view):
"""Check auth token first."""
return bool(request.auth) if request.auth else request.user.is_authenticated
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
of the auth token to avoid hitting the database.
Allows access only to user's own data.
"""
def has_object_permission(self, request, view, obj):
+10 -7
View File
@@ -34,6 +34,8 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
def __init__(self, *args, **kwargs):
"""Pass arguments to superclass except 'fields', then drop fields not listed therein."""
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
@@ -170,7 +172,6 @@ class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
abilities = serializers.SerializerMethodField(read_only=True)
slug = serializers.SerializerMethodField()
class Meta:
model = models.Team
@@ -179,7 +180,6 @@ class TeamSerializer(serializers.ModelSerializer):
"name",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
@@ -187,11 +187,18 @@ class TeamSerializer(serializers.ModelSerializer):
"id",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
def create(self, validated_data):
"""Create a new team with organization enforcement."""
# Note: this is not the purpose of this API to check the user has an organization
return super().create(
validated_data=validated_data
| {"organization_id": self.context["request"].user.organization_id}
)
def get_abilities(self, team) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
@@ -199,10 +206,6 @@ class TeamSerializer(serializers.ModelSerializer):
return team.get_abilities(request.user)
return {}
def get_slug(self, instance):
"""Return slug from the team's name."""
return instance.get_slug()
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
+7 -18
View File
@@ -182,9 +182,7 @@ class UserViewSet(
User viewset for all interactions with user infos and teams.
GET /api/users/&q=query
Return a list of users whose email matches the query. Similarity is
calculated using trigram similarity, allowing for partial,
case-insensitive matches and accented queries.
Return a list of users whose email or name matches the query.
"""
permission_classes = [permissions.IsSelf]
@@ -207,22 +205,11 @@ class UserViewSet(
if team_id := self.request.GET.get("team_id", ""):
queryset = queryset.exclude(teams__id=team_id)
# Search by case-insensitive and accent-insensitive trigram similarity
# Search by case-insensitive and accent-insensitive
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Coalesce(Func("email", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(Func("name", function="unaccent"), Value("")),
Func(Value(query), function="unaccent"),
)
)
queryset = (
queryset.annotate(similarity=similarity)
.filter(similarity__gte=SIMILARITY_THRESHOLD)
.order_by("-similarity")
queryset = queryset.filter(
Q(name__unaccent__icontains=query)
| Q(email__unaccent__icontains=query)
)
return queryset
@@ -259,6 +246,7 @@ class TeamViewSet(
ordering_fields = ["created_at"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
pagination_class = None
def get_queryset(self):
"""Custom queryset to get user related teams."""
@@ -340,6 +328,7 @@ class TeamAccessViewSet(
return context
def get_serializer_class(self):
"""Chooses list or detail serializer according to the action."""
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
@@ -0,0 +1 @@
"""Authentication module."""
+104 -11
View File
@@ -1,5 +1,9 @@
"""Authentication Backends for the People core app."""
import logging
from email.headerregistry import Address
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
@@ -10,9 +14,21 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import Organization, OrganizationAccess, OrganizationRoleChoices
logger = logging.getLogger(__name__)
User = get_user_model()
def get_domain_from_email(email: Optional[str]) -> Optional[str]:
"""Extract domain from email."""
try:
return Address(addr_spec=email).domain
except (ValueError, AttributeError):
return None
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -67,19 +83,24 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
email = user_info.get("email")
claims = {
"sub": sub,
"email": email,
"name": full_name,
}
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
if settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD:
claims[settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD] = user_info.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
)
# if sub is absent, try matching on email
@@ -90,7 +111,41 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
user = self.create_user(claims)
# Data cleaning, to be removed when user organization is null=False
# or all users have an organization.
# See https://github.com/numerique-gouv/people/issues/504
if not user.organization_id:
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
)
domain = get_domain_from_email(email)
try:
organization, organization_created = (
Organization.objects.get_or_create_from_user_claims(
registration_id=organization_registration_id,
domain=domain,
)
)
if organization_created:
logger.info("Organization %s created", organization)
# For this case, we don't create an OrganizationAccess we will
# manage this manually later, because we don't want the first
# user who log in after the release to be the admin of their
# organization. We will keep organization without admin, and
# we will have to manually clean things up (while there is
# not that much organization in the database).
except ValueError as exc:
# Raised when there is no recognizable organization
# identifier (domain or registration_id)
logger.warning("Unable to update user organization: %s", exc)
else:
user.organization = organization
user.save()
logger.info(
"User %s updated with organization %s", user.pk, organization
)
return user
@@ -101,13 +156,47 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
email = claims.get("email")
name = claims.get("name")
return self.UserModel.objects.create(
# Extract or create the organization from the data
organization_registration_id = claims.get(
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD
)
domain = get_domain_from_email(email)
try:
organization, organization_created = (
Organization.objects.get_or_create_from_user_claims(
registration_id=organization_registration_id,
domain=domain,
)
)
except ValueError as exc:
raise SuspiciousOperation(
_("Claims contained no recognizable organization identification")
) from exc
if organization_created:
logger.info("Organization %s created", organization)
logger.info("Creating user %s / %s", sub, email)
user = self.UserModel.objects.create(
organization=organization,
password="!", # noqa: S106
sub=sub,
email=claims.get("email"),
name=claims.get("name"),
email=email,
name=name,
)
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,
)
return user
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
@@ -132,8 +221,12 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
value and value != getattr(user, key)
for key, value in claims.items()
if key != "sub"
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
updated_claims = {
key: value for key, value in claims.items() if value and key != "sub"
}
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
+26
View File
@@ -119,6 +119,25 @@ class ContactFactory(BaseContactFactory):
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
class OrganizationFactory(factory.django.DjangoModelFactory):
"""Factory to create organizations for testing purposes."""
name = factory.Faker("company")
class Meta:
model = models.Organization
class Params: # pylint: disable=missing-class-docstring
with_registration_id = factory.Trait(
registration_id_list=factory.List(
[factory.Sequence(lambda n: f"{n:014d}")]
),
)
with_domain = factory.Trait(
domain_list=factory.List([factory.Faker("domain_name")]),
)
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to create random users for testing purposes."""
@@ -126,6 +145,13 @@ class UserFactory(factory.django.DjangoModelFactory):
model = models.User
django_get_or_create = ("sub",)
class Params:
with_organization = factory.Trait(
organization=factory.SubFactory(
OrganizationFactory, with_registration_id=True
),
)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
name = factory.Faker("name")
@@ -0,0 +1,62 @@
# Generated by Django 5.1.1 on 2024-10-22 10:07
import core.models
import django.contrib.postgres.fields
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Organization',
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')),
('name', models.CharField(max_length=100, verbose_name='name')),
('registration_id_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=128), blank=True, default=list, size=None, validators=[core.models.validate_unique_registration_id], verbose_name='registration ID list')),
('domain_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=256), blank=True, default=list, size=None, validators=[core.models.validate_unique_domain], verbose_name='domain list')),
],
options={
'verbose_name': 'organization',
'verbose_name_plural': 'organizations',
'db_table': 'people_organization',
'constraints': [models.CheckConstraint(condition=models.Q(('registration_id_list__len__gt', 0), ('domain_list__len__gt', 0), _connector='OR'), name='registration_id_or_domain', violation_error_message='An organization must have at least a registration ID or a domain.')],
},
),
migrations.AddField(
model_name='team',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='teams', to='core.organization'),
),
migrations.AddField(
model_name='user',
name='organization',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='core.organization'),
),
migrations.CreateModel(
name='OrganizationAccess',
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')),
('role', models.CharField(choices=[('administrator', 'Administrator')], default='administrator', max_length=20)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to='core.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_accesses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Organization/user relation',
'verbose_name_plural': 'Organization/user relations',
'db_table': 'people_organization_access',
'constraints': [models.UniqueConstraint(fields=('user', 'organization'), name='unique_organization_user', violation_error_message='This user is already in this organization.')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-11-04 14:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_add_organization_and_more'),
]
operations = [
migrations.AlterField(
model_name='team',
name='slug',
field=models.SlugField(max_length=100, null=True),
),
]
+202 -11
View File
@@ -6,19 +6,22 @@ import json
import os
import smtplib
import uuid
from contextlib import suppress
from datetime import timedelta
from logging import getLogger
from typing import Tuple
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
@@ -27,6 +30,7 @@ from timezone_field import TimeZoneField
from core.enums import WebhookStatusChoices
from core.utils.webhooks import scim_synchronizer
from core.validators import get_field_validators_from_setting
logger = getLogger(__name__)
@@ -44,6 +48,17 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
class OrganizationRoleChoices(models.TextChoices):
"""
Defines the possible roles a user can have in an organization.
For now, we only have one role, but we might add more in the future.
administrator: The user can manage the organization: change name, add/remove users.
"""
ADMIN = "administrator", _("Administrator")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -158,6 +173,140 @@ class Contact(BaseModel):
raise exceptions.ValidationError({"data": [error_message]}) from e
class OrganizationManager(models.Manager):
"""
Custom manager for the Organization model, to manage complexity/automation.
"""
def get_or_create_from_user_claims(
self, registration_id: str = None, domain: str = None, **kwargs
) -> Tuple["Organization", bool]:
"""
Get or create an organization using the most fitting information from the user's claims.
We expect to have only one organization per registration_id, but
the registration_id might not be provided.
When the registration_id is not provided, we use the domain to identify the organization.
If both are provided, we use the registration_id first to create missing organization.
Dev note: When a registration_id is provided by the Identity Provider, we don't want
to use the domain to create the organization, because it is less reliable: for example,
a professional user, may have a personal email address, and the domain would be gmail.com
which is not a good identifier for an organization. The domain email is just a fallback
when the registration_id is not provided by the Identity Provider. We can use the domain
to create the organization manually when we are sure about the "safety" of it.
"""
if not any([registration_id, domain]):
raise ValueError("You must provide either a registration_id or a domain.")
filters = models.Q()
if registration_id:
filters |= models.Q(registration_id_list__icontains=registration_id)
if domain:
filters |= models.Q(domain_list__icontains=domain)
with suppress(self.model.DoesNotExist):
# If there are several organizations, we must raise an error and fix the data
# If there is an organization, we return it
return self.get(filters, **kwargs), False
# Manage the case where the organization does not exist: we create one
if registration_id:
return self.create(
name=registration_id, registration_id_list=[registration_id], **kwargs
), True
if domain:
return self.create(name=domain, domain_list=[domain], **kwargs), True
raise ValueError("Should never reach this point.")
def validate_unique_registration_id(value):
"""
Validate that the registration ID values in an array field are unique across all instances.
"""
if Organization.objects.filter(registration_id_list__overlap=value).exists():
raise ValidationError(
"registration_id_list value must be unique across all instances."
)
def validate_unique_domain(value):
"""
Validate that the domain values in an array field are unique across all instances.
"""
if Organization.objects.filter(domain_list__overlap=value).exists():
raise ValidationError("domain_list value must be unique across all instances.")
class Organization(BaseModel):
"""
Organization model used to regroup Teams.
Each User have an Organization, which corresponds actually to a default organization
because a user can belong to a Team from another organization.
Each Team have an Organization, which is the Organization from the User who created
the Team.
Organization is managed automatically, the User should never choose their Organization.
When creating a User, you must use the `get_or_create` method from the
OrganizationManager to find the proper Organization.
An Organization can have several registration IDs and domains but during automatic
creation process, only one will be used. We may want to allow (manual) organization merge
later, to regroup several registration IDs or domain in the same Organization.
"""
name = models.CharField(_("name"), max_length=100)
registration_id_list = ArrayField(
models.CharField(
max_length=128,
validators=get_field_validators_from_setting(
"ORGANIZATION_REGISTRATION_ID_VALIDATORS"
),
),
verbose_name=_("registration ID list"),
default=list,
blank=True,
validators=[
validate_unique_registration_id,
],
)
domain_list = ArrayField(
models.CharField(max_length=256),
verbose_name=_("domain list"),
default=list,
blank=True,
validators=[validate_unique_domain],
)
objects = OrganizationManager()
class Meta:
db_table = "people_organization"
verbose_name = _("organization")
verbose_name_plural = _("organizations")
constraints = [
models.CheckConstraint(
name="registration_id_or_domain",
condition=models.Q(registration_id_list__len__gt=0)
| models.Q(domain_list__len__gt=0),
violation_error_message=_(
"An organization must have at least a registration ID or a domain."
),
),
# Check a registration ID str can only be present in one
# organization registration ID list
# Check a domain str can only be present in one organization domain list
# Those checks cannot be done with Django constraints
]
def __str__(self):
return f"{self.name} (# {self.pk})"
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -218,6 +367,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="users",
null=True, # Need to be set to False when everything is migrated
blank=True, # Need to be set to False when everything is migrated
)
objects = auth_models.UserManager()
@@ -285,13 +441,50 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
class OrganizationAccess(BaseModel):
"""
Link table between organization and users,
only for user with specific rights on Organization.
"""
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="organization_accesses",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="organization_accesses",
)
role = models.CharField(
max_length=20,
choices=OrganizationRoleChoices.choices,
default=OrganizationRoleChoices.ADMIN,
)
class Meta:
db_table = "people_organization_access"
verbose_name = _("Organization/user relation")
verbose_name_plural = _("Organization/user relations")
constraints = [
models.UniqueConstraint(
fields=["user", "organization"],
name="unique_organization_user",
violation_error_message=_("This user is already in this organization."),
),
]
def __str__(self):
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"
class Team(BaseModel):
"""
Represents the link between teams and users, specifying the role a user has in a team.
"""
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=100, unique=True, null=False, editable=False)
users = models.ManyToManyField(
User,
@@ -299,6 +492,13 @@ class Team(BaseModel):
through_fields=("team", "user"),
related_name="teams",
)
organization = models.ForeignKey(
Organization,
on_delete=models.PROTECT,
related_name="teams",
null=True, # Need to be set to False when everything is migrated
blank=True, # Need to be set to False when everything is migrated
)
class Meta:
db_table = "people_team"
@@ -309,15 +509,6 @@ class Team(BaseModel):
def __str__(self):
return self.name
def save(self, *args, **kwargs):
"""Override save function to compute the slug."""
self.slug = self.get_slug()
return super().save(*args, **kwargs)
def get_slug(self):
"""Compute slug value from name."""
return slugify(self.name)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the team.
@@ -0,0 +1 @@
"""Backend resource server module."""
@@ -19,6 +19,7 @@ class ResourceServerAuthentication(OIDCAuthentication):
"""Authenticate clients using the token received from the authorization server."""
def __init__(self):
"""Require authentication to be configured in order to instantiate."""
super().__init__()
try:
@@ -40,7 +41,7 @@ class ResourceServerAuthentication(OIDCAuthentication):
def get_access_token(self, request):
"""Retrieve and decode the access token from the request.
This method overcharges the 'get_access_token' method from the parent class,
This method overrides the 'get_access_token' method from the parent class,
to support service providers that would base64 encode the bearer token.
"""
@@ -33,6 +33,7 @@ class ResourceServerBackend:
# pylint: disable=too-many-instance-attributes
def __init__(self, authorization_server_client):
"""Require client_id, client_secret set and authorization_server_client provided."""
# pylint: disable=invalid-name
self.UserModel = auth.get_user_model()
+3 -1
View File
@@ -19,7 +19,7 @@ class AuthorizationServerClient:
- Setting appropriate headers for secure communication as recommended by RFC drafts.
"""
# ruff: noqa: PLR0913 PLR017
# ruff: noqa: PLR0913 PLR0917
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-arguments
def __init__(
@@ -31,6 +31,8 @@ class AuthorizationServerClient:
timeout,
proxy,
):
"""Require at a minimum url, url_jwks and url_introspection."""
if not url or not url_jwks or not url_introspection:
raise ImproperlyConfigured(
"Could not instantiate AuthorizationServerClient, some parameters are missing."
@@ -0,0 +1 @@
"""Core template tags module."""
+1
View File
@@ -0,0 +1 @@
"""Core tests."""
@@ -0,0 +1 @@
"""Core authentication tests."""
@@ -42,7 +42,8 @@ def test_authentication_getter_existing_user_with_email(
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = factories.UserFactory(name="John Doe")
user = factories.UserFactory(name="John Doe", with_organization=True)
def get_userinfo_mocked(*args):
return {
@@ -79,7 +80,9 @@ def test_authentication_getter_existing_user_change_fields(
It should update the email or name fields on the user when they change.
"""
klass = OIDCAuthenticationBackend()
user = factories.UserFactory(name="John Doe", email="john.doe@example.com")
user = factories.UserFactory(
name="John Doe", email="john.doe@example.com", with_organization=True
)
def get_userinfo_mocked(*args):
return {
@@ -112,7 +115,7 @@ def test_authentication_getter_existing_user_via_email(
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory()
db_user = factories.UserFactory(with_organization=True)
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
@@ -158,25 +161,23 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
User's info doesn't contain an email/name, created user's email/name should be empty.
If no user matches the user's info sub, a user should not be created without email
nor organization registration ID.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123"}
return {"sub": "123"} # No email, no organization registration ID
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email is None
assert user.name is None
assert user.password == "!"
assert models.User.objects.count() == 1
with (
pytest.raises(
SuspiciousOperation,
match="Claims contained no recognizable organization identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
def test_authentication_getter_new_user_with_email(monkeypatch):
@@ -284,3 +285,99 @@ def test_authentication_getter_existing_disabled_user_via_email(
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_with_email_new_organization(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
If the corresponding organization doesn't exist, it should be created.
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.organization is not None
assert user.organization.domain_list == ["example.com"]
assert user.organization.registration_id_list == []
@pytest.mark.parametrize(
"registration_id_setting,expected_registration_id_list,expected_domain_list",
[
(None, [], ["example.com"]),
("missing-claim", [], ["example.com"]),
("registration_number", ["12345678901234"], []),
],
)
def test_authentication_getter_new_user_with_registration_id_new_organization(
monkeypatch,
settings,
registration_id_setting,
expected_registration_id_list,
expected_domain_list,
):
"""
If no user matches the user's info sub, a user should be created.
If the corresponding organization doesn't exist, it should be created.
"""
settings.OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = registration_id_setting
klass = OIDCAuthenticationBackend()
email = "people@example.com"
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": email,
"first_name": "John",
"last_name": "Doe",
"registration_number": "12345678901234",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.organization is not None
assert user.organization.domain_list == expected_domain_list
assert user.organization.registration_id_list == expected_registration_id_list
def test_authentication_getter_existing_user_via_email_update_organization(
django_assert_num_queries, monkeypatch
):
"""
If an existing user already exists without organization, the organization must be updated.
"""
klass = OIDCAuthenticationBackend()
db_user = factories.UserFactory(name="John Doe", email="toto@my-domain.com")
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(9):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
assert user.organization is not None
assert user.organization.domain_list == ["my-domain.com"]
@@ -0,0 +1 @@
"""Core resource server tests."""
@@ -0,0 +1 @@
"""Core Swagger tests."""
@@ -5,12 +5,11 @@ Tests for Teams API endpoint in People's core app: create
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core.factories import TeamFactory, UserFactory
from core.factories import OrganizationFactory, UserFactory
from core.models import Team
pytestmark = pytest.mark.django_db
@@ -34,7 +33,8 @@ def test_api_teams_create_authenticated():
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team.
"""
user = UserFactory()
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
@@ -50,76 +50,34 @@ def test_api_teams_create_authenticated():
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.organization == organization
assert team.accesses.filter(role="owner", user=user).exists()
def test_api_teams_create_authenticated_slugify_name():
def test_api_teams_create_cannot_override_organization():
"""
Creating teams should automatically generate a slug.
Authenticated users should be able to create teams and not
be able to set the organization manually (for now).
"""
user = UserFactory()
organization = OrganizationFactory(with_registration_id=True)
user = UserFactory(organization=organization)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
{"name": "my team"},
{
"name": "my team",
"organization": OrganizationFactory(
with_registration_id=True
).pk, # ignored
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.slug == "my-team"
@pytest.mark.parametrize(
"param",
[
("my team", "my-team"),
("my team", "my-team"),
("MY TEAM TOO", "my-team-too"),
("mon équipe", "mon-equipe"),
("front devs & UX", "front-devs-ux"),
],
)
def test_api_teams_create_authenticated_expected_slug(param):
"""
Creating teams should automatically create unaccented, no unicode, lower-case slug.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
{
"name": param[0],
},
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == param[0]
assert team.slug == param[1]
def test_api_teams_create_authenticated_unique_slugs():
"""
Creating teams should raise an error if already existing slug.
"""
TeamFactory(name="existing team")
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/teams/",
{
"name": "èxisting team",
},
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["slug"] == ["Team with this Slug already exists."]
assert team.organization == organization
assert team.accesses.filter(role="owner", user=user).exists()
@@ -47,60 +47,13 @@ def test_api_teams_list_authenticated():
)
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
results = response.json()
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_teams_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
team_ids = [
str(access.team.id)
for access in factories.TeamAccessFactory.create_batch(3, user=user)
]
# Get page 1
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/teams/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
team_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/teams/?page=2",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/teams/"
assert len(content["results"]) == 1
team_ids.remove(content["results"][0]["id"])
assert team_ids == []
def test_api_teams_list_authenticated_distinct():
"""A team with several related users should only be listed once."""
user = factories.UserFactory()
@@ -118,8 +71,8 @@ def test_api_teams_list_authenticated_distinct():
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(team.id)
assert len(content) == 1
assert content[0]["id"] == str(team.id)
def test_api_teams_order():
@@ -142,7 +95,7 @@ def test_api_teams_order():
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
response_team_ids = [team["id"] for team in response_data]
team_ids.reverse()
assert (
@@ -171,7 +124,7 @@ def test_api_teams_order_param():
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
response_team_ids = [team["id"] for team in response_data]
assert (
response_team_ids == team_ids
@@ -69,7 +69,6 @@ def test_api_teams_retrieve_authenticated_related():
assert response.json() == {
"id": str(team.id),
"name": team.name,
"slug": team.slug,
"abilities": team.get_abilities(user),
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
@@ -7,7 +7,6 @@ import random
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
@@ -125,7 +124,7 @@ def test_api_teams_update_authenticated_administrators():
elif key == "updated_at":
assert value > initial_values[key]
else:
# name, slug and abilities successfully modified
# name and abilities successfully modified
assert value == new_values[key]
@@ -158,7 +157,7 @@ def test_api_teams_update_authenticated_owners():
elif key == "updated_at":
assert value > old_team_values[key]
else:
# name, slug and abilities successfully modified
# name and abilities successfully modified
assert value == new_team_values[key]
@@ -189,31 +188,3 @@ def test_api_teams_update_administrator_or_owner_of_another():
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_existing_slug_should_return_error():
"""
Updating a team's name to an existing slug should return a bad request,
instead of creating a duplicate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.TeamFactory(name="Existing team", users=[(user, "administrator")])
my_team = factories.TeamFactory(name="New team", users=[(user, "administrator")])
updated_values = serializers.TeamSerializer(instance=my_team).data
# Update my team's name for existing team. Creates a duplicate slug
updated_values["name"] = "existing team"
response = client.put(
f"/api/v1.0/teams/{my_team.id!s}/",
updated_values,
format="json",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["slug"] == ["Team with this Slug already exists."]
# Both teams names and slugs should be unchanged
assert my_team.name == "New team"
assert my_team.slug == "new-team"
+26 -36
View File
@@ -78,28 +78,16 @@ def test_api_users_authenticated_list_by_email():
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.id), str(frank.id)]
assert user_ids == [str(frank.id), str(nicole.id)]
# Even with a low similarity threshold, query should match expected emails
response = client.get("/api/v1.0/users/?q=ool")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.is_device,
"is_staff": nicole.is_staff,
"language": nicole.language,
"timezone": str(nicole.timezone),
},
{
"id": str(frank.id),
"email": frank.email,
@@ -109,6 +97,15 @@ def test_api_users_authenticated_list_by_email():
"language": frank.language,
"timezone": str(frank.timezone),
},
{
"id": str(nicole.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.is_device,
"is_staff": nicole.is_staff,
"language": nicole.language,
"timezone": str(nicole.timezone),
},
]
@@ -118,9 +115,9 @@ def test_api_users_authenticated_list_by_name():
partial query on the name.
"""
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
dave = factories.UserFactory(name="dave bowman", email=None)
dave = factories.UserFactory(name="Dave bowman", email=None)
nicole = factories.UserFactory(name="nicole foole", email=None)
frank = factories.UserFactory(name="frank poole", email=None)
frank = factories.UserFactory(name="frank poolé", email=None)
factories.UserFactory(name="heywood floyd", email=None)
client = APIClient()
@@ -128,7 +125,7 @@ def test_api_users_authenticated_list_by_name():
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
"/api/v1.0/users/?q=dave",
)
assert response.status_code == HTTP_200_OK
@@ -142,28 +139,16 @@ def test_api_users_authenticated_list_by_name():
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.id), str(frank.id)]
assert user_ids == [str(frank.id), str(nicole.id)]
# Even with a low similarity threshold, query should match expected user
response = client.get("/api/v1.0/users/?q=ool")
response = client.get("/api/v1.0/users/?q=oole")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.is_device,
"is_staff": nicole.is_staff,
"language": nicole.language,
"timezone": str(nicole.timezone),
},
{
"id": str(frank.id),
"email": frank.email,
@@ -173,6 +158,15 @@ def test_api_users_authenticated_list_by_name():
"language": frank.language,
"timezone": str(frank.timezone),
},
{
"id": str(nicole.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.is_device,
"is_staff": nicole.is_staff,
"language": nicole.language,
"timezone": str(nicole.timezone),
},
]
@@ -184,22 +178,18 @@ def test_api_users_authenticated_list_by_name_and_email():
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
nicole = factories.UserFactory(email="nicole_foole@work.com", name="nicole foole")
frank = factories.UserFactory(email="oleg_poole@work.com", name=None)
oleg = factories.UserFactory(email="oleg_poole@work.com", name=None)
david = factories.UserFactory(email=None, name="david role")
client = APIClient()
client.force_login(user)
# Result that matches a trigram in name and email ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
# "Oleg poole" matches twice on "ole" in her email
# "David role" matches once on "ole" in his name
assert user_ids == [str(nicole.id), str(frank.id), str(david.id)]
assert user_ids == [str(david.id), str(oleg.id), str(nicole.id)]
def test_api_users_authenticated_list_exclude_users_already_in_team(
@@ -0,0 +1,127 @@
"""
Unit tests for the Organization model
"""
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_organization_str():
"""The str representation should be the organization's name."""
organization = factories.OrganizationFactory(
name="HAL 9000", registration_id_list=["12345678901234"]
)
assert str(organization) == f"HAL 9000 (# {organization.pk})"
def test_models_organization_constraints():
"""It should not be possible to create an organization."""
organization = factories.OrganizationFactory(
registration_id_list=["12345678901234"], domain_list=["hal9000.com"]
)
with pytest.raises(ValidationError):
models.Organization.objects.create(name="HAL 9000")
with pytest.raises(ValidationError):
models.Organization.objects.create(
name="HAL 9000",
registration_id_list=[
organization.registration_id_list[0],
"12345678901235",
],
)
with pytest.raises(ValidationError):
models.Organization.objects.create(
name="HAL 9000", domain_list=[organization.domain_list[0], "hal9001.com"]
)
def test_models_organization_get_or_create_from_user_claims_no_kwargs():
"""It should fail."""
with pytest.raises(ValueError):
models.Organization.objects.get_or_create_from_user_claims()
def test_models_organization_get_or_create_from_user_claims_with_registration_id():
"""It should create an organization with a registration ID number."""
organization, created = models.Organization.objects.get_or_create_from_user_claims(
registration_id="12345678901234"
)
assert created is True
assert organization.registration_id_list == ["12345678901234"]
assert organization.domain_list == []
same_organization, created = (
models.Organization.objects.get_or_create_from_user_claims(
registration_id="12345678901234"
)
)
assert created is False
assert organization == same_organization
assert same_organization.registration_id_list == ["12345678901234"]
assert same_organization.domain_list == []
def test_models_organization_get_or_create_from_user_claims_with_domain():
"""It should create an organization with a domain."""
organization, created = models.Organization.objects.get_or_create_from_user_claims(
domain="hal9000.com"
)
assert created is True
assert organization.registration_id_list == []
assert organization.domain_list == ["hal9000.com"]
same_organization, created = (
models.Organization.objects.get_or_create_from_user_claims(domain="hal9000.com")
)
assert created is False
assert organization == same_organization
assert same_organization.registration_id_list == []
assert same_organization.domain_list == ["hal9000.com"]
def test_models_organization_get_or_create_from_user_claims_with_registration_id_and_domain():
"""It should create an organization with a registration ID number."""
organization, created = models.Organization.objects.get_or_create_from_user_claims(
registration_id="12345678901234", domain="hal9000.com"
)
assert created is True
assert organization.registration_id_list == ["12345678901234"]
assert organization.domain_list == []
same_organization, created = (
models.Organization.objects.get_or_create_from_user_claims(
registration_id="12345678901234", domain="hal9000.com"
)
)
assert created is False
assert organization == same_organization
assert same_organization.registration_id_list == ["12345678901234"]
assert same_organization.domain_list == []
def test_models_organization_registration_id_validators():
"""
Test the registration ID validators.
This cannot be tested dynamically because the validators are set at model loading
and this is not possible to reload the models on the fly. We therefore enforce the
setting in Test environment.
"""
models.Organization.objects.create(
name="hu",
registration_id_list=["12345678901234"],
)
with pytest.raises(ValidationError):
models.Organization.objects.create(
name="hi",
registration_id_list=["a12345678912345"],
)
@@ -47,12 +47,6 @@ def test_models_teams_name_max_length():
factories.TeamFactory(name="a " * 51)
def test_models_teams_slug_empty():
"""Slug field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Team.objects.create(slug="")
# get_abilities
+58
View File
@@ -0,0 +1,58 @@
"""
Test cases for core.validators module.
"""
from django.core.exceptions import ImproperlyConfigured
from django.core.validators import EmailValidator, RegexValidator
import pytest
from core.validators import get_field_validators_from_setting
def test_get_field_validators_from_setting_without_option(settings):
"""Test get_field_validators_from_setting without options."""
settings.VALIDATOR_NO_OPTION = [
{
"NAME": "django.core.validators.EmailValidator",
},
]
validators = get_field_validators_from_setting("VALIDATOR_NO_OPTION")
assert len(validators) == 1
assert isinstance(validators[0], EmailValidator)
def test_get_field_validators_from_setting_with_option(settings):
"""Test get_field_validators_from_setting with options."""
settings.REGEX_WITH_OPTIONS = [
{
"NAME": "django.core.validators.RegexValidator",
"OPTIONS": {
"regex": "[a-z][0-9]{14}",
},
},
]
validators = get_field_validators_from_setting("REGEX_WITH_OPTIONS")
assert len(validators) == 1
assert isinstance(validators[0], RegexValidator)
assert validators[0].regex.pattern == "[a-z][0-9]{14}"
def test_get_field_validators_from_setting_invalid_class_name(settings):
"""Test get_field_validators_from_setting with an invalid class name."""
settings.INVALID_VALIDATORS = [
{
"NAME": "non.existent.Validator",
},
]
with pytest.raises(ImproperlyConfigured):
get_field_validators_from_setting("INVALID_VALIDATORS")
def test_get_field_validators_from_setting_empty_setting(settings):
"""Test get_field_validators_from_setting with an empty setting."""
settings.EMPTY_VALIDATORS = []
validators = get_field_validators_from_setting("EMPTY_VALIDATORS")
assert not validators
+1
View File
@@ -0,0 +1 @@
"""Core utils module."""
+41
View File
@@ -0,0 +1,41 @@
"""
Declare validators that can be used in our Django models.
"""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
def get_field_validators_from_setting(setting_name: str) -> list:
"""
Get field validators from a setting.
Highly inspired by Django's `get_password_validators` function.
The setting should be a list of dictionaries, where each dictionary
should have a NAME key that points to the validator class and an
optional OPTIONS key that points to the validator options.
Example:
```
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
{
"NAME": "django.core.validators.RegexValidator",
"OPTIONS": {
"regex": "[a-z][0-9]{14}",
},
},
]
```
"""
validators = []
for validator in getattr(settings, setting_name):
try:
klass = import_string(validator["NAME"])
except ImportError as exc:
msg = "The module in NAME could not be imported: %s. Check your %s setting."
raise ImproperlyConfigured(msg % (validator["NAME"], setting_name)) from exc
validators.append(klass(**validator.get("OPTIONS", {})))
return validators
+1
View File
@@ -33,6 +33,7 @@ class DebugViewNewMailboxHtml(DebugBaseView):
template_name = "mail/html/new_mailbox.html"
def get_context_data(self, **kwargs):
"""Hardcode user credentials for debug setting."""
context = super().get_context_data(**kwargs)
context["mailbox_data"] = {
"email": "john.doe@example.com",
+1
View File
@@ -0,0 +1 @@
"""Demo module."""
+1
View File
@@ -0,0 +1 @@
"""Demo management module."""
@@ -0,0 +1 @@
"Demo management commands module."
@@ -128,6 +128,45 @@ def create_demo(stdout):
language=random.choice(settings.LANGUAGES)[0],
)
)
# this is a quick fix to fix e2e tests
# tests needs some no random data
queue.push(
models.User(
sub=uuid4(),
email="monique.test@example.com",
name="Monique Test",
password="!",
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.push(
models.User(
sub=uuid4(),
email="jeanne.test@example.com",
name="Jean Test",
password="!",
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.push(
models.User(
sub=uuid4(),
email="jean.somethingelse@example.com",
name="Jean Something",
password="!",
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating teams"):
@@ -135,8 +174,6 @@ def create_demo(stdout):
queue.push(
models.Team(
name=f"Team {i:d}",
# slug should be automatic but bulk_create doesn't use save
slug=f"team-{i:d}",
)
)
queue.flush()
@@ -156,8 +193,13 @@ def create_demo(stdout):
queue.flush()
with Timeit(stdout, "Creating domains"):
created = set()
for _i in range(defaults.NB_OBJECTS["domains"]):
name = fake.domain_name()
if name in created:
continue
created.add(name)
slug = slugify(name)
queue.push(
+1
View File
@@ -0,0 +1 @@
"""Demo tests."""
@@ -28,7 +28,9 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.User.objects.count() == TEST_NB_OBJECTS["users"]
assert (
models.User.objects.count() == TEST_NB_OBJECTS["users"] + 3
) # Monique Test, Jeanne Test and Jean Something (quick fix for e2e)
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
assert mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"]
+1
View File
@@ -0,0 +1 @@
"""Mailbox manager module."""
+35 -8
View File
@@ -1,9 +1,35 @@
"""Admin classes and registrations for People's mailbox manager app."""
from django.contrib import admin
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from requests import exceptions
from mailbox_manager import models
from mailbox_manager.utils.dimail import DimailAPIClient
@admin.action(description=_("Synchronise from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to synchronize existing mailboxes from dimail to our database."""
client = DimailAPIClient()
for domain in queryset:
try:
imported_mailboxes = client.synchronize_mailboxes_from_dimail(domain)
except exceptions.HTTPError as err:
messages.error(
request,
_(f"Synchronisation failed for {domain.name} with message: [{err}]"),
)
else:
messages.success(
request,
_(
f"Synchronisation succeed for {domain.name}. "
f"Imported mailboxes: {', '.join(imported_mailboxes)}"
),
)
class UserMailDomainAccessInline(admin.TabularInline):
@@ -28,6 +54,14 @@ class MailDomainAdmin(admin.ModelAdmin):
search_fields = ("name",)
readonly_fields = ["created_at", "slug"]
inlines = (UserMailDomainAccessInline,)
actions = (sync_mailboxes_from_dimail,)
@admin.register(models.Mailbox)
class MailboxAdmin(admin.ModelAdmin):
"""Admin for mailbox model."""
list_display = ("__str__", "first_name", "last_name")
@admin.register(models.MailDomainAccess)
@@ -50,10 +84,3 @@ class MailDomainAccessInline(admin.TabularInline):
autocomplete_fields = ["user", "domain"]
model = models.MailDomainAccess
readonly_fields = ("created_at", "updated_at")
@admin.register(models.Mailbox)
class MailboxAdmin(admin.ModelAdmin):
"""Admin for mailbox model."""
list_display = ("__str__", "first_name", "last_name")
@@ -0,0 +1 @@
"""Mailbox manager API module."""
@@ -18,6 +18,7 @@ class MailBoxPermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
def has_permission(self, request, view):
"""Check permission based on domain."""
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
@@ -40,6 +40,7 @@ class MailDomainViewSet(
queryset = models.MailDomain.objects.all()
def get_queryset(self):
"""Restrict results to the current user's team."""
return self.queryset.filter(accesses__user=self.request.user)
def perform_create(self, serializer):
@@ -100,6 +101,7 @@ class MailDomainAccessViewSet(
detail_serializer_class = serializers.MailDomainAccessSerializer
def get_serializer_class(self):
"""Chooses list or detail serializer according to the action."""
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
@@ -0,0 +1 @@
"Mailbox manager management module."
@@ -0,0 +1 @@
"""Mailbox manager management commands module."""
@@ -7,6 +7,9 @@ from django.core.management.base import BaseCommand, CommandError
import requests
from rest_framework import status
from mailbox_manager.enums import MailDomainStatusChoices
from mailbox_manager.models import MailDomain
User = get_user_model()
@@ -48,16 +51,21 @@ class Command(BaseCommand):
perms=["new_domain", "create_users", "manage_users"],
)
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
if not MailDomain.objects.filter(name=domain_name).exists():
MailDomain.objects.create(
name=domain_name, status=MailDomainStatusChoices.ENABLED
)
self.create_domain(domain_name)
# we create a dimail user for keycloak+regie user John Doe
# This way, la Régie will be able to make request in the name of
# this user
people_base_user = User.objects.get(name="John Doe")
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
# we create a domain and add John Doe to it
domain_name = "test.domain.com"
self.create_domain(domain_name)
self.create_allows(people_base_user.sub, domain_name)
people_base_user = User.objects.filter(name="John Doe")
if people_base_user.exists():
self.create_user(name=people_base_user.sub, password="whatever") # noqa S106
self.create_allows(people_base_user.sub, domain_name)
self.stdout.write("DONE", ending="\n")
@@ -0,0 +1 @@
"""Mailbox manager tests."""
@@ -11,6 +11,7 @@ from django.test.utils import override_settings
from django.utils.translation import gettext_lazy as _
import pytest
import requests
import responses
from rest_framework import status
from rest_framework.test import APIClient
@@ -531,7 +532,7 @@ def test_api_mailboxes__handling_dimail_unexpected_error(mock_error):
content_type="application/json",
)
with pytest.raises(SystemError):
with pytest.raises(requests.exceptions.HTTPError):
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
@@ -0,0 +1,69 @@
"""Test the `setup_dimail_db` management command"""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management import call_command
import pytest
import requests
from mailbox_manager.management.commands.setup_dimail_db import DIMAIL_URL, admin
pytestmark = pytest.mark.django_db
admin_auth = (admin["username"], admin["password"])
User = get_user_model()
@pytest.mark.skipif(
settings.DEBUG is not True,
reason="Run only in local (dimail container not running in other envs)",
)
def test_commands_setup_dimail_db():
"""The create_demo management command should create objects as expected."""
call_command("setup_dimail_db")
# check created users
response = requests.get(url=f"{DIMAIL_URL}/users/", auth=admin_auth, timeout=10)
users = response.json()
# if John Doe exists, we created a dimail user for them
local_user = User.objects.filter(name="John Doe").exists()
assert len(users) == 3 if local_user else 2
# remove uuid because we cannot devine them
[user.pop("uuid") for user in users] # pylint: disable=W0106
if local_user:
assert users.pop() == {
"is_admin": False,
"name": User.objects.get(name="John Doe").uuid,
"perms": [],
}
assert users == [
{
"is_admin": True,
"name": "admin",
"perms": [],
},
{
"is_admin": False,
"name": "la_regie",
"perms": [
"new_domain",
"create_users",
"manage_users",
],
},
]
# check created domains
response = requests.get(url=f"{DIMAIL_URL}/domains/", auth=admin_auth, timeout=10)
domains = response.json()
assert len(domains) == 1
assert domains[0]["name"] == "test.domain.com"
# check created allows
response = requests.get(url=f"{DIMAIL_URL}/allows/", auth=admin_auth, timeout=10)
allows = response.json()
assert len(allows) == 2 if local_user else 1
@@ -0,0 +1,156 @@
"""
Unit tests for dimail client
"""
import re
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from logging import Logger
from unittest import mock
import pytest
import responses
from rest_framework import status
from mailbox_manager import factories, models
from mailbox_manager.utils.dimail import DimailAPIClient
pytestmark = pytest.mark.django_db
def test_dimail_synchronization__already_sync():
"""
No mailbox should be created when everything is already synced.
"""
domain = factories.MailDomainEnabledFactory()
factories.MailboxFactory.create_batch(3, domain=domain)
pre_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
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.synchronize_mailboxes_from_dimail(domain)
post_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
assert post_sync_mailboxes.count() == 3
assert imported_mailboxes == []
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"""
domain = factories.MailDomainEnabledFactory()
assert not models.Mailbox.objects.exists()
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",
)
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",
}
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.synchronize_mailboxes_from_dimail(domain)
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert mock_warning.call_count == 3
# 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"],
)
# 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)
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == "oxadmin"
assert imported_mailboxes == [mailbox_valid["email"]]
@@ -0,0 +1 @@
"""Mailbox manager utils."""
+69 -1
View File
@@ -1,6 +1,9 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
import ast
import smtplib
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from email.headerregistry import Address
from logging import getLogger
from django.conf import settings
@@ -13,6 +16,8 @@ import requests
from rest_framework import status
from urllib3.util import Retry
from mailbox_manager import models
logger = getLogger(__name__)
adapter = requests.adapters.HTTPAdapter(
@@ -123,7 +128,7 @@ class DimailAPIClient:
logger.error(
"[DIMAIL] unexpected error : %s %s", response.status_code, error_content
)
raise SystemError(
raise requests.exceptions.HTTPError(
f"Unexpected response from dimail: {response.status_code} {error_content}"
)
@@ -163,3 +168,66 @@ class DimailAPIClient:
recipient,
exception,
)
def synchronize_mailboxes_from_dimail(self, domain):
"""Synchronize mailboxes from dimail - open xchange to our database.
This is useful in case of acquisition of a pre-existing mail domain.
Mailboxes created here are not new mailboxes and will not trigger mail notification."""
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/mailboxes/",
headers=self.get_headers(),
verify=True,
timeout=10,
)
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.pass_dimail_unexpected_response(response)
dimail_mailboxes = ast.literal_eval(
response.content.decode("utf-8")
) # format output str to proper list
people_mailboxes = models.Mailbox.objects.filter(domain=domain)
imported_mailboxes = []
for dimail_mailbox in dimail_mailboxes:
if not dimail_mailbox["email"] 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,
secondary_email=dimail_mailbox[
"email"
], # secondary email is mandatory. Unfortunately, dimail doesn't
# store any. We temporarily give current email as secondary email.
)
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:
logger.warning(
"Import of email %s failed with error %s",
dimail_mailbox["email"],
err,
)
return imported_mailboxes
+1
View File
@@ -0,0 +1 @@
"""People module."""
+32 -3
View File
@@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -387,6 +388,11 @@ class Base(Configuration):
environ_name="USER_OIDC_FIELDS_TO_NAME",
environ_prefix=None,
)
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD = values.Value(
default=None,
environ_name="OIDC_ORGANIZATION_REGISTRATION_ID_FIELD",
environ_prefix=None,
)
OIDC_OP_TOKEN_INTROSPECTION_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_TOKEN_INTROSPECTION_ENDPOINT", environ_prefix=None
@@ -436,6 +442,15 @@ class Base(Configuration):
environ_prefix=None,
)
# Organizations
ORGANIZATION_REGISTRATION_ID_VALIDATORS = json.loads(
values.Value(
default="[]",
environ_name="ORGANIZATION_REGISTRATION_ID_VALIDATORS",
environ_prefix=None,
)
)
FEATURES = {
"TEAMS": values.BooleanValue(
default=True, environ_name="FEATURE_TEAMS", environ_prefix=None
@@ -488,10 +503,14 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
traces_sample_rate=0.1,
default_integrations=False,
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
class Build(Base):
@@ -550,6 +569,7 @@ class Development(Base):
)
def __init__(self):
"""In dev, force installs needed for Swagger API."""
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]
@@ -593,6 +613,15 @@ class Test(Base):
# this is a dev credentials for mail provisioning API
MAIL_PROVISIONING_API_CREDENTIALS = "bGFfcmVnaWU6cGFzc3dvcmQ="
ORGANIZATION_REGISTRATION_ID_VALIDATORS = [
{
"NAME": "django.core.validators.RegexValidator",
"OPTIONS": {
"regex": "^[0-9]{14}$",
},
},
]
class ContinuousIntegration(Test):
"""
+11 -11
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.4.1"
version = "1.5.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,14 +25,14 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.44",
"boto3==1.35.54",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.5.0",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.1.1",
"redis==5.2.0",
"django-redis==5.4.0",
"django-storages==1.14.4",
"django-timezone-field>=5.1",
@@ -50,8 +50,7 @@ dependencies = [
"joserfc==1.0.0",
"requests==2.32.3",
"sentry-sdk[django]==2.17.0",
"url-normalize==1.4.3",
"whitenoise==6.7.0",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
]
@@ -64,19 +63,19 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"drf-spectacular-sidecar==2024.11.1",
"ipdb==0.13.13",
"ipython==8.28.0",
"ipython==8.29.0",
"pyfakefs==5.7.1",
"pylint-django==2.6.1",
"pylint==3.3.1",
"pytest-cov==5.0.0",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.7.0",
"ruff==0.7.2",
"types-requests==2.32.0.20241016",
"freezegun==1.5.1",
]
@@ -116,8 +115,9 @@ select = [
"S", # flake8-bandit
"SLF", # flake8-self
"T20", # flake8-print
"D1", # pydocstyle
]
ignore= ["DJ001", "PLR2004"]
ignore= ["DJ001", "PLR2004", "D105", "D106"]
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","people","first-party","local-folder"]
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
+16 -17
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.4.1",
"version": "1.5.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,37 +16,36 @@
},
"dependencies": {
"@gouvfr-lasuite/integration": "1.0.2",
"@hookform/resolvers": "3.9.0",
"@hookform/resolvers": "3.9.1",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.56.2",
"i18next": "23.15.1",
"@tanstack/react-query": "5.59.20",
"i18next": "23.16.5",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.13",
"next": "15.0.3",
"react": "*",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-hook-form": "7.53.0",
"react-i18next": "15.0.2",
"react-select": "5.8.1",
"sass": "1.80.3",
"react-hook-form": "7.53.2",
"react-i18next": "15.1.1",
"react-select": "5.8.3",
"sass": "1.80.6",
"styled-components": "6.1.13",
"zod": "3.23.8",
"zustand": "4.5.5"
"zustand": "5.0.1"
},
"devDependencies": {
"@hookform/devtools": "4.3.1",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.58.0",
"@tanstack/react-query-devtools": "5.59.20",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.9",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.10",
"@types/react": "18.3.12",
"@types/react-dom": "*",
"dotenv": "16.4.5",
"eslint-config-people": "*",
@@ -55,7 +54,7 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.3",
"stylelint": "16.9.0",
"stylelint": "16.10.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*"
@@ -1,11 +1,6 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Team } from '../types';
@@ -17,18 +12,14 @@ export enum TeamsOrdering {
export type TeamsParams = {
ordering: TeamsOrdering;
};
type TeamsAPIParams = TeamsParams & {
page: number;
};
type TeamsResponse = APIList<Team>;
type TeamsResponse = Team[];
export const getTeams = async ({
ordering,
page,
}: TeamsAPIParams): Promise<TeamsResponse> => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
}: TeamsParams): Promise<TeamsResponse> => {
const orderingQuery = ordering ? `?ordering=${ordering}` : '';
const response = await fetchAPI(`teams/${orderingQuery}`);
if (!response.ok) {
throw new APIError('Failed to get the teams', await errorCauses(response));
@@ -39,33 +30,9 @@ export const getTeams = async ({
export const KEY_LIST_TEAM = 'teams';
export function useTeams(
param: TeamsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
TeamsResponse,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
>,
) {
return useInfiniteQuery<
TeamsResponse,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_TEAM, param],
queryFn: ({ pageParam }) =>
getTeams({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
export function useTeams(params: TeamsParams) {
return useQuery({
queryKey: [KEY_LIST_TEAM, params],
queryFn: () => getTeams(params),
});
}
@@ -23,10 +23,7 @@ describe('PanelTeams', () => {
});
it('renders with no team to display', async () => {
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
count: 0,
results: [],
});
fetchMock.mock(`end:/teams/?ordering=-created_at`, []);
render(<TeamList />, { wrapper: AppWrapper });
@@ -40,16 +37,13 @@ describe('PanelTeams', () => {
});
it('renders an empty team', async () => {
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [],
},
],
});
fetchMock.mock(`end:/teams/?ordering=-created_at`, [
{
id: '1',
name: 'Team 1',
accesses: [],
},
]);
render(<TeamList />, { wrapper: AppWrapper });
@@ -59,21 +53,18 @@ describe('PanelTeams', () => {
});
it('renders a team with only 1 member', async () => {
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'owner',
},
],
},
],
});
fetchMock.mock(`end:/teams/?ordering=-created_at`, [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'owner',
},
],
},
]);
render(<TeamList />, { wrapper: AppWrapper });
@@ -83,25 +74,22 @@ describe('PanelTeams', () => {
});
it('renders a non-empty team', () => {
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'admin',
},
{
id: '2',
role: 'member',
},
],
},
],
});
fetchMock.mock(`end:/teams/?ordering=-created_at`, [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'admin',
},
{
id: '2',
role: 'member',
},
],
},
]);
render(<TeamList />, { wrapper: AppWrapper });
@@ -109,7 +97,7 @@ describe('PanelTeams', () => {
});
it('renders the error', async () => {
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
fetchMock.mock(`end:/teams/?ordering=-created_at`, {
status: 500,
});
@@ -125,10 +113,7 @@ describe('PanelTeams', () => {
});
it('renders with team panel open', async () => {
fetchMock.mock(`end:/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [],
});
fetchMock.mock(`end:/teams/?ordering=-created_at`, []);
render(<Panel />, { wrapper: AppWrapper });
@@ -140,10 +125,7 @@ describe('PanelTeams', () => {
});
it('closes and opens the team panel', async () => {
fetchMock.get(`end:/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [],
});
fetchMock.get(`end:/teams/?ordering=-created_at`, []);
render(<Panel />, { wrapper: AppWrapper });
@@ -1,9 +1,8 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useMemo, useRef } from 'react';
import React, { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { InfiniteScroll } from '@/components/InfiniteScroll';
import { Team, useTeams } from '@/features/teams/team-management';
import { useTeamStore } from '../store';
@@ -57,39 +56,14 @@ const TeamListState = ({ isLoading, isError, teams }: PanelTeamsStateProps) => {
export const TeamList = () => {
const ordering = useTeamStore((state) => state.ordering);
const {
data,
isError,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTeams({
const { data, isError, isLoading } = useTeams({
ordering,
});
const containerRef = useRef<HTMLDivElement>(null);
const teams = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Team[]);
}, [data?.pages]);
return (
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}>
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
$margin={{ top: 'none' }}
$padding="none"
role="listbox"
>
<TeamListState isLoading={isLoading} isError={isError} teams={teams} />
</InfiniteScroll>
<TeamListState isLoading={isLoading} isError={isError} teams={data} />
</Box>
);
};
@@ -26,9 +26,9 @@ const payloadGetTeams = {
};
const mockApiRequests = (page: Page) => {
void page.route('**/teams/?page=1&ordering=-created_at', (route) => {
void page.route('**/teams/?ordering=-created_at', (route) => {
void route.fulfill({
json: payloadGetTeams,
json: payloadGetTeams.results,
});
});
};
@@ -139,9 +139,9 @@ test.describe('Members Delete', () => {
await createTeam(page, 'member-delete-6', browserName, 1);
// To not be the only owner
await addNewMember(page, 0, 'Owner');
await addNewMember(page, 0, 'Owner', 'Jean');
const username = await addNewMember(page, 1, 'Administration', 'something');
const username = await addNewMember(page, 0, 'Administration', 'Monique');
const table = page.getByLabel('List members card').getByRole('table');
@@ -93,27 +93,6 @@ test.describe('Teams Create', () => {
await expect(page).toHaveURL(/\/teams\//);
});
test('checks error when duplicate team', async ({ page, browserName }) => {
const panel = page.getByLabel('Teams panel').first();
await panel.getByRole('link', { name: 'Add a team' }).click();
const teamName = `My duplicate team ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Team name').fill(teamName);
await page.getByRole('button', { name: 'Create the team' }).click();
await panel.getByRole('link', { name: 'Add a team' }).click();
await page.getByText('Team name').fill(teamName);
await page.getByRole('button', { name: 'Create the team' }).click();
await expect(
page.getByText(
'This name is already used for another group. Please enter another one.',
),
).toBeVisible();
});
test('checks 404 on teams/[id] page', async ({ page }) => {
await page.goto('/teams/some-unknown-team');
await expect(
@@ -1,7 +1,5 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { createTeam, keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
@@ -31,13 +29,13 @@ test.describe('Teams Panel', () => {
test('checks the sort button', async ({ page }) => {
const responsePromiseSortDesc = page.waitForResponse(
(response) =>
response.url().includes('/teams/?page=1&ordering=-created_at') &&
response.url().includes('/teams/?ordering=-created_at') &&
response.status() === 200,
);
const responsePromiseSortAsc = page.waitForResponse(
(response) =>
response.url().includes('/teams/?page=1&ordering=created_at') &&
response.url().includes('/teams/?ordering=created_at') &&
response.status() === 200,
);
@@ -62,24 +60,6 @@ test.describe('Teams Panel', () => {
expect(responseSortDesc.ok()).toBeTruthy();
});
test('checks the infinite scroll', async ({ page, browserName }) => {
test.setTimeout(90000);
const panel = page.getByLabel('Teams panel').first();
const randomTeams = await createTeam(
page,
'team-infinite',
browserName,
40,
);
await expect(panel.locator('li')).toHaveCount(20);
await panel.getByText(randomTeams[24]).click();
await waitForElementCount(panel.locator('li'), 21, 10000);
expect(await panel.locator('li').count()).toBeGreaterThan(20);
});
test('checks the hover and selected state', async ({ page, browserName }) => {
const panel = page.getByLabel('Teams panel').first();
await createTeam(page, 'team-hover', browserName, 2);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.4.1",
"version": "1.5.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -9,7 +9,7 @@
"test:ui": "yarn test --ui"
},
"devDependencies": {
"@playwright/test": "1.47.2",
"@playwright/test": "1.48.2",
"@types/node": "*",
"eslint-config-people": "*",
"typescript": "*"
+5 -9
View File
@@ -17,18 +17,18 @@ export default defineConfig({
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Fail fast */
retries: 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 3 : undefined,
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [['html', { outputFolder: './report' }]],
reporter: [['html', { outputFolder: './report' }], ['list']],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL: baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: 'retain-on-failure',
},
webServer: {
@@ -46,9 +46,5 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'], locale: 'en-US' },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], locale: 'en-US' },
},
],
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "people",
"version": "1.4.1",
"version": "1.5.0",
"private": true,
"workspaces": {
"packages": [
@@ -24,10 +24,10 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@types/node": "20.16.10",
"@types/react-dom": "18.3.0",
"@types/node": "22.9.0",
"@types/react-dom": "18.3.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"typescript": "5.6.2"
"typescript": "5.6.3"
}
}
@@ -1,24 +1,24 @@
{
"name": "eslint-config-people",
"version": "1.4.1",
"version": "1.5.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "14.2.13",
"@tanstack/eslint-plugin-query": "5.58.1",
"@typescript-eslint/eslint-plugin": "8.7.0",
"@typescript-eslint/parser": "8.7.0",
"@next/eslint-plugin-next": "15.0.3",
"@tanstack/eslint-plugin-query": "5.60.1",
"@typescript-eslint/eslint-plugin": "8.14.0",
"@typescript-eslint/parser": "8.14.0",
"eslint": "8.57.0",
"eslint-config-next": "14.2.13",
"eslint-config-next": "15.0.3",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.9.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.0.1",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.37.0",
"eslint-plugin-testing-library": "6.3.0"
"eslint-plugin-react": "7.37.2",
"eslint-plugin-testing-library": "6.4.0"
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.4.1",
"version": "1.5.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:desk",
@@ -11,10 +11,10 @@
"test": "jest"
},
"dependencies": {
"@types/jest": "29.5.13",
"@types/jest": "29.5.14",
"@types/node": "*",
"eslint-config-people": "*",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-import": "2.31.0",
"i18next-parser": "9.0.2",
"jest": "29.7.0",
"ts-jest": "29.2.5",
+9598 -9744
View File
File diff suppressed because it is too large Load Diff
+46 -37
View File
@@ -1,9 +1,9 @@
djangoSecretKey: ENC[AES256_GCM,data:cjXSpQ+4IlpM6bn0fGZ6tdhWdFwppPuOwBdPEjI5kYEnj79aMalYzUUiEPImAa8/d80=,iv:h4grzvAWhya7Gz75EoQHQN0Hp0fl7I21/6G0Xx0n2P8=,tag:QKg755yZt1gm+dyVRfGBzQ==,type:str]
djangoSuperUserPass: ENC[AES256_GCM,data:GauOc+A=,iv:GAY2S6LSS3c8y4XtpyWP0jMteYjNYnAS++VFyQIWVXM=,tag:xy9IsOIpgYYieVZVgzAhcQ==,type:str]
mail_provisioning_api_credentials: ENC[AES256_GCM,data:FQdonFAixRpzMbvjjltIUvwZ3B5e7rRs,iv:zz0GynN5rxzLWAAW1Gr5RFZ0AnPBrwUlOM0tPTrDq0s=,tag:1xmZthk6+H4KCWHwjFoJPA==,type:str]
djangoSecretKey: ENC[AES256_GCM,data:MeAsS1OoGaC1yKvK4jlsvtM/tnXdy3AiZItRafBIvHJzz2D1fQ2Ol85cX6cJ1H7XGRs=,iv:cV/H03WnCYiPgjvuQTUXuhsPd/mHforbI818lkv4Tcw=,tag:ofJ9+AA+aMxuAt03n2j6sQ==,type:str]
djangoSuperUserPass: ENC[AES256_GCM,data:CrUCj+w=,iv:VvCIQYDvhbIeWI2lJt6kw4hBxzERY4H9OOV6CkCxXg4=,tag:e6LLH8bBenG7ZlWutkiECQ==,type:str]
mail_provisioning_api_credentials: ENC[AES256_GCM,data:2iDJSkOV/muVZQ5ZrWyBB+uslzEj/4Yv,iv:awJgZ4wUl1xM19yTFooa1e/U91awm8xraZWEYI5ZIh4=,tag:/n64HEwNVO5f1XuoYBTI6g==,type:str]
oidc:
clientId: ENC[AES256_GCM,data:0mjYen3Pu6/mjInlwic+IizFaMtpstUxkB5hiaxBE4eaYrf2,iv:pb+q3KPgAqWY3/xBAyF9gHyEnmFbQQsJiXKGnCxJJhU=,tag:P6jH8LS6DZb1l2KZTkMFdg==,type:str]
clientSecret: ENC[AES256_GCM,data:/1wTDUcZglxhosbQqeJBCYf1CkhUq/bXx8RcogMsc1VVumfIyJEgKuF66BAcEuDOD7h16fZwyjCD6N9M/Uj43w==,iv:o1dwPf+H0uKRU2Rbx5UM2djB3sFDTKKdG/lXZ74e2Cc=,tag:Kay/9Jqf6SDAXMl3/eg5LQ==,type:str]
clientId: ENC[AES256_GCM,data:C7WWJAC02IZ47FVtHUoFMX/t9u9Ar1wU0xN54IR+TcVmNLR6,iv:GCu4unvxtV2sxxR+Jo9c39Zyo21utQPM4/iyk0OIFOE=,tag:qU5Vcfq9LRxffRJW/h1taA==,type:str]
clientSecret: ENC[AES256_GCM,data:0FttMuHtz3zciIoGZl+2ele2SR2IGSW12RXZuYMZtHZBT71jgN4v9cR9zKPvpbudqGvoF86doPfHWZvBCcx6zA==,iv:HyfUnSsWWTVEK4Pf7kgK0MtlZvQiy6cKODjCw0WDG4w=,tag:0NbQK6+SWB82ul89kmzRHA==,type:str]
sops:
kms: []
gcp_kms: []
@@ -13,59 +13,68 @@ sops:
- recipient: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwUFg5QjJtYk52a2V1YXlC
bFBsTlZNS0tsMkNoRzVEenprV29hbGtOM0dRClVOVVVlemRzcTRpY2pSMDdQMGNY
d1JYU2kyZWgxaGlRejdKb0x6YnB0L0kKLS0tIDB2dklJWWtGNi84TCtWcTFEUEkw
eWkrU1lET0xwZHZwTHJvNE53Mm9Cc3MKNCtVwUNnllvg228ax4z4oHhA383zvkhN
2FAEOnKe94x3e2st4WrLK+fdO/4wE544ykCm2sUzXJfTN1g6N8YPyA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBObklxN2hPUEd2bkZQSE1j
MWE0a1dJcVloOTcyOHNmcC84dytaZ0NXNVJFCkl2eGFLUTh3LzFIRzNRNUhMT2Ir
aWpxK2cvcVZXbUVTbFFUSFZnaGtuekEKLS0tIFJ2NnJMejZuYWFTbkFYNGYrSS9X
aUxCb21NTlpYQWdraTA0djBsRkVCbGcK8l3yr3Wsit1bjWrHahdY4bPdVjz76WHC
ESSR0ekaHw+7jXe8yhfalLrFTyN9aa5/wJOy51oNIh6i9J9qiGpt5A==
-----END AGE ENCRYPTED FILE-----
- recipient: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzeE9WdE43cmFxT2FLMU00
RTdXek1xNUVseXpKeWR5Y3VEUExMSUxrQkhvClkvMUQvM0tsM3JTSUZaQjVXaGR4
QXBMQTU2UFVHSlFhK1NsVFFmM3k5NncKLS0tIE1VcjhTQ1JLdndyT2lqbGtYeFNC
TFp0N1BSbGxkQkFNSTJZN0tod1VIdE0KtqKga/vF4AfRMOr8MsHObXRAWbwQNCgY
We6JuFyJ+qL90TT6aFe7HfZP6m+LfiweOpNkzHj7TknbvSlJ/VzJZQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyRldFY3lFUkJ6UmhVUkJ5
ZmlQczJ2MklFSy9BVVV3K0UwWVpIOW5FYkc0CnI4WHNhTk1qa1BmOU16L0k2YzV5
Z25tT244NnlibVdMcWRWNlFleG1FYlUKLS0tIGpMcktpQjcva29TWVJkWGRNL0Vi
RTZ2V2luMTdaUGU3a04xSU1aSFJ4WWsKqTKbwlTGmTc99D4Ud/ohQNWamGX9QR06
jLLK2ySKP2EbBZxLe+3MZlufPPiESY8246pfdaymrdWZ1PS00TOdhA==
-----END AGE ENCRYPTED FILE-----
- recipient: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1UzVZZWh1djdNQUg4VGlK
WGhQT1JTcEV1Nm5qYUp3dEpvVHc0YWV5cVIwCmR2Q1pPekZJSExYQTNRM0RacTk0
QlkvV3g1aHhJVGRQNmYrb1dpRmFhZ00KLS0tIDJtUmRUTWdHRnJ6RVFNNS9JaTRX
ajUyZjR0VkRER2t3cHhTeU1TQzBzSFEKkmVOrv1G5/1DO1l9LNNZHryb7iMnDEVW
3gokQTKAcYd0D5G295/8Mvrm2E7j54KP5WhVjDN/f9Lg2liYEnS06Q==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaMTVWNHlXc0k2UUM4b3Q2
VTNQSmYySXc3Y0tWUHU2czhVWWt4bldabFdrClg2TWRvbHZkYVpiMnF2U2tPYXJy
ZXNwQzBVcnBXMkxEMmNXeWFXWGNVb2sKLS0tIGduOWpSTkxCKzNXY2xtQS9rWGp2
WTEyeDlRYlVtQTJ3N3RPMVpla0U3MTgK87FDs8GwhUGwgV5aLTWYAaVi+4QkWCmv
BG/RfGeYAm87FGGg/UUEPUCZgLnYPZwz/SzKfAZQlRP5s3POFRGpEg==
-----END AGE ENCRYPTED FILE-----
- recipient: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoblBGaDNobGtMc3FEK0pM
QTRKUURXdUxkcGZlaGlCYWJxN0dqWHhKWlhzClAzM1F1VHVFMjZzaURvSVpZdC9C
KzlBRWgrallpMWFpT2pzL0Q2RTF4eEEKLS0tIDk2QjNqZDNMZEdKVmdZRFlyRWZD
WE5abEdmQ29zTWJqZlBwbkdJQlNQZHMKfjtz47qD3BRVNIJ2hyyBWE/+Xj5AqY9q
vZ3T7HPq2qGZIqgFNXQAyv1pgy2ulgWdxP/fawRcR/xWerb7+PkVFg==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmZHZCVlIwM2cyeVMzWUpR
ZlFPSmthdGF2MVNwOFpjSWJmV1ptV3BZVHo0Ckh3ckc1K013YkdxUzNsMEUwa0pw
SFdGR3lmTlpJRzRFVTRqRmc4SFlMMW8KLS0tIGxnSWhmWlpPelhlZTkwOXBrMDRT
U1JPK3Z6NzBxNFNWenEyYVJZRzF2T1kKyFhaWvQ2/ZttyBDshz6fmhd3cgL31rhO
0EtPVQO5p7kDDyG2/TyrfR32C5/5+YNqS+Cggk31jon7blNvV3asVA==
-----END AGE ENCRYPTED FILE-----
- recipient: age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1a21pZHNUdUtqQUZ6Z3lm
VDFORFpYNlFYUFkwRVZkS3JKR0lnVTNzdlJnCkZlTi9LMXZBMEJ1Uml1cDh4ZHRy
bGlEcjEwUlp6RWJxTnhVNE5nNkxtTVkKLS0tIFV5WERuZiszNHFjd0V5cFlJWjAw
T3RVTDRZRjRUNTkwV1pQa2NSMkgydTQK8+hb0NP+Egyq4s7SmI5MPLFKJ3Jlztbh
GqOJ6vzORZwc/jvry2IMqtiZfDsdEYTAvMz16aaxcSMCmvFuXdtmvg==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQUzVTRCtOWlBMOGwwMTNp
ZndVZVZ4bXlPUHJCeFVhRVVpKzlmWnNZS0N3CjdWOGRNQmZkM2tib29NK0NXT3pH
alNnVDhiUWlTUXJkc0ZRb3MyLzhjY3cKLS0tIENzRDllUVV0dkdyeVNoclUwc21Z
amd2TEttd25PN2NNY0RFclZISFBaUVkKGUYbTjt/cw7KzHeSNt9Kem+Xhy7zcxC+
JPEliPnJiMuzoZNIoKq0Ta1aWaC9leN5k5JAbFOpqQTkcY+38V3Fpw==
-----END AGE ENCRYPTED FILE-----
- recipient: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3aUJKNlZPemdaY1BBdS8z
RC9UMTdmYVBsNFpjcWtrVVZiTHVtcUNnWjNrClBYWmFMTHpUT2tERG1JUHFPUm1y
bndCZHdoaWlhL3Iza3h0YUh3WWRJZ2MKLS0tIENiV1ZvV2luOFI0b0h5Q0xJQ21M
NjlseVc1TzUyNmczRVNHMThxZVlaVVUKRCqwzf0JfHRODTCWb6hS8lz0qqjx2GPS
zCMU8WcNi0Afj0h32q7xCZzRZP5vitzA2Ro9510VwQp0sN6C6rKR+g==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuWlkvaWNjdnFFTG44UkN6
Q3RaeUk3T3N1RFlISm1HQzkwa3MzdmtQSGdJCmdwM241WmhpS2ZKWVFNSmE5MTRQ
c3FGeWFhZFpobjQ1SEV4OWR0ZDNLMWsKLS0tIGpqaU5jZ1NhakErd2JsZG53RDNv
SXdwdThDSnRrRktSMW9xckpsNDNKV1kKI+iCo2o87qVA9E2dtnmIu251Xg0KbgVF
/J/M1HQVnIEHxhQYSjXat0ZAZDs5B1YnZ+nUG3iJ8q1hOKp2O9xtIw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2024-09-11T14:03:37Z"
mac: ENC[AES256_GCM,data:OpSIQiyQ7FlIGJnh+T2rxxHETewt27jURPqbAxlfusS4yY2pxxK40Cdv7QLUcoNKOuERnWth1qKGeY4uXqxXQK72hq4DWR32t6SoX795f/W8zqtBN378wrOQIgXwamzZQeY5aV/9NNHDTVnpjdGwGF3R+WYlfk3HpAKx/yNx5xg=,iv:EZwG1RnfoU6HTV6j0+FgrxdMNMUWT/tpO4iZubLQxkI=,tag:lwnNRrGDwRg18quW2ZHxCg==,type:str]
- recipient: age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoYUhBcjdGQTBTUWJ3cGgy
NXJHYnM5dXZHTzNzL1NWSitEYit3NWNhN2hjClErOGNFLzZ6VHVnaFRyZk05dFB4
M29ybkduSE44Uk9BcGN0aVQ0TUxxUVkKLS0tIGtsUGhMdXdIQlZNKzJNRzNnWUhF
M2hQY3kraFNqbjU3SkIzcWdZeDZIWFkK7Z39fJzr7a7/Lk62hU9GUjQPeA6C4Jp7
3Nj8sGpGKbt83u2tNYTHtpNa2a6MFqKfccxRKxwYUf9DfPRhH5p9nQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2024-10-23T09:37:33Z"
mac: ENC[AES256_GCM,data:L6tN1Lx4FtDUty2OKHIS9KiaayX9mTwiXzBsrPP8rEM3Gs/Z/v4XMfiIylBs6m1XUwrOy7kFNUGfnu1d72nB4ukWZBHTmcE9wZ3U1AaEnjjMPdIlUtyaNxmAbw5/QprZcempMLd5750QjEUHqDTzmF2+yI+Jt0mRMQEAFYY/5b4=,iv:vyRwRl1minGkv3XJMORWaf5NwJXWGa8us/x/DAyRDrQ=,tag:zgKEgD7IH/b1x7LRzq2NXg==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.9.0
+2 -1
View File
@@ -35,9 +35,10 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email siret"
OIDC_REDIRECT_ALLOWED_HOSTS: https://desk.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "^[0-9]{14}$"}}]'
LOGIN_REDIRECT_URL: https://desk.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://desk.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://desk.127.0.0.1.nip.io
@@ -1,7 +1,7 @@
image:
repository: lasuite/people-backend
pullPolicy: Always
tag: "v1.4.1"
tag: "v1.5.0"
backend:
migrateJobAnnotations:
@@ -51,9 +51,10 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email siret"
OIDC_REDIRECT_ALLOWED_HOSTS: https://desk-preprod.beta.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "^[0-9]{14}$"}}]'
LOGIN_REDIRECT_URL: https://desk-preprod.beta.numerique.gouv.fr
LOGIN_REDIRECT_URL_FAILURE: https://desk-preprod.beta.numerique.gouv.fr
LOGOUT_REDIRECT_URL: https://desk-preprod.beta.numerique.gouv.fr
@@ -113,7 +114,7 @@ frontend:
image:
repository: lasuite/people-frontend
pullPolicy: Always
tag: "v1.4.1"
tag: "v1.5.0"
ingress:
enabled: true
@@ -1,7 +1,7 @@
image:
repository: lasuite/people-backend
pullPolicy: Always
tag: "v1.4.1"
tag: "v1.5.0"
backend:
migrateJobAnnotations:
@@ -51,9 +51,10 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email siret"
OIDC_REDIRECT_ALLOWED_HOSTS: https://regie.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "^[0-9]{14}$"}}]'
LOGIN_REDIRECT_URL: https://regie.numerique.gouv.fr
LOGIN_REDIRECT_URL_FAILURE: https://regie.numerique.gouv.fr
LOGOUT_REDIRECT_URL: https://regie.numerique.gouv.fr
@@ -113,7 +114,7 @@ frontend:
image:
repository: lasuite/people-frontend
pullPolicy: Always
tag: "v1.4.1"
tag: "v1.5.0"
ingress:
enabled: true
@@ -65,9 +65,10 @@ backend:
name: backend
key: OIDC_RS_PRIVATE_KEY_STR
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email siret"
OIDC_REDIRECT_ALLOWED_HOSTS: https://desk-staging.beta.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
ORGANIZATION_REGISTRATION_ID_VALIDATORS: '[{"NAME": "django.core.validators.RegexValidator", "OPTIONS": {"regex": "[a-z][0-9]{14}"}}]'
LOGIN_REDIRECT_URL: https://desk-staging.beta.numerique.gouv.fr
LOGIN_REDIRECT_URL_FAILURE: https://desk-staging.beta.numerique.gouv.fr
LOGOUT_REDIRECT_URL: https://desk-staging.beta.numerique.gouv.fr
+1 -1
View File
@@ -26,7 +26,7 @@
</mj-section>
<mj-section padding="0px 20px">
<mj-column>
<mj-button href="//{{ webmail_url }}" background-color="#000091" color="white" padding="30px 0px">
<mj-button background-color="#000091" color="white" href="{{ webmail_url }}">
{% trans "Go to La Messagerie" %}
</mj-button>
<!-- Signature -->
+4
View File
@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1