Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f30264445 | |||
| bde91d55da | |||
| a328e16e53 | |||
| edde9c8d15 | |||
| a18f06ed27 | |||
| 72abe04c72 | |||
| 79e92214ab | |||
| e5f1151f58 | |||
| ca886c19b0 | |||
| b602478406 | |||
| 8b0f942f9b | |||
| bd6fe584c6 | |||
| 821db276bc | |||
| 639490d41e | |||
| 748e24cab6 | |||
| 55c0815c31 | |||
| 989239082e | |||
| 988a091e53 | |||
| 20a19d36b5 | |||
| 7d695ab81c | |||
| a42e7a10db | |||
| ad4065e682 | |||
| ababcde0d6 | |||
| faf8dcc7e5 | |||
| 2bbed8de0c | |||
| a736a9143f | |||
| c4ea62dc1f | |||
| 1d1f5cfbb6 | |||
| 3934a0bc28 | |||
| fd3ac00ea7 | |||
| 2d46b7d504 | |||
| 54e5be81e2 | |||
| 31944e8083 | |||
| d1421dbe8c | |||
| c8800259ae | |||
| bfc2462103 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,3 +8,4 @@ creation_rules:
|
||||
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
|
||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
|
||||
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
|
||||
|
||||
+29
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Root module."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core root module."""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Core utils module."""
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Demo module."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mailbox manager module."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People module."""
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
+39
-57
@@ -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);
|
||||
|
||||
@@ -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": "*"
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user