Compare commits
146 Commits
v1.6.1
...
ev/tryfix-test
| Author | SHA1 | Date | |
|---|---|---|---|
| 7522569ce0 | |||
| 351c696ef8 | |||
| 579dbdee10 | |||
| b4a877381a | |||
| 92753082c7 | |||
| 4df4172151 | |||
| e7af1fd591 | |||
| 8a2b0d0a76 | |||
| d48a3ff677 | |||
| 4fb5d87df9 | |||
| 6274764f90 | |||
| 7f0e231474 | |||
| 2c15609c1e | |||
| cd94dc5091 | |||
| 9d9216cf39 | |||
| 7dd9eae5d9 | |||
| 914319c366 | |||
| c34ad00fae | |||
| 289879962b | |||
| cd88799943 | |||
| 4011c8e8ed | |||
| b98281fa72 | |||
| 04bd154bad | |||
| 227ecd0700 | |||
| 6d618b4aff | |||
| 3a2b2b8a53 | |||
| f969b118cb | |||
| c48957612b | |||
| e09e1d1b80 | |||
| 615d5894c9 | |||
| 914554e45c | |||
| 3f990e93b7 | |||
| 9de20a496e | |||
| 870ef424f5 | |||
| 0e92c1cafa | |||
| defc18ea40 | |||
| 4ccea4655b | |||
| 45fd10fd2d | |||
| 201864db3a | |||
| 182f9c1d17 | |||
| cff3d5c123 | |||
| 32a576bbe9 | |||
| 010d3674de | |||
| 80976e3761 | |||
| b848f9eca6 | |||
| cd7135da00 | |||
| 0a795f6e6f | |||
| 6c8329405d | |||
| ea3a45ea87 | |||
| 86451df8b4 | |||
| 76fc789eb6 | |||
| 632a78c339 | |||
| 20cc173e93 | |||
| d495ef3e19 | |||
| 4def80214c | |||
| 2428229dbb | |||
| 9b9aa2aa37 | |||
| a28bb5e2a2 | |||
| 6962953625 | |||
| b0b718e657 | |||
| 0abfd49fee | |||
| 5d2e63fc18 | |||
| 94a443525e | |||
| 42d7d00772 | |||
| 5ec65b1202 | |||
| 9ba30843c5 | |||
| 4de589a7e8 | |||
| 151f030582 | |||
| a58152b9a9 | |||
| 8fd55a61c5 | |||
| 2435a59078 | |||
| fd2c90f50d | |||
| 469014ac41 | |||
| e60bae4321 | |||
| 2e7f224e30 | |||
| 8865e250a0 | |||
| fa114b1064 | |||
| 38369a8312 | |||
| d3940f6d09 | |||
| 6061a65e44 | |||
| 4a763396e2 | |||
| fcc63f9b82 | |||
| 0f4db82b02 | |||
| bcdc57905b | |||
| 30832a20e1 | |||
| a021e9eec6 | |||
| fa80edfaa8 | |||
| 38a5f158b5 | |||
| 6e14c2e61f | |||
| dd9a905dc0 | |||
| 2f380b49d0 | |||
| 9953dd2111 | |||
| 5d84e226b7 | |||
| 6fde76fb46 | |||
| 7154a491f4 | |||
| 019ce99a86 | |||
| 579aac264e | |||
| c54f010989 | |||
| 841a6c27ba | |||
| b060bbea51 | |||
| ac24dd91a7 | |||
| 867c89a2e6 | |||
| 54120eb179 | |||
| cf155dc033 | |||
| e623c32c9a | |||
| a1892d2693 | |||
| 632a96ccf4 | |||
| d7c654e1aa | |||
| 99b6181944 | |||
| 514414de16 | |||
| f1956f4e86 | |||
| f13e2c18b9 | |||
| 4ba1901355 | |||
| e821163269 | |||
| fc9499e211 | |||
| 9dde2fe7e2 | |||
| 5dad700c1c | |||
| f759d318b9 | |||
| 60ab61d125 | |||
| 625f122ad5 | |||
| 6d5d0f7ebb | |||
| b7908801d4 | |||
| 0785c3a5bc | |||
| 51eb3474e4 | |||
| 10be01f6af | |||
| 8e20f86d2c | |||
| 03c79b0412 | |||
| a33822b38d | |||
| edbd1f0061 | |||
| 5692c50f21 | |||
| 6fe4818743 | |||
| 02c6048d2c | |||
| a6f409f6ed | |||
| ccb06b3abf | |||
| 3469764697 | |||
| 892a5f10d6 | |||
| 0357caa75a | |||
| 9785ce295d | |||
| a26e10909d | |||
| d6f1cae9e9 | |||
| 478a3ffbd1 | |||
| 8e6b6318c9 | |||
| b524369add | |||
| a991737a59 | |||
| a041296f8a | |||
| 512d9fe82c |
@@ -11,26 +11,9 @@ jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
|
||||
@@ -19,20 +19,9 @@ jobs:
|
||||
trivy-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -57,36 +46,19 @@ jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/people-backend
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
@@ -108,32 +80,15 @@ jobs:
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/people-frontend
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
- name: create-version-json
|
||||
id: create-version-json
|
||||
uses: jsdaniell/create-json@v1.2.3
|
||||
@@ -144,7 +99,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -163,29 +118,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "people,secrets"
|
||||
@@ -197,6 +197,10 @@ jobs:
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Setup Dimail DB
|
||||
run: |
|
||||
make dimail-setup-db
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- ./src/helm/desk/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+112
-1
@@ -8,6 +8,110 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add count mailboxes to MailDomain serializer
|
||||
- ✨(dimail) manage 'action required' status for MailDomain
|
||||
- ✨(domains) add action required status on MailDomain
|
||||
- ✨(dimail) send pending mailboxes upon domain activation
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(teams) do not display add button when disallowed #676
|
||||
- 🚑️(plugins) fix name from SIRET specific case #674
|
||||
- 🐛(api) restrict mailbox sync to enabled domains
|
||||
|
||||
## [1.10.1] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(dimail) management command to fetch domain status
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(scripts) adapts release script after moving the deployment part
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(dimail) fix imported mailboxes should be enabled instead of pending #659
|
||||
- ⚡️(api) add missing cache for stats endpoint
|
||||
|
||||
## [1.10.0] - 2025-01-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) create stats endpoint
|
||||
- ✨(teams) add Team dependencies #560
|
||||
- ✨(organization) add admin action for plugin #640
|
||||
- ✨(anct) fetch and display organization names of communes #583
|
||||
- ✨(frontend) display email if no username #562
|
||||
- 🧑💻(oidc) add ability to pull registration ID (e.g. SIRET) from OIDC #577
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) improve e2e tests avoiding race condition from mocks #641
|
||||
- 🐛(backend) fix flaky test with search contact #605
|
||||
- 🐛(backend) fix flaky test with team access #646
|
||||
- 🧑💻(dimail) remove 'NoneType: None' log in debug mode
|
||||
- 🐛(frontend) fix flaky e2e test #636
|
||||
- 🐛(frontend) fix disable mailbox button display #631
|
||||
- 🐛(backend) fix dimail call despite mailbox creation failure on our side
|
||||
- 🧑💻(user) fix the User.language infinite migration #611
|
||||
|
||||
## [1.9.1] - 2024-12-18
|
||||
|
||||
## [1.9.0] - 2024-12-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) fix manage roles on domain admin view
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add admin action to check domain health
|
||||
- ✨(dimail) check domain health
|
||||
- ✨(frontend) disable mailbox and allow to create pending mailbox
|
||||
- ✨(organizations) add siret to name conversion #584
|
||||
- 💄(frontend) redirect home according to abilities #588
|
||||
- ✨(maildomain_access) add API endpoint to search users #508
|
||||
|
||||
## [1.8.0] - 2024-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(contacts) add "abilities" to API endpoint data #585
|
||||
- ✨(contacts) allow filter list API with email
|
||||
- ✨(contacts) return profile contact from same organization
|
||||
- ✨(dimail) automate allows requests to dimail
|
||||
- ✨(contacts) add notes & force full_name #565
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(contacts) move user profile to contact #572
|
||||
- ♻️(contacts) split api test module in actions #573
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) fix manage roles on domain admin view
|
||||
- 🐛(mailbox) fix activate mailbox feature
|
||||
- 🔧(helm) fix the configuration environment #579
|
||||
|
||||
## [1.7.1] - 2024-11-28
|
||||
|
||||
## [1.7.0] - 2024-11-28
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(mailbox) allow to activate mailbox
|
||||
- ✨(mailbox) allow to disable mailbox
|
||||
- ✨(backend) add ServiceProvider #522
|
||||
- 💄(admin) allow header color customization #552
|
||||
- ✨(organization) add API endpoints #551
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(admin) add organization on user #555
|
||||
|
||||
## [1.6.1] - 2024-11-22
|
||||
|
||||
### Fixed
|
||||
@@ -172,7 +276,14 @@ 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.6.1...main
|
||||
[unreleased]: https://github.com/numerique-gouv/people/compare/v1.10.1...main
|
||||
[1.10.1]: https://github.com/numerique-gouv/people/releases/v1.10.1
|
||||
[1.10.0]: https://github.com/numerique-gouv/people/releases/v1.10.0
|
||||
[1.9.1]: https://github.com/numerique-gouv/people/releases/v1.9.1
|
||||
[1.9.0]: https://github.com/numerique-gouv/people/releases/v1.9.0
|
||||
[1.8.0]: https://github.com/numerique-gouv/people/releases/v1.8.0
|
||||
[1.7.1]: https://github.com/numerique-gouv/people/releases/v1.7.1
|
||||
[1.7.0]: https://github.com/numerique-gouv/people/releases/v1.7.0
|
||||
[1.6.1]: https://github.com/numerique-gouv/people/releases/v1.6.1
|
||||
[1.6.0]: https://github.com/numerique-gouv/people/releases/v1.6.0
|
||||
[1.5.0]: https://github.com/numerique-gouv/people/releases/v1.5.0
|
||||
|
||||
+11
-11
@@ -1,7 +1,7 @@
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.12.6-alpine3.20 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 setuptools
|
||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
### ---- Front-end dependencies image ----
|
||||
FROM node:20 as frontend-deps
|
||||
FROM node:20 AS frontend-deps
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
@@ -24,7 +24,7 @@ COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
### ---- Front-end builder dev image ----
|
||||
FROM node:20 as frontend-builder-dev
|
||||
FROM node:20 AS frontend-builder-dev
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -34,12 +34,12 @@ COPY ./src/frontend .
|
||||
WORKDIR ./apps/desk
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-builder-dev as frontend-builder
|
||||
FROM frontend-builder-dev AS frontend-builder
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
@@ -60,7 +60,7 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -72,7 +72,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 as mail-builder
|
||||
FROM node:20 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -83,7 +83,7 @@ RUN yarn install --frozen-lockfile && \
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base as link-collector
|
||||
FROM base AS link-collector
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
# Install libpangocairo & rdfind
|
||||
@@ -108,7 +108,7 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${PEOPLE_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base as core
|
||||
FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
@@ -143,7 +143,7 @@ WORKDIR /app
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core as backend-development
|
||||
FROM core AS backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
@@ -169,7 +169,7 @@ ENV DB_HOST=postgresql \
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
FROM core AS backend-production
|
||||
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
|
||||
@@ -354,10 +354,56 @@ start-kind: ## Create the kubernetes cluster
|
||||
./bin/start-kind.sh
|
||||
.PHONY: start-kind
|
||||
|
||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
./bin/install-external-secrets.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
tilt-up: ## start tilt - k8s local development
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: tilt-up
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
release: ## helper for release and deployment
|
||||
python scripts/release.py
|
||||
.PHONY: release
|
||||
|
||||
install-secret: ## install the kubernetes secrets from Vaultwarden
|
||||
if kubectl -n desk get secrets bitwarden-cli-desk; then \
|
||||
echo "Secret already present"; \
|
||||
else \
|
||||
echo "Please provide the following information:"; \
|
||||
read -p "Enter your vaultwarden email login: " LOGIN; \
|
||||
read -p "Enter your vaultwarden password: " PASSWORD; \
|
||||
read -p "Enter your vaultwarden server url: " URL; \
|
||||
echo "\nCreate vaultwarden secret"; \
|
||||
echo "apiVersion: v1" > /tmp/secret.yaml; \
|
||||
echo "kind: Secret" >> /tmp/secret.yaml; \
|
||||
echo "metadata:" >> /tmp/secret.yaml; \
|
||||
echo " name: bitwarden-cli-desk" >> /tmp/secret.yaml; \
|
||||
echo " namespace: desk" >> /tmp/secret.yaml; \
|
||||
echo "type: Opaque" >> /tmp/secret.yaml; \
|
||||
echo "stringData:" >> /tmp/secret.yaml; \
|
||||
echo " BW_HOST: $$URL" >> /tmp/secret.yaml; \
|
||||
echo " BW_PASSWORD: $$PASSWORD" >> /tmp/secret.yaml; \
|
||||
echo " BW_USERNAME: $$LOGIN" >> /tmp/secret.yaml; \
|
||||
kubectl -n desk apply -f /tmp/secret.yaml;\
|
||||
rm -f /tmp/secret.yaml; \
|
||||
fi; \
|
||||
if kubectl get ns external-secrets; then \
|
||||
echo "External secret already deployed"; \
|
||||
else \
|
||||
helm repo add external-secrets https://charts.external-secrets.io; \
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true; \
|
||||
fi
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
fetch-domain-status:
|
||||
@$(MANAGE) fetch_domain_status
|
||||
.PHONY: fetch-domain-status
|
||||
|
||||
+17
-1
@@ -29,7 +29,9 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
|
||||
# helmfile in docker mount the current working directory and the helmfile.yaml
|
||||
# requires the keycloak config in another directory
|
||||
k8s_yaml(local('cd .. && helmfile -n desk -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
@@ -56,3 +58,17 @@ cmd_button('Migrate db',
|
||||
icon_name='developer_board',
|
||||
text='Run database migration',
|
||||
)
|
||||
|
||||
# Command to created domain/users/access from people to dimail
|
||||
populate_dimail_from_people = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py setup_dimail_db --populate-from-people
|
||||
'''
|
||||
cmd_button('Populate dimail from people',
|
||||
argv=['sh', '-c', populate_dimail_from_people],
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Populate dimail from people',
|
||||
)
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
NAMESPACE=${1:-desk}
|
||||
SECRET_NAME=${2:-bitwarden-cli-desk}
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
|
||||
|
||||
cleanup() {
|
||||
rm -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
# Check if kubectl is available
|
||||
check_prerequisites() {
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "Error: kubectl is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if secret already exists
|
||||
check_secret_exists() {
|
||||
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
|
||||
}
|
||||
|
||||
|
||||
# Collect user input securely
|
||||
get_user_input() {
|
||||
echo "Please provide the following information:"
|
||||
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
echo
|
||||
read -p "Enter your Vaultwarden server url: " URL
|
||||
}
|
||||
|
||||
# Create and apply the secret
|
||||
create_secret() {
|
||||
cat > "${TEMP_SECRET_FILE}" << EOF
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${SECRET_NAME}
|
||||
namespace: ${NAMESPACE}
|
||||
type: Opaque
|
||||
stringData:
|
||||
BW_HOST: ${URL}
|
||||
BW_PASSWORD: ${PASSWORD}
|
||||
BW_USERNAME: ${LOGIN}
|
||||
EOF
|
||||
|
||||
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
|
||||
# Install external-secrets using Helm
|
||||
install_external_secrets() {
|
||||
if ! kubectl get ns external-secrets &>/dev/null; then
|
||||
echo "Installing external-secrets…"
|
||||
helm repo add external-secrets https://charts.external-secrets.io
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true
|
||||
else
|
||||
echo "External secrets already deployed"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
if check_secret_exists; then
|
||||
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e ${TEMP_SECRET_FILE}
|
||||
|
||||
get_user_input
|
||||
echo -e "\nCreating Vaultwarden secret…"
|
||||
create_secret
|
||||
install_external_secrets
|
||||
|
||||
echo "Secret installation completed successfully"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Regular → Executable
+2
-101
@@ -1,102 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -o errexit
|
||||
#!/usr/bin/env bash
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
# 0. Create ca
|
||||
echo "0. Create ca"
|
||||
mkcert -install
|
||||
cd /tmp
|
||||
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
||||
cd $CURRENT_DIR
|
||||
|
||||
# 1. Create registry container unless it already exists
|
||||
echo "1. Create registry container unless it already exists"
|
||||
reg_name='kind-registry'
|
||||
reg_port='5001'
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
|
||||
docker run \
|
||||
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
||||
registry:2
|
||||
fi
|
||||
|
||||
# 2. Create kind cluster with containerd registry config dir enabled
|
||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# TODO: kind will eventually enable this by default and this patch will
|
||||
# be unnecessary.
|
||||
#
|
||||
# See:
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: kindest/node:v1.27.3
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
# 3. Add the registry config to the nodes
|
||||
echo "3. Add the registry config to the nodes"
|
||||
#
|
||||
# This is necessary because localhost resolves to loopback addresses that are
|
||||
# network-namespace local.
|
||||
# In other words: localhost in the container is not localhost on the host.
|
||||
#
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
EOF
|
||||
done
|
||||
|
||||
# 4. Connect the registry to the cluster network if not already connected
|
||||
echo "4. Connect the registry to the cluster network if not already connected"
|
||||
# This allows kind to bootstrap the network but ensures they're on the same network
|
||||
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
|
||||
docker network connect "kind" "${reg_name}"
|
||||
fi
|
||||
|
||||
# 5. Document the local registry
|
||||
echo "5. Document the local registry"
|
||||
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-registry-hosting
|
||||
namespace: kube-public
|
||||
data:
|
||||
localRegistryHosting.v1: |
|
||||
host: "localhost:${reg_port}"
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
EOF
|
||||
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- desk
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ services:
|
||||
|
||||
dimail:
|
||||
entrypoint: /opt/dimail-api/start-dev.sh
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.0.12
|
||||
image: registry.mim-libre.fr/dimail/dimail-api:v0.1.1
|
||||
pull_policy: always
|
||||
environment:
|
||||
DIMAIL_MODE: FAKE
|
||||
|
||||
+103
-47
@@ -58,6 +58,23 @@
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "e2e.marie",
|
||||
"email": "marie.varzy@gmail.com",
|
||||
"firstName": "Marie",
|
||||
"lastName": "Delamairie",
|
||||
"enabled": true,
|
||||
"attributes": {
|
||||
"siret": "21580304000017"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e.marie"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
@@ -101,211 +118,211 @@
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member",
|
||||
"email": "jean.team-member@example.com",
|
||||
"username": "e2e.team-member",
|
||||
"email": "e2e.team-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member"
|
||||
"value": "password-e2e.team-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator",
|
||||
"email": "jean.team-administrator@example.com",
|
||||
"username": "e2e.team-administrator",
|
||||
"email": "e2e.team-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator"
|
||||
"value": "password-e2e.team-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner",
|
||||
"email": "jean.team-owner@example.com",
|
||||
"username": "e2e.team-owner",
|
||||
"email": "e2e.team-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner"
|
||||
"value": "password-e2e.team-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-member",
|
||||
"email": "jean.mail-member@example.com",
|
||||
"username": "e2e.mail-member",
|
||||
"email": "e2e.mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-member"
|
||||
"value": "password-e2e.mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-administrator",
|
||||
"email": "jean.mail-administrator@example.com",
|
||||
"username": "e2e.mail-administrator",
|
||||
"email": "e2e.mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-administrator"
|
||||
"value": "password-e2e.mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.mail-owner",
|
||||
"email": "jean.mail-owner@example.com",
|
||||
"username": "e2e.mail-owner",
|
||||
"email": "e2e.mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.mail-owner"
|
||||
"value": "password-e2e.mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-member",
|
||||
"email": "jean.team-member-mail-member@example.com",
|
||||
"username": "e2e.team-member-mail-member",
|
||||
"email": "e2e.team-member-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-member"
|
||||
"value": "password-e2e.team-member-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-administrator",
|
||||
"email": "jean.team-member-mail-administrator@example.com",
|
||||
"username": "e2e.team-member-mail-administrator",
|
||||
"email": "e2e.team-member-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-administrator"
|
||||
"value": "password-e2e.team-member-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-member-mail-owner",
|
||||
"email": "jean.team-member-mail-owner@example.com",
|
||||
"username": "e2e.team-member-mail-owner",
|
||||
"email": "e2e.team-member-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Member Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-member-mail-owner"
|
||||
"value": "password-e2e.team-member-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-member",
|
||||
"email": "jean.team-administrator-mail-member@example.com",
|
||||
"username": "e2e.team-administrator-mail-member",
|
||||
"email": "e2e.team-administrator-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-member"
|
||||
"value": "password-e2e.team-administrator-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-administrator",
|
||||
"email": "jean.team-administrator-mail-administrator@example.com",
|
||||
"username": "e2e.team-administrator-mail-administrator",
|
||||
"email": "e2e.team-administrator-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-administrator"
|
||||
"value": "password-e2e.team-administrator-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-administrator-mail-owner",
|
||||
"email": "jean.team-administrator-mail-owner@example.com",
|
||||
"username": "e2e.team-administrator-mail-owner",
|
||||
"email": "e2e.team-administrator-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Administrator Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-administrator-mail-owner"
|
||||
"value": "password-e2e.team-administrator-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-member",
|
||||
"email": "jean.team-owner-mail-member@example.com",
|
||||
"username": "e2e.team-owner-mail-member",
|
||||
"email": "e2e.team-owner-mail-member@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Member",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-member"
|
||||
"value": "password-e2e.team-owner-mail-member"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-administrator",
|
||||
"email": "jean.team-owner-mail-administrator@example.com",
|
||||
"username": "e2e.team-owner-mail-administrator",
|
||||
"email": "e2e.team-owner-mail-administrator@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Group Owner Mailbox Administrator",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-administrator"
|
||||
"value": "password-e2e.team-owner-mail-administrator"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "jean.team-owner-mail-owner",
|
||||
"email": "jean.team-owner-mail-owner@example.com",
|
||||
"username": "e2e.team-owner-mail-owner",
|
||||
"email": "e2e.team-owner-mail-owner@example.com",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Mailbox Owner",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-jean.team-owner-mail-owner"
|
||||
"value": "password-e2e.team-owner-mail-owner"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
@@ -695,9 +712,17 @@
|
||||
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false,
|
||||
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
|
||||
"scopeMappings": [
|
||||
{
|
||||
"clientScope": "siret",
|
||||
"roles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientScope": "offline_access",
|
||||
"roles": ["offline_access"]
|
||||
"roles": [
|
||||
"offline_access"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopeMappings": {
|
||||
@@ -947,6 +972,7 @@
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"siret",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
@@ -1107,6 +1133,35 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eb220fbb-02ac-4105-95a3-727954f6565d",
|
||||
"name": "siret",
|
||||
"description": "siret",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "false",
|
||||
"gui.order": ""
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"id": "333a4e89-9363-4c36-b56f-79c6b019c6c6",
|
||||
"name": "siret",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"aggregate.attrs": "false",
|
||||
"userinfo.token.claim": "true",
|
||||
"multivalued": "false",
|
||||
"user.attribute": "siret",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "siret"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "af52ccc3-4ecb-49b4-9a67-5d4172f16070",
|
||||
"name": "role_list",
|
||||
@@ -1573,7 +1628,8 @@
|
||||
"email",
|
||||
"roles",
|
||||
"web-origins",
|
||||
"acr"
|
||||
"acr",
|
||||
"siret"
|
||||
],
|
||||
"defaultOptionalClientScopes": [
|
||||
"offline_access",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Local development with Kubernetes
|
||||
|
||||
We use tilt to provide a local development environment for Kubernetes.
|
||||
Tilt is a tool that helps you develop applications for Kubernetes.
|
||||
It watches your files for changes, rebuilds your containers, and restarts your pods.
|
||||
It's like having a conversation with your cluster.
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This guide assumes you have the following tools installed:
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/)
|
||||
- [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/)
|
||||
* [mkcert](https://github.com/FiloSottile/mkcert)
|
||||
- [ctlptl](https://github.com/tilt-dev/ctlptl)
|
||||
- [Tilt](https://docs.tilt.dev/install.html)
|
||||
* [helm](https://helm.sh/docs/intro/install/)
|
||||
* [helmfile](https://github.com/helmfile/helmfile)
|
||||
* [secrets](https://github.com/jkroepke/helm-secrets/wiki/Installation)
|
||||
* [sops](https://github.com/getsops/sops)
|
||||
|
||||
|
||||
### SOPS configuration
|
||||
|
||||
**Generate a SOPS key**
|
||||
|
||||
For this specific step you need to have the `age-keygen` tool installed.
|
||||
See https://github.com/FiloSottile/age.
|
||||
Then generate a key:
|
||||
|
||||
```bash
|
||||
age-keygen -o my-age.key
|
||||
```
|
||||
|
||||
**Install the SOPS key**
|
||||
|
||||
Read the SOPS documentation on how to install the key in your environment.
|
||||
https://github.com/getsops/sops?tab=readme-ov-file#22encrypting-using-age
|
||||
|
||||
On Ubuntu it's like:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/sops/age/
|
||||
cp my-age.key ~/.config/sops/age/keys.txt
|
||||
chmod 400 ~/.config/sops/age/keys.txt
|
||||
```
|
||||
|
||||
**Add the SOPS key to the repository**
|
||||
|
||||
Update the [.sops.yaml](../.sops.yaml) file with the **public** key id you generated.
|
||||
|
||||
|
||||
### Helmfile in Docker
|
||||
|
||||
If you use helmfile in Docker, you may need an additional configuration to make
|
||||
it work with you age key.
|
||||
|
||||
You need to mount `-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/"`
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
|
||||
docker run --rm --net=host \
|
||||
-v "${HOME}/.kube:/root/.kube" \
|
||||
-v "${HOME}/.config/helm:/root/.config/helm" \
|
||||
-v "${HOME}/.config/sops/age/:/helm/.config/sops/age/" \
|
||||
-v "${HOME}/.minikube:/${HOME}/.minikube" \
|
||||
-v "${PWD}:/wd" \
|
||||
-e KUBECONFIG=/root/.kube/config \
|
||||
--workdir /wd ghcr.io/helmfile/helmfile:v0.150.0 helmfile "$@"
|
||||
```
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
### Create the kubernetes cluster
|
||||
|
||||
Run the following command to create a kubernetes cluster using kind:
|
||||
|
||||
```bash
|
||||
./bin/start-kind.sh
|
||||
```
|
||||
|
||||
**or** run the equivalent using the makefile
|
||||
|
||||
```bash
|
||||
make start-kind
|
||||
```
|
||||
|
||||
### [Optional] Install the secret
|
||||
|
||||
You don't need to do this if you are running the stack with keycloak.
|
||||
|
||||
```bash
|
||||
make install-external-secrets
|
||||
```
|
||||
|
||||
### Deploy the application
|
||||
|
||||
```bash
|
||||
# Pro Connect environment
|
||||
tilt up -f ./bin/Tiltfile
|
||||
|
||||
# Standalone environment with keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
```
|
||||
|
||||
**or** run the equivalent using the makefile
|
||||
|
||||
```bash
|
||||
# Pro Connect environment
|
||||
make tilt-up
|
||||
|
||||
# Standalone environment with keycloak
|
||||
make tilt-up-keycloak
|
||||
```
|
||||
|
||||
That's it! You should now have a local development environment for Kubernetes.
|
||||
|
||||
You can access the application at https://desk.127.0.0.1.nip.io
|
||||
|
||||
## Management
|
||||
|
||||
To manage the cluster, you can use k9s.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Add dimail to the local development environment
|
||||
- Add a reset demo `cmd_button` to Tilt
|
||||
+103
-73
@@ -6,16 +6,56 @@ interact efficiently.
|
||||
|
||||
Let's see how we could interact with Django's shell to recreate David's environment in the app.
|
||||
|
||||
## Base contacts from the organization records
|
||||
## Profile contact
|
||||
|
||||
David Bowman is an exemplary employee at Space Odyssey Corporation. His email is
|
||||
`david.bowman@spaceodyssey.com` and he is registered in the organization's records via a base
|
||||
contact as follows:
|
||||
The system identifies Dave through the `sub` associated with his OIDC session.
|
||||
The OIDC should also provide his email address.
|
||||
|
||||
Note: as some Identity Providers do not provide a constant `sub`, the system should be able to
|
||||
fallback to the email address to find it the user is already in the database.
|
||||
|
||||
When Dave logs in for the first time, the system checks if he is already in the database. If not
|
||||
a new user is created for him.
|
||||
|
||||
Users needs to be linked to an Organization (see [organizations.md](./organizations.md)).
|
||||
|
||||
```python
|
||||
david_base_contact = Contact.objects.create(
|
||||
space_odyssey, _created = Organization.objects.get_or_create(...)
|
||||
|
||||
david_user = User.objects.create(
|
||||
organization=space_odyssey,
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
name="David Bowman",
|
||||
sub="1234567890",
|
||||
email="david.bowman@spaceodyssey.com",
|
||||
)
|
||||
```
|
||||
|
||||
The system then creates a profile contact for Dave, based on the information provided by the OIDC:
|
||||
|
||||
```python
|
||||
david_profile_contact = Contact.objects.create(
|
||||
owner=david_user,
|
||||
user=david_user, # this means it's the user's profile contact
|
||||
full_name="David Bowman",
|
||||
short_name="David",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Future feature:**
|
||||
Dave will be prompted to confirm the details of the base contact stored in the database
|
||||
and fill the details for a better profile.
|
||||
|
||||
His profile contact will be updated with the new information.
|
||||
|
||||
```python
|
||||
Contact.objects.filter(user=david_user, owner=david_user).update(
|
||||
short_name="Dave",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
|
||||
@@ -48,91 +88,81 @@ david_base_contact = Contact.objects.create(
|
||||
)
|
||||
```
|
||||
|
||||
When David logs-in to the People application for the first time using the corporation's OIDC
|
||||
Single Sign-On service. A user is created for him on the fly by the system, together with an
|
||||
identity record representing the OIDC session:
|
||||
David's profile contact is available to all users of his company/organization.
|
||||
|
||||
```python
|
||||
david_user = User.objects.create(
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
)
|
||||
david_identity = Identity.objects.create(
|
||||
"user": david_user,
|
||||
"sub": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"email" : "david.bowman@spaceodyssey.com",
|
||||
"is_main": True,
|
||||
)
|
||||
```
|
||||
|
||||
## Profile contact
|
||||
|
||||
The system identifies Dave through the email associated with his OIDC session and prompts him to
|
||||
confirm the details of the base contact stored in the database.
|
||||
|
||||
When David confirms, giving an alternative short name that he prefers, a contact override is
|
||||
created on top of the organization's base contact. This new contact is marked as David's profile
|
||||
on the user:
|
||||
|
||||
```python
|
||||
david_contact = Contact.objects.create(
|
||||
base=david_base_contact,
|
||||
owner=david_user,
|
||||
full_name="David Bowman",
|
||||
short_name="Dave",
|
||||
data={}
|
||||
)
|
||||
david_user.profile_contact = david_contact
|
||||
david_user.save()
|
||||
```
|
||||
|
||||
If Dave had not had any existing contact in the organization's records, the profile contact would
|
||||
have been created independently, without any connection to a base contact:
|
||||
|
||||
```python
|
||||
david_contact = Contact.objects.create(
|
||||
base=None,
|
||||
owner=david_user,
|
||||
full_name="David Bowman",
|
||||
short_name="Dave",
|
||||
data={}
|
||||
)
|
||||
```
|
||||
|
||||
Now, Dave feels like sharing his mobile phone number with his colleagues. He can do this
|
||||
by editing his contact in the application:
|
||||
|
||||
```python
|
||||
contact.data["phones"] = [
|
||||
{"type": "Mobile", "value": "(123) 456-7890"},
|
||||
]
|
||||
contact.save()
|
||||
```
|
||||
|
||||
## Contact override
|
||||
|
||||
During a Space conference he attended, Dave met Dr Ryan Stone, a medical engineer who gave him
|
||||
her professional email address. Ryan is already present in the system but her email is missing.
|
||||
her personal email address (because she will quit the company she works for). Ryan is already
|
||||
present in the system.
|
||||
|
||||
Dave can add it to his private version of the contact:
|
||||
|
||||
```python
|
||||
ryan_base_contact = Contact.objects.create(
|
||||
full_name="Ryan Stone",
|
||||
data={}
|
||||
ryan_user = User.objects.create(
|
||||
organization=space_odyssey,
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
name="Ryan Stone",
|
||||
sub="0987654321",
|
||||
email="ryan.stone@spaceodyssey.com",
|
||||
)
|
||||
ryan_contact = Contact.objects.create(
|
||||
base=ryan_base_contact,
|
||||
owner=david_user,
|
||||
|
||||
ryan_profile_contact = Contact.objects.create(
|
||||
user=ryan_user,
|
||||
owner=ryan_user,
|
||||
full_name="Ryan Stone",
|
||||
short_name="Dr Ryan",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "ryan.stone@hubblestation.com"},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
david_ryan_contact = Contact.objects.create(
|
||||
override=ryan_profile_contact,
|
||||
owner=david_user,
|
||||
full_name="Ryan Stone",
|
||||
short_name="Dr Ryan",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Home", "value": "ryan@stone.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Personal contacts
|
||||
|
||||
Dave met a lot of people during the last Opensource Experience event. He can add them to his personal contacts:
|
||||
|
||||
```python
|
||||
julie_personal_contact = Contact.objects.create(
|
||||
owner=david_user,
|
||||
full_name="Julie Powell",
|
||||
short_name="Julie",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "julie.powell@example.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
These contacts are only available to Dave.
|
||||
|
||||
|
||||
## Contacts from shared directories
|
||||
|
||||
This is a future feature that will allow Dave to access contacts from shared directories.
|
||||
Shared directories will provide contacts for all users from several organizations.
|
||||
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
Dave wants to form a team with Ryan and other colleagues to work together better on using the organization's digital tools for their projects.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Organization model
|
||||
|
||||
## Purpose
|
||||
|
||||
The initial `Organization` model allows to regroup `User` and `Team` under a same object.
|
||||
|
||||
While the purpose was purely technical in the first place, few features emerged afterward.
|
||||
|
||||
|
||||
## Link with the `User` model
|
||||
|
||||
Each user must have a "default" organization.
|
||||
|
||||
When a user logs in:
|
||||
|
||||
- The backend tries to get an existing organization from a "registration ID" or from the user's mail domain
|
||||
- If the organization does not exist, it is created using the "registration ID" and fallbacks on the user's mail domain
|
||||
- The user organization is set to this organization.
|
||||
|
||||
**Note:** While deploying the new code, we allow the already existing user to not have one.
|
||||
|
||||
|
||||
## Link with the `Team` model
|
||||
|
||||
Each team must have an organization. This organization is not defined by the user, but automatically set
|
||||
using the user's organization.
|
||||
|
||||
The team's organization does not restrict the users who can be added to the team.
|
||||
|
||||
There is currently no feature relying on the team organization.
|
||||
|
||||
**Note:** While deploying the new code, we allow the teams to not have one.
|
||||
|
||||
|
||||
## Organization-related features
|
||||
|
||||
### Organization UI
|
||||
|
||||
A new interface can be created to allow users to see all members of an organization.
|
||||
This could be used along with the contacts.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ServiceProvider model
|
||||
|
||||
## Purpose
|
||||
|
||||
The `ServiceProvider` model represents a ... service provider, also known as "tools using some data from this project".
|
||||
|
||||
|
||||
## Link with the `Organization` model
|
||||
|
||||
An organization can be linked to several service providers.
|
||||
The goal here, is to allow users of an organization to have access, to a service provider or not.
|
||||
The first implementation does not have any feature related, but the first feature will probably be
|
||||
to list applications visible in the "all application menu" (aka "la gaufre").
|
||||
|
||||
|
||||
## Link with the `Team` model
|
||||
|
||||
A team can be linked to several service providers.
|
||||
This is used as a filter when a service provider calls the resource server, only the teams linked to
|
||||
this service provider are returned. This is mandatory for data segregation: we don't want all service
|
||||
providers to be able to list all data regarding other service providers.
|
||||
|
||||
|
||||
## Limitations
|
||||
|
||||
There is currently no way to provision all the service providers automatically. So when a service provider
|
||||
creates a team via the resource server, we create the `ServiceProvider` on the fly, without any understandable name.
|
||||
This will need to be improved later.
|
||||
+5
-5
@@ -19,18 +19,18 @@ A new browser window will open and you will be able to run the tests.
|
||||
## Available accounts
|
||||
|
||||
The `make demo` command creates the following accounts:
|
||||
- `jean.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
- `e2e.team-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only belong to a team with the specified role.
|
||||
- `jean.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
- `e2e.mail-<role>@example.com` where `<role>` is one of `administrator`, `member`, `owner`:
|
||||
this account only have a mailbox with the specified role access.
|
||||
- `jean.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
|
||||
- `e2e.team-<team_role>-mail-<domain_role>@example.com` with a combination of roles as for the
|
||||
previous accounts.
|
||||
|
||||
For each account, the password is `password-e2e-<username>`, for instance `password-e2e-jean.team-member`.
|
||||
For each account, the password is `password-e2e.<role>`, for instance `password-e2e.team-member`.
|
||||
|
||||
In the E2E tests you can use these accounts to benefit from there accesses,
|
||||
using the `keyCloakSignIn(page, browserName, <account_name>)`. The account name is the
|
||||
username without the prefix `jean.`.
|
||||
username without the prefix `e2e.`.
|
||||
|
||||
``` typescript jsx
|
||||
await keyCloakSignIn(page, browserName, 'mail-owner');
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
cat <<'EOF' >$PRE_COMMIT_FILE
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# directories containing potential secrets
|
||||
DIRS="."
|
||||
|
||||
+67
-26
@@ -24,18 +24,6 @@ def update_files(version):
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
# helm files
|
||||
sys.stdout.write("Update helm files...\n")
|
||||
for env in ["preprod", "production"]:
|
||||
path = f"src/helm/env.d/{env}/values.desk.yaml.gotmpl"
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "tag:" in line:
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
# frontend package.json
|
||||
sys.stdout.write("Update package.json...\n")
|
||||
files_to_modify = []
|
||||
@@ -55,6 +43,17 @@ def update_files(version):
|
||||
return
|
||||
|
||||
|
||||
def update_helm_files(path):
|
||||
sys.stdout.write("Update helm files...\n")
|
||||
with open(path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
for index, line in enumerate(lines):
|
||||
if "tag:" in line:
|
||||
lines[index] = re.sub(r'\"(.*?)\"', f'"v{version}"', line)
|
||||
with open(path, 'w+') as file:
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def update_changelog(path, version):
|
||||
"""Update changelog file with release info
|
||||
"""
|
||||
@@ -77,7 +76,43 @@ def update_changelog(path, version):
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def prepare_release(version, kind):
|
||||
def deployment_part(version, kind):
|
||||
""" Update helm file of preprod on deployment repository numerique-gouv/lasuite-deploiement
|
||||
"""
|
||||
# Move to lasuite-deploiement repository
|
||||
try:
|
||||
os.chdir('../lasuite-deploiement')
|
||||
except FileNotFoundError:
|
||||
sys.stdout.write("\033[1;31m[Error] You must have a clone of https://github.com/numerique-gouv/lasuite-deploiement\x1b[0m")
|
||||
sys.stdout.write("\033[0;32mPlease clone it and re-run script with option --only-deployment-part\x1b[0m")
|
||||
sys.stdout.write(" >>> python scripts/release.py --only-deployment-part \x1b[0m")
|
||||
else:
|
||||
run_command("git checkout main", shell=True)
|
||||
run_command("git pull", shell=True)
|
||||
deployment_branch = f"regie/{version}/preprod"
|
||||
run_command(f"git checkout -b {deployment_branch}", shell=True)
|
||||
run_command("git pull --rebase origin main", shell=True)
|
||||
path = f"manifests/regie/env.d/preprod/values.desk.yaml.gotmpl"
|
||||
update_helm_files(path)
|
||||
run_command(f"git add {path}", shell=True)
|
||||
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}"""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"""\033[0;32m
|
||||
### DEPLOYMENT ###
|
||||
NEXT COMMAND on lasuite-deploiement repository:
|
||||
>> git push origin {deployment_branch}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""")
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {deployment_branch}", shell=True)
|
||||
sys.stdout.write(f"""\033[1;34m
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR {deployment_branch} and merge code to main
|
||||
\x1b[0m""")
|
||||
|
||||
|
||||
def project_part(version, kind):
|
||||
"""Manage only la regie part with update of CHANGELOG, version files and tag"""
|
||||
sys.stdout.write('Let\'s go to create branch to release\n')
|
||||
branch_to_release = f"release/{version}"
|
||||
run_command(f"git checkout -b {branch_to_release}", shell=True)
|
||||
@@ -90,25 +125,29 @@ def prepare_release(version, kind):
|
||||
run_command("git add src/", shell=True)
|
||||
message = f"""🔖({RELEASE_KINDS[kind]}) release version {version}
|
||||
|
||||
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
Update all version files and changelog for {RELEASE_KINDS[kind]} release."""
|
||||
run_command(f"git commit -m '{message}'", shell=True)
|
||||
confirm = input(f"\nNEXT COMMAND: \n>> git push origin {branch_to_release}\nContinue ? (y,n) ")
|
||||
confirm = input(f"""\033[0;32m
|
||||
### RELEASE ###
|
||||
NEXT COMMAND on current repository:
|
||||
>> git push origin {branch_to_release}
|
||||
Continue ? (y,n)
|
||||
\x1b[0m""")
|
||||
if confirm == 'y':
|
||||
run_command(f"git push origin {branch_to_release}", shell=True)
|
||||
sys.stdout.write(f"""
|
||||
PLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR and merge code to main
|
||||
--> Then do:
|
||||
\033[1;34mPLEASE DO THE FOLLOWING INSTRUCTIONS:
|
||||
--> Please submit PR {branch_to_release} and merge code to main
|
||||
--> Then do:
|
||||
>> git checkout main
|
||||
>> git pull
|
||||
>> git tag -a v{version}
|
||||
>> git tag v{version}
|
||||
>> git push origin v{version}
|
||||
--> Please check and wait for the docker image v{version} to be published here: https://hub.docker.com/r/lasuite/people-backend/tags
|
||||
>> git tag -d preprod
|
||||
>> git tag preprod
|
||||
>> git push -f origin preprod
|
||||
--> Now please generate release on github interface for the current tag v{version}
|
||||
""")
|
||||
--> Please check and wait for the docker image v{version} to be published here:
|
||||
- https://hub.docker.com/r/lasuite/people-backend/tags
|
||||
- https://hub.docker.com/r/lasuite/people-frontend/tags
|
||||
--> Now please generate release on github interface for the current tag v{version}
|
||||
\x1b[0m""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -117,4 +156,6 @@ if __name__ == "__main__":
|
||||
version = input("Enter your release version:")
|
||||
while kind not in RELEASE_KINDS:
|
||||
kind = input("Enter kind of release (p:patch, m:minor, mj:major):")
|
||||
prepare_release(version, kind)
|
||||
if "--only-deployment-part" not in sys.argv:
|
||||
project_part(version, kind)
|
||||
deployment_part(version, kind)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People custom admin site."""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Custom Django admin site application configuration."""
|
||||
|
||||
from django.contrib.admin.apps import AdminConfig
|
||||
|
||||
|
||||
class PeopleAdminConfig(AdminConfig):
|
||||
"""Declare our custom Django admin site."""
|
||||
|
||||
default_site = "admin.sites.PeopleAdminSite"
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Custom Django admin site for the People app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
class PeopleAdminSite(admin.AdminSite):
|
||||
"""People custom admin site."""
|
||||
|
||||
def each_context(self, request):
|
||||
"""Add custom context to the admin site."""
|
||||
return super().each_context(request) | {
|
||||
"ADMIN_HEADER_BACKGROUND": settings.ADMIN_HEADER_BACKGROUND,
|
||||
"ADMIN_HEADER_COLOR": settings.ADMIN_HEADER_COLOR,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block extrastyle %}{{ block.super }}
|
||||
<style>
|
||||
html[data-theme="light"], :root {
|
||||
{% if ADMIN_HEADER_BACKGROUND %}--header-bg: {{ ADMIN_HEADER_BACKGROUND }};{% endif %}
|
||||
{% if ADMIN_HEADER_COLOR %}--header-color: {{ ADMIN_HEADER_COLOR }};{% endif %}
|
||||
}
|
||||
ul.messagelist li.info {
|
||||
background-color: #EEEEEE;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,12 +1,19 @@
|
||||
"""Admin classes and registrations for People's core app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from treebeard.admin import TreeAdmin
|
||||
from treebeard.forms import movenodeform_factory
|
||||
|
||||
from mailbox_manager.admin import MailDomainAccessInline
|
||||
|
||||
from . import models
|
||||
from .plugins.loader import (
|
||||
get_organization_plugins,
|
||||
organization_plugins_run_after_create,
|
||||
)
|
||||
|
||||
|
||||
class TeamAccessInline(admin.TabularInline):
|
||||
@@ -49,6 +56,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"id",
|
||||
"sub",
|
||||
"password",
|
||||
"organization",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -108,17 +116,28 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
get_user.short_description = _("User")
|
||||
|
||||
|
||||
class TeamServiceProviderInline(admin.TabularInline):
|
||||
"""Inline admin class for service providers."""
|
||||
|
||||
can_delete = False
|
||||
model = models.Team.service_providers.through
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
class TeamAdmin(TreeAdmin):
|
||||
"""Team admin interface declaration."""
|
||||
|
||||
inlines = (TeamAccessInline, TeamWebhookInline)
|
||||
form = movenodeform_factory(models.Team)
|
||||
inlines = (TeamAccessInline, TeamWebhookInline, TeamServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
list_display = (
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ("path", "depth", "numchild")
|
||||
|
||||
|
||||
@admin.register(models.TeamAccess)
|
||||
@@ -184,7 +203,27 @@ class ContactAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"full_name",
|
||||
"owner",
|
||||
"base",
|
||||
"override",
|
||||
)
|
||||
|
||||
|
||||
class OrganizationServiceProviderInline(admin.TabularInline):
|
||||
"""Inline admin class for service providers."""
|
||||
|
||||
can_delete = False
|
||||
model = models.Organization.service_providers.through
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.action(description=_("Run post creation plugins"), permissions=["change"])
|
||||
def run_post_creation_plugins(modeladmin, request, queryset): # pylint: disable=unused-argument
|
||||
"""Run the post creation plugins for the selected organizations."""
|
||||
for organization in queryset:
|
||||
organization_plugins_run_after_create(organization)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_("Post creation plugins have been run for the selected organizations."),
|
||||
)
|
||||
|
||||
|
||||
@@ -192,13 +231,22 @@ class ContactAdmin(admin.ModelAdmin):
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for organizations."""
|
||||
|
||||
actions = [run_post_creation_plugins]
|
||||
list_display = (
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
inlines = (OrganizationAccessInline,)
|
||||
inlines = (OrganizationAccessInline, OrganizationServiceProviderInline)
|
||||
exclude = ("service_providers",) # Handled by the inline
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Adapt actions list to the context."""
|
||||
actions = super().get_actions(request)
|
||||
if not get_organization_plugins():
|
||||
actions.pop("run_post_creation_plugins", None)
|
||||
return actions
|
||||
|
||||
|
||||
@admin.register(models.OrganizationAccess)
|
||||
@@ -213,3 +261,17 @@ class OrganizationAccessAdmin(admin.ModelAdmin):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.ServiceProvider)
|
||||
class ServiceProviderAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for service providers."""
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"audience_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name", "audience_id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People core client API endpoints"""
|
||||
@@ -4,28 +4,43 @@ from rest_framework import exceptions, serializers
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models
|
||||
from core.models import ServiceProvider
|
||||
|
||||
|
||||
class ContactSerializer(serializers.ModelSerializer):
|
||||
"""Serialize contacts."""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
fields = [
|
||||
"id",
|
||||
"base",
|
||||
"abilities",
|
||||
"override",
|
||||
"data",
|
||||
"full_name",
|
||||
"notes",
|
||||
"owner",
|
||||
"short_name",
|
||||
]
|
||||
read_only_fields = ["id", "owner"]
|
||||
read_only_fields = ["id", "owner", "abilities"]
|
||||
extra_kwargs = {
|
||||
"override": {"required": False},
|
||||
}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "base" field readonly but only for update/patch."""
|
||||
validated_data.pop("base", None)
|
||||
"""Make "override" field readonly but only for update/patch."""
|
||||
validated_data.pop("override", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def get_abilities(self, contact) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return contact.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
@@ -50,12 +65,40 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize organizations."""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
fields = ["id", "name", "registration_id_list", "domain_list", "abilities"]
|
||||
read_only_fields = ["id", "registration_id_list", "domain_list"]
|
||||
|
||||
def get_abilities(self, organization) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return organization.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class UserOrganizationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize organizations for users."""
|
||||
|
||||
class Meta:
|
||||
model = models.Organization
|
||||
fields = ["id", "name", "registration_id_list"]
|
||||
read_only_fields = ["id", "name", "registration_id_list"]
|
||||
|
||||
|
||||
class UserSerializer(DynamicFieldsModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
|
||||
email = serializers.ReadOnlyField()
|
||||
name = serializers.ReadOnlyField()
|
||||
organization = UserOrganizationSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
@@ -64,6 +107,7 @@ class UserSerializer(DynamicFieldsModelSerializer):
|
||||
"email",
|
||||
"language",
|
||||
"name",
|
||||
"organization",
|
||||
"timezone",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
@@ -79,6 +123,7 @@ class UserMeSerializer(UserSerializer):
|
||||
"""
|
||||
|
||||
abilities = serializers.SerializerMethodField()
|
||||
organization = UserOrganizationSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
@@ -89,6 +134,7 @@ class UserMeSerializer(UserSerializer):
|
||||
"is_staff",
|
||||
"language",
|
||||
"name",
|
||||
"organization",
|
||||
"timezone",
|
||||
# added fields
|
||||
"abilities",
|
||||
@@ -205,27 +251,44 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
service_providers = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ServiceProvider.objects.all(), many=True, required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"accesses",
|
||||
"created_at",
|
||||
"depth",
|
||||
"name",
|
||||
"numchild",
|
||||
"path",
|
||||
"service_providers",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"accesses",
|
||||
"created_at",
|
||||
"depth",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a new team with organization enforcement."""
|
||||
# When called as a resource server, we enforce the team service provider
|
||||
if sp_audience := self.context.get("from_service_provider_audience", None):
|
||||
service_provider, _created = models.ServiceProvider.objects.get_or_create(
|
||||
audience_id=sp_audience
|
||||
)
|
||||
validated_data["service_providers"] = [service_provider]
|
||||
|
||||
# Note: this is not the purpose of this API to check the user has an organization
|
||||
return super().create(
|
||||
validated_data=validated_data
|
||||
@@ -273,3 +336,12 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
attrs["team_id"] = team_id
|
||||
attrs["issuer"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class ServiceProviderSerializer(serializers.ModelSerializer):
|
||||
"""Serialize service providers."""
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceProvider
|
||||
fields = ["id", "audience_id", "name"]
|
||||
read_only_fields = ["id", "audience_id"]
|
||||
@@ -1,7 +1,14 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import datetime
|
||||
import operator
|
||||
from functools import reduce
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import OuterRef, Q, Subquery
|
||||
from django.db.models import OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
@@ -17,10 +24,11 @@ from rest_framework import (
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from core import models
|
||||
from core.api import permissions
|
||||
from core.api.client import serializers
|
||||
from core.utils.raw_sql import gen_sql_filter_json_array
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
SIMILARITY_THRESHOLD = 0.04
|
||||
from mailbox_manager import models as domains_models
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
@@ -135,31 +143,46 @@ class ContactViewSet(
|
||||
):
|
||||
"""Contact ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsOwnedOrPublic]
|
||||
queryset = models.Contact.objects.all()
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.Contact.objects.select_related("user").all()
|
||||
serializer_class = serializers.ContactSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
ordering_fields = ["full_name", "short_name", "created_at"]
|
||||
ordering = ["full_name"]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed users by a query with throttle protection."""
|
||||
user = self.request.user
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
# Exclude contacts that:
|
||||
# List only contacts that:
|
||||
queryset = queryset.filter(
|
||||
# - belong to another user (keep public and owned contacts)
|
||||
Q(owner__isnull=True) | Q(owner=user),
|
||||
# - are profile contacts for a user
|
||||
user__isnull=True,
|
||||
# - are overriden base contacts
|
||||
overriding_contacts__isnull=True,
|
||||
# - is public (owner is None)
|
||||
Q(owner__isnull=True)
|
||||
# - are owned by the user
|
||||
| Q(owner=user)
|
||||
# - are profile contacts for a user from the same organization
|
||||
| Q(user__organization_id=user.organization_id),
|
||||
# - are not overriden by another contact
|
||||
overridden_by__isnull=True,
|
||||
)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive similarity
|
||||
# Search by case-insensitive and accent-insensitive on:
|
||||
# - full name
|
||||
# - short name
|
||||
# - email (from data `emails` field)
|
||||
if query := self.request.GET.get("q", ""):
|
||||
queryset = queryset.filter(
|
||||
Q(full_name__unaccent__icontains=query)
|
||||
| Q(short_name__unaccent__icontains=query)
|
||||
| Q(
|
||||
id__in=gen_sql_filter_json_array(
|
||||
queryset.model,
|
||||
"data->'emails'",
|
||||
"value",
|
||||
query,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
@@ -172,6 +195,45 @@ class ContactViewSet(
|
||||
return super().perform_create(serializer)
|
||||
|
||||
|
||||
class OrganizationViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
Organization ViewSet
|
||||
|
||||
GET /api/organizations/<organization_id>/
|
||||
Return the organization details for the given id.
|
||||
|
||||
PUT /api/organizations/<organization_id>/
|
||||
Update the organization details for the given id.
|
||||
|
||||
PATCH /api/organizations/<organization_id>/
|
||||
Partially update the organization details for the given id.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.Organization.objects.all()
|
||||
serializer_class = serializers.OrganizationSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Limit listed organizations to the one the user belongs to."""
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(pk=self.request.user.organization_id)
|
||||
.annotate(
|
||||
user_role=Subquery(
|
||||
models.OrganizationAccess.objects.filter(
|
||||
user=self.request.user, organization=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
SerializerPerActionMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -186,7 +248,9 @@ class UserViewSet(
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().order_by("-created_at")
|
||||
queryset = (
|
||||
models.User.objects.select_related("organization").all().order_by("-created_at")
|
||||
)
|
||||
serializer_class = serializers.UserSerializer
|
||||
get_me_serializer_class = serializers.UserMeSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
@@ -239,21 +303,57 @@ class TeamViewSet(
|
||||
):
|
||||
"""Team ViewSet"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
permission_classes = [permissions.TeamPermission, permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering_fields = ["created_at", "name", "path"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
teams_queryset = models.Team.objects.filter(
|
||||
accesses__user=self.request.user,
|
||||
)
|
||||
depth_path = teams_queryset.values("depth", "path")
|
||||
|
||||
if not depth_path:
|
||||
return models.Team.objects.none()
|
||||
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
|
||||
user_role=Subquery(user_role_query)
|
||||
|
||||
return (
|
||||
models.Team.objects.prefetch_related("accesses", "service_providers")
|
||||
.filter(
|
||||
reduce(
|
||||
operator.or_,
|
||||
(
|
||||
Q(
|
||||
# The team the user has access to
|
||||
depth=d["depth"],
|
||||
path=d["path"],
|
||||
)
|
||||
| Q(
|
||||
# The parent team the user has access to
|
||||
depth__lt=d["depth"],
|
||||
path__startswith=d["path"][: models.Team.steplen],
|
||||
organization_id=self.request.user.organization_id,
|
||||
)
|
||||
for d in depth_path
|
||||
),
|
||||
),
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role for the team
|
||||
# and if the user does not have access, it's ok to consider them as a member
|
||||
# because it's a parent team.
|
||||
.annotate(
|
||||
user_role=Coalesce(
|
||||
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -489,3 +589,73 @@ class ConfigView(views.APIView):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
return response.Response(dict_settings)
|
||||
|
||||
|
||||
class StatView(views.APIView):
|
||||
"""API ViewSet for sharing some public metrics."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@method_decorator(cache_page(3600))
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/stats/
|
||||
Return a dictionary of public metrics.
|
||||
"""
|
||||
context = {
|
||||
"total_users": models.User.objects.count(),
|
||||
"mau": models.User.objects.filter(
|
||||
last_login__gte=datetime.datetime.now() - datetime.timedelta(30)
|
||||
).count(),
|
||||
"teams": models.Team.objects.count(),
|
||||
"domains": domains_models.MailDomain.objects.count(),
|
||||
"mailboxes": domains_models.Mailbox.objects.count(),
|
||||
}
|
||||
return response.Response(context)
|
||||
|
||||
|
||||
class ServiceProviderFilter(filters.BaseFilterBackend):
|
||||
"""
|
||||
Filter service providers.
|
||||
"""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
"""
|
||||
Filter service providers by audience or name.
|
||||
"""
|
||||
if name := request.GET.get("name"):
|
||||
queryset = queryset.filter(name__icontains=name)
|
||||
if audience_id := request.GET.get("audience_id"):
|
||||
queryset = queryset.filter(audience_id=audience_id)
|
||||
return queryset
|
||||
|
||||
|
||||
class ServiceProviderViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with service providers.
|
||||
|
||||
GET /api/v1.0/service-providers/
|
||||
Return a list of service providers.
|
||||
|
||||
GET /api/v1.0/service-providers/<service_provider_id>/
|
||||
Return a service provider.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = models.ServiceProvider.objects.all()
|
||||
serializer_class = serializers.ServiceProviderSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
filter_backends = [filters.OrderingFilter, ServiceProviderFilter]
|
||||
ordering = ["name"]
|
||||
ordering_fields = ["name", "created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter the queryset to limit results to user's organization."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(organizations__id=self.request.user.organization_id)
|
||||
return queryset
|
||||
@@ -53,3 +53,18 @@ class AccessPermission(IsAuthenticated):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
|
||||
class TeamPermission(IsAuthenticated):
|
||||
"""Permission class for team objects viewset."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check permission only when the user tries to create a new team."""
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
if request.method != "POST":
|
||||
return True
|
||||
|
||||
abilities = request.user.get_abilities()
|
||||
return abilities["teams"]["can_create"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""People core resource server API endpoints"""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Client serializers for the People core app resource server API."""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"depth",
|
||||
"name",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"depth",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
Create a new team with organization enforcement.
|
||||
|
||||
In this context, called as a resource server,
|
||||
the team service provider is enforced.
|
||||
|
||||
When the service provider audience is unknown it is created on the fly.
|
||||
"""
|
||||
sp_audience = self.context["from_service_provider_audience"]
|
||||
service_provider, _created = models.ServiceProvider.objects.get_or_create(
|
||||
audience_id=sp_audience
|
||||
)
|
||||
|
||||
# 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,
|
||||
"service_providers": [service_provider],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Resource server API endpoints"""
|
||||
|
||||
import operator
|
||||
from functools import reduce
|
||||
|
||||
from django.db.models import OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from rest_framework import (
|
||||
filters,
|
||||
mixins,
|
||||
viewsets,
|
||||
)
|
||||
|
||||
from core import models
|
||||
from core.api import permissions
|
||||
from core.api.client.viewsets import Pagination
|
||||
from core.resource_server.mixins import ResourceServerMixin
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
class TeamViewSet( # pylint: disable=too-many-ancestors
|
||||
ResourceServerMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
Team ViewSet dedicated to the resource server.
|
||||
|
||||
The DELETE method is not allowed for now, because the use case is
|
||||
not clear yet and it comes with complexity to know if we can delete
|
||||
a team or not (eg. if a team has other SP, it might not be deleted
|
||||
but what do we do then, only remove the current SP?).
|
||||
|
||||
GET /resource-server/v1.0/teams/
|
||||
Return list of Teams of the user and available for the audience.
|
||||
|
||||
POST /resource-server/v1.0/teams/
|
||||
Create a new Team for the user for the audience.
|
||||
|
||||
GET /resource-server/v1.0/teams/{team_id}/
|
||||
Return the Team details if available for the audience.
|
||||
|
||||
PUT /resource-server/v1.0/teams/{team_id}/
|
||||
Update the Team details (only name for now).
|
||||
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
pagination_class = Pagination
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related teams."""
|
||||
teams_queryset = models.Team.objects.filter(
|
||||
accesses__user=self.request.user,
|
||||
)
|
||||
depth_path = teams_queryset.values("depth", "path")
|
||||
|
||||
if not depth_path:
|
||||
return models.Team.objects.none()
|
||||
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
|
||||
service_provider_audience = self._get_service_provider_audience()
|
||||
service_provider_prefetch = Prefetch(
|
||||
"service_providers",
|
||||
queryset=models.ServiceProvider.objects.filter(
|
||||
audience_id=service_provider_audience
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
models.Team.objects.prefetch_related(
|
||||
"accesses",
|
||||
service_provider_prefetch,
|
||||
)
|
||||
.filter(
|
||||
reduce(
|
||||
operator.or_,
|
||||
(
|
||||
Q(
|
||||
# The team the user has access to
|
||||
depth=d["depth"],
|
||||
path=d["path"],
|
||||
)
|
||||
| Q(
|
||||
# The parent team the user has access to
|
||||
depth__lt=d["depth"],
|
||||
path__startswith=d["path"][: models.Team.steplen],
|
||||
organization_id=self.request.user.organization_id,
|
||||
)
|
||||
for d in depth_path
|
||||
),
|
||||
),
|
||||
service_providers__audience_id=service_provider_audience,
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role for the team
|
||||
# and if the user does not have access, it's ok to consider them as a member
|
||||
# because it's a parent team.
|
||||
.annotate(
|
||||
user_role=Coalesce(
|
||||
Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created team."""
|
||||
team = serializer.save()
|
||||
models.TeamAccess.objects.create(
|
||||
team=team,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
@@ -14,7 +14,12 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import Organization, OrganizationAccess, OrganizationRoleChoices
|
||||
from core.models import (
|
||||
Contact,
|
||||
Organization,
|
||||
OrganizationAccess,
|
||||
OrganizationRoleChoices,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -196,6 +201,19 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
user=user,
|
||||
role=OrganizationRoleChoices.ADMIN,
|
||||
)
|
||||
|
||||
# Initiate the user's profile
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
user=user,
|
||||
full_name=name or email,
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": email},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
|
||||
@@ -115,8 +115,27 @@ class ContactFactory(BaseContactFactory):
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
base = factory.SubFactory("core.factories.ContactFactory", base=None, owner=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory")
|
||||
|
||||
|
||||
class ProfileContactFactory(BaseContactFactory):
|
||||
"""A factory to create profile contacts for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
owner = factory.SelfAttribute(".user")
|
||||
user = factory.SubFactory("core.factories.UserFactory")
|
||||
|
||||
|
||||
class OverrideContactFactory(BaseContactFactory):
|
||||
"""A factory to create contacts for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
override = factory.SubFactory("core.factories.ContactFactory", owner=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory")
|
||||
|
||||
|
||||
class OrganizationFactory(factory.django.DjangoModelFactory):
|
||||
@@ -138,6 +157,22 @@ class OrganizationFactory(factory.django.DjangoModelFactory):
|
||||
)
|
||||
|
||||
|
||||
class OrganizationAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory to create organization accesses for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.OrganizationAccess
|
||||
|
||||
user = factory.SubFactory(
|
||||
"core.factories.UserFactory",
|
||||
organization=factory.SelfAttribute("..organization"),
|
||||
)
|
||||
organization = factory.SubFactory(
|
||||
"core.factories.OrganizationFactory", with_registration_id=True
|
||||
)
|
||||
role = factory.fuzzy.FuzzyChoice(models.OrganizationRoleChoices.values)
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create random users for testing purposes."""
|
||||
|
||||
@@ -164,7 +199,6 @@ class TeamFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
django_get_or_create = ("name",)
|
||||
skip_postgeneration_save = True
|
||||
|
||||
name = factory.Sequence(lambda n: f"team{n}")
|
||||
@@ -180,6 +214,13 @@ class TeamFactory(factory.django.DjangoModelFactory):
|
||||
else:
|
||||
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])
|
||||
|
||||
@factory.post_generation
|
||||
def service_providers(self, create, extracted, **kwargs):
|
||||
"""Add service providers to team from a given list of service providers."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.service_providers.set(extracted)
|
||||
|
||||
|
||||
class TeamAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake team user accesses for testing."""
|
||||
@@ -212,3 +253,27 @@ class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
email = factory.Faker("email")
|
||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||
issuer = factory.SubFactory(UserFactory)
|
||||
|
||||
|
||||
class ServiceProviderFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create service providers for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceProvider
|
||||
skip_postgeneration_save = True
|
||||
|
||||
audience_id = factory.Faker("uuid4")
|
||||
|
||||
@factory.post_generation
|
||||
def teams(self, create, extracted, **kwargs):
|
||||
"""Add teams to service provider from a given list."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.teams.set(extracted)
|
||||
|
||||
@factory.post_generation
|
||||
def organizations(self, create, extracted, **kwargs):
|
||||
"""Add organization to service provider from a given list."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
self.organizations.set(extracted)
|
||||
|
||||
@@ -48,7 +48,7 @@ class Migration(migrations.Migration):
|
||||
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
|
||||
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('language', models.CharField(choices=settings.LANGUAGES, default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
|
||||
@@ -22,8 +22,8 @@ class Migration(migrations.Migration):
|
||||
('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')),
|
||||
('registration_id_list', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=128), blank=True, default=list, size=None, 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, verbose_name='domain list')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'organization',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-07 16:24
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_remove_team_slug'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ServiceProvider',
|
||||
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=256, unique=True, verbose_name='name')),
|
||||
('audience_id', models.CharField(db_index=True, max_length=256, unique=True, verbose_name='audience id')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'service provider',
|
||||
'verbose_name_plural': 'service providers',
|
||||
'db_table': 'people_service_provider',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='service_providers',
|
||||
field=models.ManyToManyField(blank=True, related_name='organizations', to='core.serviceprovider'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='service_providers',
|
||||
field=models.ManyToManyField(blank=True, related_name='teams', to='core.serviceprovider'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-28 09:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_add_serviceprovider'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, default='', verbose_name='notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='contact',
|
||||
name='full_name',
|
||||
field=models.CharField(default='-', max_length=150, verbose_name='full name'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 10:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_contact_notes_alter_contact_full_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='base_owner_constraint',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='base_not_self',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='override',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='overridden_by', to='core.contact'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('override__isnull', False), ('owner__isnull', True), _negated=True), name='override_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('override', models.F('id')), _negated=True), name='override_not_self', violation_error_message='A contact cannot override itself.'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contact',
|
||||
name='base',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together={('owner', 'override')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 10:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def init_profile_contact(apps, schema_editor):
|
||||
"""Create a Contact for each User to be used as their profile."""
|
||||
User = apps.get_model("core", "User")
|
||||
Contact = apps.get_model("core", "Contact")
|
||||
|
||||
for user in User.objects.all(): # This is a small table for now
|
||||
Contact.objects.create(
|
||||
owner=user,
|
||||
user=user,
|
||||
full_name=user.name or user.email,
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": user.email},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_rename_contact_base_to_override'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='profile_contact',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='user',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='profile_contact', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('user__isnull', True), models.Q(('owner', models.F('user')), ('owner__isnull', False)), _connector='OR'), name='profile_contact_owner_constraint', violation_error_message='Users can only declare as profile a contact they own.'),
|
||||
),
|
||||
migrations.RunPython(init_profile_contact, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-05 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_change_user_profile_to_contact'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# From 100ms to 78ms for 5 000 contacts on my computer
|
||||
# This is not a big improvement and may need further investigation
|
||||
migrations.RunSQL(
|
||||
"CREATE INDEX json_field_index_emails_value ON people_contact USING GIN ((data->'emails'->'value') jsonb_path_ops);"
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-26 13:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from treebeard.numconv import NumConv
|
||||
|
||||
|
||||
def update_team_paths(apps, schema_editor):
|
||||
Team = apps.get_model("core", "Team")
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
steplen = 5
|
||||
|
||||
# Initialize NumConv with the specified custom alphabet
|
||||
converter = NumConv(len(alphabet), alphabet)
|
||||
|
||||
nodes = Team.objects.all().order_by("created_at")
|
||||
for i, node in enumerate(nodes, 1):
|
||||
node.depth = 1 # root nodes are at depth 1
|
||||
|
||||
# Use NumConv to encode the index `i` to a base representation and
|
||||
# pad it to the specified step length using the custom alphabet
|
||||
node.path = converter.int2str(i).rjust(steplen, alphabet[0])
|
||||
|
||||
if nodes:
|
||||
Team.objects.bulk_update(nodes, ["depth", "path"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_contacts_add_index_on_data_emails'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='depth',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='numchild',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='path',
|
||||
field=models.CharField(default='', max_length=400, db_collation="C"),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(update_team_paths, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="team",
|
||||
name="path",
|
||||
field=models.CharField(unique=True, max_length=400, db_collation="C"),
|
||||
),
|
||||
]
|
||||
+200
-56
@@ -21,14 +21,15 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.translation import gettext, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
import jsonschema
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from core.enums import WebhookStatusChoices
|
||||
from core.plugins.loader import organization_plugins_run_after_create
|
||||
from core.utils.webhooks import scim_synchronizer
|
||||
from core.validators import get_field_validators_from_setting
|
||||
|
||||
@@ -40,7 +41,7 @@ with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
|
||||
contact_schema = json.load(contact_schema_file)
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices):
|
||||
class RoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
|
||||
"""Defines the possible roles a user can have in a team."""
|
||||
|
||||
MEMBER = "member", _("Member")
|
||||
@@ -48,7 +49,7 @@ class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
class OrganizationRoleChoices(models.TextChoices):
|
||||
class OrganizationRoleChoices(models.TextChoices): # pylint: disable=too-many-ancestors
|
||||
"""
|
||||
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.
|
||||
@@ -99,10 +100,10 @@ class BaseModel(models.Model):
|
||||
class Contact(BaseModel):
|
||||
"""User contacts"""
|
||||
|
||||
base = models.ForeignKey(
|
||||
override = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="overriding_contacts",
|
||||
related_name="overridden_by",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
@@ -113,11 +114,19 @@ class Contact(BaseModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
full_name = models.CharField(_("full name"), max_length=150, null=True, blank=True)
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="profile_contact",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("full name"), max_length=150)
|
||||
short_name = models.CharField(_("short name"), max_length=30, null=True, blank=True)
|
||||
|
||||
# avatar =
|
||||
# notes =
|
||||
notes = models.TextField(_("notes"), blank=True, default="")
|
||||
data = models.JSONField(
|
||||
_("contact information"),
|
||||
help_text=_("A JSON object containing the contact information"),
|
||||
@@ -136,31 +145,38 @@ class Contact(BaseModel):
|
||||
ordering = ("full_name", "short_name")
|
||||
verbose_name = _("contact")
|
||||
verbose_name_plural = _("contacts")
|
||||
unique_together = ("owner", "base")
|
||||
unique_together = ("owner", "override")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=~models.Q(base__isnull=False, owner__isnull=True),
|
||||
name="base_owner_constraint",
|
||||
condition=~models.Q(override__isnull=False, owner__isnull=True),
|
||||
name="override_owner_constraint",
|
||||
violation_error_message="A contact overriding a base contact must be owned.",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=~models.Q(base=models.F("id")),
|
||||
name="base_not_self",
|
||||
violation_error_message="A contact cannot be based on itself.",
|
||||
condition=~models.Q(override=models.F("id")),
|
||||
name="override_not_self",
|
||||
violation_error_message="A contact cannot override itself.",
|
||||
),
|
||||
# When a user is set, the owner must be the user
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=True)
|
||||
| models.Q(owner=models.F("user"), owner__isnull=False),
|
||||
name="profile_contact_owner_constraint",
|
||||
violation_error_message="Users can only declare as profile a contact they own.",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or self.short_name
|
||||
return self.full_name
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
# Check if the contact points to a base contact that itself points to another base contact
|
||||
if self.base_id and self.base.base_id:
|
||||
# Check if the contact overrides a contact that itself overrides another base contact
|
||||
if self.override_id and self.override.override_id:
|
||||
raise exceptions.ValidationError(
|
||||
"A contact cannot point to a base contact that itself points to a base contact."
|
||||
"A contact cannot override a contact that itself overrides a contact."
|
||||
)
|
||||
|
||||
# Validate the content of the "data" field against our jsonschema definition
|
||||
@@ -172,6 +188,55 @@ class Contact(BaseModel):
|
||||
error_message = f"Validation error in '{field_path:s}': {e.message}"
|
||||
raise exceptions.ValidationError({"data": [error_message]}) from e
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the contact.
|
||||
|
||||
Beware that the model allows owner to be None, we are still not
|
||||
sure about the use case for this and the API does not allow this.
|
||||
For now, we still consider here, a contact without owner is "public"
|
||||
so we allow access to it.
|
||||
"""
|
||||
is_owner = user.pk == self.owner_id
|
||||
is_profile_member_or_same_organization = bool(self.user_id) and (
|
||||
self.user.organization_id == user.organization_id
|
||||
)
|
||||
|
||||
return {
|
||||
"get": is_owner or is_profile_member_or_same_organization or not self.owner,
|
||||
"patch": is_owner,
|
||||
"put": is_owner,
|
||||
"delete": is_owner and not self.user, # Can't delete a profile contact
|
||||
}
|
||||
|
||||
|
||||
class ServiceProvider(BaseModel):
|
||||
"""
|
||||
Represents a service provider that will consume our information.
|
||||
|
||||
Organization uses this model to define the list of SP available to their users.
|
||||
Team uses this model to define their visibility to the various SP.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=256, unique=True)
|
||||
audience_id = models.CharField(
|
||||
_("audience id"), max_length=256, unique=True, db_index=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_service_provider"
|
||||
verbose_name = _("service provider")
|
||||
verbose_name_plural = _("service providers")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Enforce name (even if ugly) from the `audience_id` field."""
|
||||
if not self.name:
|
||||
self.name = self.audience_id # ok, same length
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class OrganizationManager(models.Manager):
|
||||
"""
|
||||
@@ -222,23 +287,15 @@ class OrganizationManager(models.Manager):
|
||||
|
||||
raise ValueError("Should never reach this point.")
|
||||
|
||||
def create(self, **kwargs):
|
||||
"""
|
||||
Create an organization with the given kwargs.
|
||||
|
||||
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.")
|
||||
This method is overridden to call the Organization plugins.
|
||||
"""
|
||||
instance = super().create(**kwargs)
|
||||
organization_plugins_run_after_create(instance)
|
||||
return instance
|
||||
|
||||
|
||||
class Organization(BaseModel):
|
||||
@@ -270,16 +327,20 @@ class Organization(BaseModel):
|
||||
verbose_name=_("registration ID list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
validators=[
|
||||
validate_unique_registration_id,
|
||||
],
|
||||
# list overlap validation is done in the validate_unique method
|
||||
)
|
||||
domain_list = ArrayField(
|
||||
models.CharField(max_length=256),
|
||||
verbose_name=_("domain list"),
|
||||
default=list,
|
||||
blank=True,
|
||||
validators=[validate_unique_domain],
|
||||
# list overlap validation is done in the validate_unique method
|
||||
)
|
||||
|
||||
service_providers = models.ManyToManyField(
|
||||
ServiceProvider,
|
||||
related_name="organizations",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = OrganizationManager()
|
||||
@@ -306,6 +367,55 @@ class Organization(BaseModel):
|
||||
def __str__(self):
|
||||
return f"{self.name} (# {self.pk})"
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
Validate Registration/Domain values in an array field are unique
|
||||
across all instances.
|
||||
|
||||
This can't be done in the field validator because we need to
|
||||
exclude the current object if already in database.
|
||||
"""
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
if self.pk:
|
||||
organization_qs = Organization.objects.exclude(pk=self.pk)
|
||||
else:
|
||||
organization_qs = Organization.objects.all()
|
||||
|
||||
# Check a registration ID can only be present in one organization
|
||||
if (
|
||||
self.registration_id_list
|
||||
and organization_qs.filter(
|
||||
registration_id_list__overlap=self.registration_id_list
|
||||
).exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
"registration_id_list value must be unique across all instances."
|
||||
)
|
||||
|
||||
# Check a domain can only be present in one organization
|
||||
if (
|
||||
self.domain_list
|
||||
and organization_qs.filter(domain_list__overlap=self.domain_list).exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
"domain_list value must be unique across all instances."
|
||||
)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the organization.
|
||||
"""
|
||||
# Use the role from queryset annotation will raise on purpose if not used properly
|
||||
is_admin = self.user_role == OrganizationRoleChoices.ADMIN # pylint: disable=no-member
|
||||
|
||||
return {
|
||||
"get": user.organization_id == self.pk,
|
||||
"patch": is_admin,
|
||||
"put": is_admin,
|
||||
"delete": False,
|
||||
}
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
@@ -329,16 +439,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
email = models.EmailField(_("email address"), null=True, blank=True)
|
||||
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
|
||||
profile_contact = models.OneToOneField(
|
||||
Contact,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="user",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
choices=settings.LANGUAGES,
|
||||
default=settings.LANGUAGE_CODE,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
@@ -404,11 +507,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
if self.email:
|
||||
self.email = User.objects.normalize_email(self.email)
|
||||
|
||||
if self.profile_contact_id and not self.profile_contact.owner == self:
|
||||
raise exceptions.ValidationError(
|
||||
"Users can only declare as profile a contact they own."
|
||||
)
|
||||
|
||||
def _convert_valid_invitations(self):
|
||||
"""
|
||||
Convert valid invitations to team accesses.
|
||||
@@ -475,7 +573,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
.get()
|
||||
)
|
||||
|
||||
teams_can_view = user_info.teams_can_view
|
||||
teams_can_view = user_info.teams_can_view or settings.FEATURES["TEAMS_DISPLAY"]
|
||||
mailboxes_can_view = user_info.mailboxes_can_view
|
||||
|
||||
return {
|
||||
@@ -487,7 +585,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
},
|
||||
"teams": {
|
||||
"can_view": teams_can_view and settings.FEATURES["TEAMS_DISPLAY"],
|
||||
"can_view": teams_can_view,
|
||||
"can_create": teams_can_view and settings.FEATURES["TEAMS_CREATE"],
|
||||
},
|
||||
"mailboxes": {
|
||||
@@ -536,11 +634,49 @@ class OrganizationAccess(BaseModel):
|
||||
return f"{self.user!s} is {self.role:s} in organization {self.organization!s}"
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
class TeamManager(MP_NodeManager):
|
||||
"""
|
||||
Custom manager for the Team model, to manage complexity/automation.
|
||||
"""
|
||||
|
||||
def create(self, parent_id=None, **kwargs):
|
||||
"""
|
||||
Replace the default create method to ease the Team creation process.
|
||||
|
||||
Notes:
|
||||
- the `add_*` methods from django-treebeard does not support the "using db".
|
||||
Which means it will always use the default db.
|
||||
- the `add_*` methods from django-treebeard does not support the "force_insert".
|
||||
|
||||
"""
|
||||
if parent_id is None:
|
||||
return self.model.add_root(**kwargs)
|
||||
|
||||
# Retrieve parent object, because django-treebeard uses raw queries for most
|
||||
# write operations, and raw queries don’t update the django objects of the db
|
||||
# entries they modify. See caveats in the django-treebeard documentation.
|
||||
# This might be changed later if we never do any operation on the parent object
|
||||
# before creating the child.
|
||||
# Beware the N+1 here.
|
||||
return self.get(pk=parent_id).add_child(**kwargs)
|
||||
|
||||
|
||||
class Team(MP_Node, BaseModel):
|
||||
"""
|
||||
Represents the link between teams and users, specifying the role a user has in a team.
|
||||
|
||||
When a team is created from here, the user have to choose which Service Providers
|
||||
can see it.
|
||||
When a team is created from a Service Provider this one is automatically set in the
|
||||
Team `service_providers`.
|
||||
"""
|
||||
|
||||
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
# Django treebeard does not allow max_length = None...
|
||||
steplen = 5
|
||||
path = models.CharField(max_length=5 * 80, unique=True, db_collation="C")
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
@@ -556,6 +692,13 @@ class Team(BaseModel):
|
||||
null=True, # Need to be set to False when everything is migrated
|
||||
blank=True, # Need to be set to False when everything is migrated
|
||||
)
|
||||
service_providers = models.ManyToManyField(
|
||||
ServiceProvider,
|
||||
related_name="teams",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = TeamManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "people_team"
|
||||
@@ -818,14 +961,15 @@ class Invitation(BaseModel):
|
||||
"""Email invitation to the user."""
|
||||
try:
|
||||
with override(self.issuer.language):
|
||||
subject = gettext("Invitation to join La Régie!")
|
||||
template_vars = {
|
||||
"title": _("Invitation to join Desk!"),
|
||||
"title": subject,
|
||||
"site": Site.objects.get_current(),
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||
mail.send_mail(
|
||||
_("Invitation to join Desk!"),
|
||||
subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[self.email],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core plugins package."""
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Base plugin class for organization plugins."""
|
||||
|
||||
|
||||
class BaseOrganizationPlugin:
|
||||
"""
|
||||
Base class for organization plugins.
|
||||
|
||||
Plugins must implement all methods of this class even if it is only to "pass".
|
||||
"""
|
||||
|
||||
def run_after_create(self, organization) -> None:
|
||||
"""Method called after creating an organization."""
|
||||
raise NotImplementedError("Plugins must implement the run_after_create method")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Helper functions to load and run organization plugins."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from core.plugins.base import BaseOrganizationPlugin
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_organization_plugins() -> List[BaseOrganizationPlugin]:
|
||||
"""
|
||||
Return a list of all organization plugins.
|
||||
While the plugins initialization does not depend on the request, we can cache the result.
|
||||
"""
|
||||
return [
|
||||
import_string(plugin_path)() for plugin_path in settings.ORGANIZATION_PLUGINS
|
||||
]
|
||||
|
||||
|
||||
def organization_plugins_run_after_create(organization):
|
||||
"""
|
||||
Run the after create method for all organization plugins.
|
||||
|
||||
Each plugin will be called in the order they are listed in the settings.
|
||||
Each plugin is responsible to save changes if needed, this is not optimized
|
||||
but this could be easily improved later if needed.
|
||||
"""
|
||||
for plugin_instance in get_organization_plugins():
|
||||
plugin_instance.run_after_create(organization)
|
||||
@@ -53,3 +53,20 @@ class ResourceServerAuthentication(OIDCAuthentication):
|
||||
pass
|
||||
|
||||
return access_token
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the request and return a tuple of (user, token) or None.
|
||||
|
||||
We override the 'authenticate' method from the parent class to store
|
||||
the introspected token audience inside the request.
|
||||
"""
|
||||
result = super().authenticate(request) # Might raise AuthenticationFailed
|
||||
|
||||
if result is None: # Case when there is no access token
|
||||
return None
|
||||
|
||||
# Note: at this stage, the request is a "drf_request" object
|
||||
request.resource_server_token_audience = self.backend.token_origin_audience
|
||||
|
||||
return result
|
||||
|
||||
@@ -61,6 +61,10 @@ class ResourceServerBackend:
|
||||
token_introspection={"essential": True},
|
||||
)
|
||||
|
||||
# Declare the token origin audience: to know where the token comes from
|
||||
# and store it for further use in the application
|
||||
self.token_origin_audience = None
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Maintain API compatibility with OIDCAuthentication class from mozilla-django-oidc
|
||||
@@ -85,6 +89,8 @@ class ResourceServerBackend:
|
||||
that extends RFC 7662 by returning a signed and encrypted JWT for stronger assurance that
|
||||
the authorization server issued the token introspection response.
|
||||
"""
|
||||
self.token_origin_audience = None # Reset the token origin audience
|
||||
|
||||
jwt = self._introspect(access_token)
|
||||
claims = self._verify_claims(jwt)
|
||||
user_info = self._verify_user_info(claims["token_introspection"])
|
||||
@@ -100,6 +106,8 @@ class ResourceServerBackend:
|
||||
logger.debug("Login failed: No user with %s found", sub)
|
||||
return None
|
||||
|
||||
self.token_origin_audience = str(user_info["aud"])
|
||||
|
||||
return user
|
||||
|
||||
def _verify_user_info(self, introspection_response):
|
||||
@@ -127,6 +135,12 @@ class ResourceServerBackend:
|
||||
logger.debug(message)
|
||||
raise SuspiciousOperation(message)
|
||||
|
||||
audience = introspection_response.get("aud", None)
|
||||
if not audience:
|
||||
raise SuspiciousOperation(
|
||||
"Introspection response does not provide source audience."
|
||||
)
|
||||
|
||||
return introspection_response
|
||||
|
||||
def _introspect(self, token):
|
||||
@@ -219,6 +233,8 @@ class ResourceServerBackend:
|
||||
class ResourceServerImproperlyConfiguredBackend:
|
||||
"""Fallback backend for improperly configured Resource Servers."""
|
||||
|
||||
token_origin_audience = None
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Indicate that the Resource Server is improperly configured."""
|
||||
raise AuthenticationFailed("Resource Server is improperly configured")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Mixins for resource server views.
|
||||
"""
|
||||
|
||||
from rest_framework import exceptions as drf_exceptions
|
||||
|
||||
from .authentication import ResourceServerAuthentication
|
||||
|
||||
|
||||
class ResourceServerMixin:
|
||||
"""
|
||||
Mixin for resource server views:
|
||||
- Restrict the authentication class to ResourceServerAuthentication.
|
||||
- Adds the Service Provider ID to the serializer context.
|
||||
- Fetch the Service Provider ID from the OIDC introspected token stored
|
||||
in the request.
|
||||
|
||||
This Mixin *must* be used in every view that should act as a resource server.
|
||||
"""
|
||||
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
|
||||
# When used as a resource server, we need to know the audience to automatically:
|
||||
# - add the Service Provider to the team "scope" on creation
|
||||
context["from_service_provider_audience"] = (
|
||||
self._get_service_provider_audience()
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def _get_service_provider_audience(self):
|
||||
"""Return the audience of the Service Provider from the OIDC introspected token."""
|
||||
if not isinstance(
|
||||
self.request.successful_authenticator, ResourceServerAuthentication
|
||||
):
|
||||
# We could check request.resource_server_token_audience here, but it's
|
||||
# more explicit to check the authenticator type and assert the attribute
|
||||
# existence.
|
||||
return None
|
||||
|
||||
# When used as a resource server, the request has a token audience
|
||||
service_provider_audience = self.request.resource_server_token_audience
|
||||
|
||||
if not service_provider_audience: # should not happen
|
||||
raise drf_exceptions.AuthenticationFailed(
|
||||
"Resource server token audience not found in request"
|
||||
)
|
||||
|
||||
return service_provider_audience
|
||||
@@ -0,0 +1 @@
|
||||
"""Contacts tests package."""
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
CONTACT_DATA = {
|
||||
"emails": [
|
||||
{"type": "Work", "value": "john.doe@work.com"},
|
||||
{"type": "Home", "value": "john.doe@home.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
{"type": "Other", "value": "(987) 654-3210"},
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"type": "Home",
|
||||
"street": "123 Main St",
|
||||
"city": "Cityville",
|
||||
"state": "CA",
|
||||
"zip": "12345",
|
||||
"country": "USA",
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{"type": "Blog", "value": "http://personalwebsite.com"},
|
||||
{"type": "Website", "value": "http://workwebsite.com"},
|
||||
],
|
||||
"customFields": {"custom_field_1": "value1", "custom_field_2": "value2"},
|
||||
"organizations": [
|
||||
{
|
||||
"name": "ACME Corporation",
|
||||
"department": "IT",
|
||||
"jobTitle": "Software Engineer",
|
||||
},
|
||||
{
|
||||
"name": "XYZ Ltd",
|
||||
"department": "Marketing",
|
||||
"jobTitle": "Marketing Specialist",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_anonymous_forbidden():
|
||||
"""Anonymous users should not be able to create contacts via the API."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David",
|
||||
"short_name": "Bowman",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert not models.Contact.objects.exists()
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_missing_base():
|
||||
"""Authenticated user should be able to create contact without override."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": {},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
new_contact = models.Contact.objects.get(owner=user)
|
||||
|
||||
assert response.json() == {
|
||||
"override": None,
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"data": {},
|
||||
"full_name": "David Bowman",
|
||||
"id": str(new_contact.pk),
|
||||
"notes": "",
|
||||
"owner": str(user.pk),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_successful():
|
||||
"""Authenticated users should be able to create contacts."""
|
||||
user = factories.UserFactory()
|
||||
base_contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Existing override for another user should not interfere
|
||||
factories.ContactFactory(override=base_contact)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"override": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Contact.objects.count() == 3
|
||||
|
||||
contact = models.Contact.objects.get(owner=user)
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"override": str(base_contact.id),
|
||||
"data": CONTACT_DATA,
|
||||
"full_name": "David Bowman",
|
||||
"notes": "",
|
||||
"owner": str(user.id),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
assert contact.full_name == "David Bowman"
|
||||
assert contact.short_name == "Dave"
|
||||
assert contact.data == CONTACT_DATA
|
||||
assert contact.override == base_contact
|
||||
assert contact.owner == user
|
||||
|
||||
|
||||
@override_settings(ALLOW_API_USER_CREATE=True)
|
||||
def test_api_contacts_create_authenticated_existing_override():
|
||||
"""
|
||||
Trying to create a contact overriding a contact that is already overridden by the user
|
||||
should receive a 400 error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
base_contact = factories.BaseContactFactory()
|
||||
factories.ContactFactory(override=base_contact, owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"override": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"notes": "",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
assert response.json() == {
|
||||
"__all__": ["Contact with this Owner and Override already exists."]
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_successful_with_notes():
|
||||
"""Authenticated users should be able to create contacts with notes."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
"notes": "This is a note",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
contact = models.Contact.objects.get(owner=user)
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"override": None,
|
||||
"data": CONTACT_DATA,
|
||||
"full_name": "David Bowman",
|
||||
"notes": "This is a note",
|
||||
"owner": str(user.id),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
assert contact.full_name == "David Bowman"
|
||||
assert contact.short_name == "Dave"
|
||||
assert contact.data == CONTACT_DATA
|
||||
assert contact.owner == user
|
||||
assert contact.notes == "This is a note"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a list of contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of contacts."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = client.delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_contacts_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a public contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be allowed to delete a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Contact.objects.count() == 0
|
||||
assert models.Contact.objects.filter(id=contact.id).exists() is False
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ProfileContactFactory(user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.exists() is True
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a contact they don't own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 1
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_no_query(django_assert_num_queries):
|
||||
"""
|
||||
Authenticated users should be able to list contacts without applying a query.
|
||||
Profile and overridden contacts should be excluded.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
# The user's profile contact should be listed (why not)
|
||||
user_profile_contact = factories.ContactFactory(
|
||||
owner=user, user=user, full_name="Dave Bowman"
|
||||
)
|
||||
|
||||
# A contact that belongs to another user should not be listed
|
||||
factories.ContactFactory()
|
||||
# even if from the same organization
|
||||
factories.ContactFactory(owner__organization=organization)
|
||||
|
||||
# A profile contact should not be listed if from another organization
|
||||
factories.ProfileContactFactory()
|
||||
|
||||
# A profile contact for someone in the same organization should be listed
|
||||
profile_contact = factories.ProfileContactFactory(
|
||||
user__organization=organization, full_name="Frank Poole"
|
||||
)
|
||||
|
||||
# An overridden contact should not be listed, but the override must be
|
||||
overriden_contact = factories.ProfileContactFactory(user__organization=organization)
|
||||
override_contact = factories.ContactFactory(
|
||||
owner=user, override=overriden_contact, full_name="Nicole Foole"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"id": str(user_profile_contact.pk),
|
||||
"abilities": {"delete": False, "get": True, "patch": True, "put": True},
|
||||
"override": None,
|
||||
"owner": str(user.pk),
|
||||
"data": user_profile_contact.data,
|
||||
"full_name": user_profile_contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": user_profile_contact.short_name,
|
||||
},
|
||||
{
|
||||
"id": str(profile_contact.pk),
|
||||
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
|
||||
"override": None,
|
||||
"owner": str(profile_contact.user.pk),
|
||||
"data": profile_contact.data,
|
||||
"full_name": profile_contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": profile_contact.short_name,
|
||||
},
|
||||
{
|
||||
"id": str(override_contact.pk),
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"override": str(overriden_contact.pk),
|
||||
"owner": str(user.pk),
|
||||
"data": override_contact.data,
|
||||
"full_name": override_contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": override_contact.short_name,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_by_full_name():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case insensitive and
|
||||
partial query on the full name.
|
||||
"""
|
||||
user = factories.UserFactory(name="Prudence Crandall")
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="David Bowman", data={})
|
||||
nicole = factories.BaseContactFactory(full_name="Nicole Foole", data={})
|
||||
frank = factories.BaseContactFactory(full_name="Frank Poole", data={})
|
||||
factories.BaseContactFactory(full_name="Heywood Floyd", data={})
|
||||
|
||||
# Full query should work
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/contacts/?q=ank")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ole")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ool")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_by_email():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(
|
||||
full_name="0", # don't match on full name but allow ordering
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Home", "value": "dave@personal.com"},
|
||||
{"type": "Work", "value": "david.bowman@example.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
nicole = factories.BaseContactFactory(
|
||||
full_name="1", # don't match on full name but allow ordering
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "nicole.foole@example.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
frank = factories.BaseContactFactory(
|
||||
full_name="2", # don't match on full name but allow ordering
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Home", "value": "francky@personal.com"},
|
||||
{"type": "Work", "value": "franck.poole@example.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
factories.BaseContactFactory(
|
||||
full_name="3", # don't match on full name but allow ordering
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "heywood.floyd@example.com"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# Full query should work
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=david.bowman@example.com")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.pk)]
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/contacts/?q=anc")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.pk)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=olé") # accented
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(nicole.pk), str(frank.pk)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=oOl") # mixed case
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(nicole.pk), str(frank.pk)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
|
||||
|
||||
# Unaccented full name
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=EEE")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=AAA")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_content():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented query should find unaccented content."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=ééé")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=ààà")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a user."""
|
||||
client = APIClient()
|
||||
contact = factories.ContactFactory()
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_owned(django_assert_num_queries):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"abilities": {"delete": True, "get": True, "patch": True, "put": True},
|
||||
"override": None,
|
||||
"owner": str(contact.owner.id),
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve public contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
|
||||
"override": None,
|
||||
"owner": None,
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"notes": "",
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve another user's contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_contacts_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_owned(django_assert_num_queries):
|
||||
"""
|
||||
Authenticated users should be allowed to update their own contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
with django_assert_num_queries(8):
|
||||
# user, 2x contact, user, 3x check, update contact
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["override", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to update their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user, user=user)
|
||||
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["override", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to update contacts owned by other users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory() # owned by another user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["override"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
@@ -0,0 +1 @@
|
||||
"""Test organization API endpoints."""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tests for Organizations API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_anonymous(client):
|
||||
"""Anonymous users should not be allowed to retrieve an organization."""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
response = client.get(f"/api/v1.0/organizations/{organization.pk}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_unrelated(client):
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve an organization to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Organization matches the given query."}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_belong_to_organization(client):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve an organization to which they
|
||||
belong to.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(
|
||||
registration_id_list=["56618615316840", "31561861231231", "98781236231482"],
|
||||
domain_list=["example.com", "example.org"],
|
||||
)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(organization.pk),
|
||||
"name": organization.name,
|
||||
"abilities": {"delete": False, "get": True, "patch": False, "put": False},
|
||||
"domain_list": ["example.com", "example.org"],
|
||||
"registration_id_list": ["56618615316840", "31561861231231", "98781236231482"],
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_retrieve_authenticated_administrator(client):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve an organization
|
||||
which they administrate.
|
||||
"""
|
||||
organization_access = (
|
||||
factories.OrganizationAccessFactory()
|
||||
) # only role is administrator for now
|
||||
user = organization_access.user
|
||||
organization = organization_access.organization
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/organizations/{organization.pk!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Tests for Organizations API endpoint in People's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_organizations_update_anonymous(client):
|
||||
"""Anonymous users should not be allowed to update an organization."""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_unrelated(client):
|
||||
"""
|
||||
Authenticated users should not be allowed to update an organization to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Organization matches the given query."}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_belong_to_organization(client):
|
||||
"""
|
||||
Authenticated users should NOT be allowed to update an organization to which they
|
||||
belong to.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_organizations_update_authenticated_administrator(client):
|
||||
"""
|
||||
Authenticated users should be allowed to update an organization
|
||||
which they administrate.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(
|
||||
registration_id_list=["56618615316840", "31561861231231", "98781236231482"],
|
||||
domain_list=["example.com", "example.org"],
|
||||
)
|
||||
organization_access = factories.OrganizationAccessFactory(organization=organization)
|
||||
user = organization_access.user
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/organizations/{organization.pk}/",
|
||||
{"name": "New Name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(organization.pk),
|
||||
"name": "New Name",
|
||||
"abilities": {"delete": False, "get": True, "patch": True, "put": True},
|
||||
"domain_list": ["example.com", "example.org"],
|
||||
"registration_id_list": ["56618615316840", "31561861231231", "98781236231482"],
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Tests for the authentication process of the resource server."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.rfc7518.rsa_key import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
from rest_framework.request import Request as DRFRequest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.models import ServiceProvider
|
||||
from core.resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_authorization_bearer(token):
|
||||
"""
|
||||
Build an Authorization Bearer header value from a token.
|
||||
|
||||
This can be used like this:
|
||||
client.post(
|
||||
...
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
"""
|
||||
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_authentication_class(client, settings):
|
||||
"""
|
||||
Defines the settings for the resource server
|
||||
for a full authentication with introspection process.
|
||||
|
||||
This is an integration test that checks the authentication process
|
||||
when using the ResourceServerAuthentication class.
|
||||
|
||||
This test asserts the DRF request object contains the
|
||||
`resource_server_token_audience` attribute which is used in
|
||||
the resource server views.
|
||||
|
||||
This test uses the `/resource-server/v1.0/teams/` URL as an example
|
||||
because we don't want to create a new URL just for this test.
|
||||
"""
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
unencrypted_pem_private_key = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
pem_public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = unencrypted_pem_private_key.decode("utf-8")
|
||||
settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "RS256"
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
# Mock the JWKS endpoint
|
||||
public_numbers = private_key.public_key().public_numbers()
|
||||
responses.add(
|
||||
responses.GET,
|
||||
settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
body=json.dumps(
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
"use": "sig",
|
||||
"kid": "1234567890",
|
||||
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
|
||||
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def encrypt_jwt(json_data):
|
||||
"""Encrypt the JWT token for the backend to decrypt."""
|
||||
token = jose_jwt.encode(
|
||||
{
|
||||
"kid": "1234567890",
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
},
|
||||
json_data,
|
||||
RSAKey.import_key(unencrypted_pem_private_key),
|
||||
algorithms=[settings.OIDC_RS_SIGNING_ALGO],
|
||||
)
|
||||
|
||||
return jose_jwe.encrypt_compact(
|
||||
protected={
|
||||
"alg": settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
"enc": settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
},
|
||||
plaintext=token,
|
||||
public_key=RSAKey.import_key(pem_public_key),
|
||||
algorithms=[
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
],
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
body=encrypt_jwt(
|
||||
{
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"token_introspection": {
|
||||
"sub": "very-specific-sub",
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_service_provider",
|
||||
"scope": "openid groups",
|
||||
"active": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Try to authenticate while the user does not exist => 401
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/", # use an exising URL here
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
|
||||
# Create a user with the specific sub, the access is authorized
|
||||
UserFactory(sub="very-specific-sub")
|
||||
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/", # use an exising URL here
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
response_request = response.renderer_context.get("request")
|
||||
assert isinstance(response_request, DRFRequest)
|
||||
assert isinstance(
|
||||
response_request.successful_authenticator, ResourceServerAuthentication
|
||||
)
|
||||
|
||||
# Check that the user is authenticated
|
||||
assert response_request.user.is_authenticated
|
||||
|
||||
# Check the request contains the resource server token audience
|
||||
assert response_request.resource_server_token_audience == "some_service_provider"
|
||||
|
||||
# Check that no service provider is created here
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
@@ -296,7 +296,7 @@ def test_introspect_public_key_import_failure(
|
||||
|
||||
def test_verify_user_info_success(resource_server_backend):
|
||||
"""Test '_verify_user_info' with a successful response."""
|
||||
introspection_response = {"active": True, "scope": "groups"}
|
||||
introspection_response = {"active": True, "scope": "groups", "aud": "123"}
|
||||
|
||||
result = resource_server_backend._verify_user_info(introspection_response)
|
||||
assert result == introspection_response
|
||||
@@ -333,7 +333,7 @@ def test_get_user_success(resource_server_backend):
|
||||
|
||||
access_token = "valid_access_token"
|
||||
mock_jwt = Mock()
|
||||
mock_claims = {"token_introspection": {"sub": "user123"}}
|
||||
mock_claims = {"token_introspection": {"sub": "user123", "aud": "123"}}
|
||||
mock_user = Mock()
|
||||
|
||||
resource_server_backend._introspect = Mock(return_value=mock_jwt)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server API endpoints."""
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Defines fixtures for the resource server API tests."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
|
||||
from core.resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
User = get_user_model()
|
||||
fake = Faker()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _force_login_via_resource_server(
|
||||
client_fixture,
|
||||
user: User,
|
||||
service_provider_audience: Optional[str],
|
||||
):
|
||||
"""
|
||||
Context manager to authenticate a user with a service provider via
|
||||
a resource server call.
|
||||
|
||||
This allows to authenticate a user with a service provider without doing
|
||||
all the introspection process.
|
||||
|
||||
This is a private function, use the `force_login_via_resource_server`
|
||||
fixture instead.
|
||||
|
||||
The `service_provider_audience` parameter might not match any existing
|
||||
service provider audience, doing so allow to check the behavior when
|
||||
the service provider is not yet known.
|
||||
"""
|
||||
|
||||
def mock_authenticate(self, request): # pylint: disable=unused-argument
|
||||
request.resource_server_token_audience = (
|
||||
service_provider_audience or fake.pystr(min_chars=10, max_chars=10)
|
||||
)
|
||||
return user, "unused-token"
|
||||
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication, "authenticate", mock_authenticate
|
||||
):
|
||||
client_fixture.force_login(
|
||||
user,
|
||||
backend="core.resource_server.authentication.ResourceServerAuthentication",
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="force_login_via_resource_server")
|
||||
@responses.activate
|
||||
def force_login_via_resource_server_fixture():
|
||||
"""
|
||||
Fixture to authenticate a user with a service provider via a resource server call.
|
||||
|
||||
Usage:
|
||||
```
|
||||
def test_login_with_resource_server(
|
||||
client, force_login_via_resource_server,
|
||||
):
|
||||
user = UserFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/<whatever>/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
# response is authenticated
|
||||
```
|
||||
"""
|
||||
return _force_login_via_resource_server
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the resource server Team API endpoints."""
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_201_CREATED,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import OrganizationFactory, ServiceProviderFactory, UserFactory
|
||||
from core.models import ServiceProvider, Team
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create teams."""
|
||||
response = APIClient().post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert not Team.objects.exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_new_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team and a new service provider should be created and
|
||||
associated to the team.
|
||||
"""
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
assert ServiceProvider.objects.count() == 0
|
||||
|
||||
with force_login_via_resource_server(client, user, "some_service_provider"):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
team = Team.objects.get()
|
||||
team_access = team.accesses.get()
|
||||
service_provider = ServiceProvider.objects.get() # service provider created
|
||||
|
||||
assert response.json() == {
|
||||
"created_at": team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"id": str(team.pk),
|
||||
"depth": team.depth,
|
||||
"name": "my team",
|
||||
"numchild": team.numchild,
|
||||
"path": team.path,
|
||||
"updated_at": team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
}
|
||||
|
||||
# check team data
|
||||
assert team.name == "my team"
|
||||
assert team.organization == organization
|
||||
|
||||
# check team access data
|
||||
assert team_access.role == "owner"
|
||||
assert team_access.user == user
|
||||
|
||||
# check service provider data
|
||||
assert service_provider.audience_id == "some_service_provider"
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_existing_service_provider(
|
||||
client,
|
||||
force_login_via_resource_server,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team and an existing service provider should be associated
|
||||
to the team.
|
||||
"""
|
||||
user = UserFactory()
|
||||
service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
assert ServiceProvider.objects.count() == 1 # no object created
|
||||
team = Team.objects.get() # team created
|
||||
assert team.service_providers.get().audience_id == service_provider.audience_id
|
||||
assert team.name == "my team"
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_cannot_override_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and not
|
||||
be able to set the organization manually (for now).
|
||||
"""
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
service_provider = ServiceProviderFactory()
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
"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.organization == organization
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_cannot_override_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to create teams and not
|
||||
be able to set the team service provider manually.
|
||||
"""
|
||||
user = UserFactory(with_organization=True)
|
||||
service_provider = ServiceProviderFactory()
|
||||
|
||||
other_service_provider = ServiceProviderFactory()
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.post(
|
||||
"/resource-server/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
"service_providers": [str(other_service_provider.pk)], # ignored
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.service_providers.get().audience_id == service_provider.audience_id
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team."""
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.Team.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_teams_delete_not_allowed(client, force_login_via_resource_server):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team from a resource
|
||||
server, even if they have the right permissions.
|
||||
|
||||
This may be implemented in the future, but for now, it is not allowed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "owner")], service_providers=[service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.delete(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "DELETE" not allowed.'}
|
||||
assert models.Team.objects.count() == 1
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list teams."""
|
||||
factories.TeamFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/resource-server/v1.0/teams/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated( # pylint: disable=too-many-locals
|
||||
client, django_assert_num_queries, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list teams
|
||||
they are an owner/administrator/member of, and only list from the
|
||||
requesting service provider should appear.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
hidden_service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team_access_1 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="member"
|
||||
)
|
||||
team_1 = team_access_1.team
|
||||
|
||||
team_access_2 = factories.TeamAccessFactory(
|
||||
user=user,
|
||||
team__service_providers=[hidden_service_provider, service_provider],
|
||||
role="member",
|
||||
)
|
||||
team_2 = team_access_2.team
|
||||
|
||||
team_access_3 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="administrator"
|
||||
)
|
||||
team_3 = team_access_3.team
|
||||
|
||||
team_access_4 = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[service_provider], role="owner"
|
||||
)
|
||||
team_4 = team_access_4.team
|
||||
|
||||
# Team filtered out because of the service provider
|
||||
_not_included_team_access = factories.TeamAccessFactory(
|
||||
user=user, team__service_providers=[hidden_service_provider]
|
||||
)
|
||||
|
||||
# Authenticate using the resource server, ie via the Authorization header
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
with django_assert_num_queries(5):
|
||||
# queries: Team path, Count, Team, ServiceProvider, TeamAccess
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/?ordering=created_at",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"created_at": team_1.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_1.depth,
|
||||
"id": str(team_1.pk),
|
||||
"name": team_1.name,
|
||||
"numchild": team_1.numchild,
|
||||
"path": team_1.path,
|
||||
"updated_at": team_1.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_2.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_2.depth,
|
||||
"id": str(team_2.pk),
|
||||
"name": team_2.name,
|
||||
"numchild": team_2.numchild,
|
||||
"path": team_2.path,
|
||||
"updated_at": team_2.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_3.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_3.depth,
|
||||
"id": str(team_3.pk),
|
||||
"name": team_3.name,
|
||||
"numchild": team_3.numchild,
|
||||
"path": team_3.path,
|
||||
"updated_at": team_3.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
"created_at": team_4.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": team_4.depth,
|
||||
"id": str(team_4.pk),
|
||||
"name": team_4.name,
|
||||
"numchild": team_4.numchild,
|
||||
"path": team_4.path,
|
||||
"updated_at": team_4.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated_new_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Team list from not yet known service provider should be empty (not fail).
|
||||
|
||||
Teams without service providers should not be listed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
_team = factories.TeamFactory(users=[user])
|
||||
|
||||
with force_login_via_resource_server(client, user, "some_service_provider"):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated_distinct(client, force_login_via_resource_server):
|
||||
"""A team with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[user, other_user], service_providers=[service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert content["count"] == 1
|
||||
|
||||
results = content["results"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == str(team.id)
|
||||
|
||||
|
||||
def test_api_teams_order_param(client, force_login_via_resource_server):
|
||||
"""
|
||||
Test that the 'created_at' field is sorted in ascending order
|
||||
when the 'ordering' query parameter is set.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team_ids = [
|
||||
str(team.id)
|
||||
for team in factories.TeamFactory.create_batch(
|
||||
5, users=[user], service_providers=[service_provider]
|
||||
)
|
||||
]
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
assert response_team_ids == team_ids, (
|
||||
"created_at values are not sorted from oldest to newest"
|
||||
)
|
||||
|
||||
|
||||
def test_api_teams_list_with_parent_teams(client, force_login_via_resource_server):
|
||||
"""
|
||||
Authenticated users should be able to list teams including parent teams.
|
||||
Parent teams should not be listed if they don't have the service provider.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(name="Root", service_providers=[service_provider])
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second", parent_id=first_team.pk, service_providers=[service_provider]
|
||||
)
|
||||
|
||||
factories.TeamAccessFactory(user=user, team=second_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data["count"] == 2
|
||||
|
||||
team_ids = [team["id"] for team in response_data["results"]]
|
||||
assert len(team_ids) == 2
|
||||
assert set(team_ids) == {str(root_team.id), str(second_team.id)}
|
||||
|
||||
|
||||
def test_api_teams_list_with_parent_teams_other_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list teams including parent teams.
|
||||
Parent teams should not be listed if they don't have the service provider
|
||||
or if the user does not belong to the organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
service_providers=[service_provider],
|
||||
organization=other_organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First", parent_id=root_team.pk, organization=other_organization
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=other_organization,
|
||||
)
|
||||
|
||||
factories.TeamAccessFactory(user=user, team=second_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
"/resource-server/v1.0/teams/",
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data["count"] == 1
|
||||
|
||||
team_ids = [team["id"] for team in response_data["results"]]
|
||||
assert len(team_ids) == 1
|
||||
assert set(team_ids) == {str(second_team.id)}
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.status import HTTP_404_NOT_FOUND
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.factories import TeamAccessFactory, UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a team."""
|
||||
team = factories.TeamFactory()
|
||||
response = APIClient().get(f"/resource-server/v1.0/teams/{team.id}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_unrelated(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(service_providers=[service_provider])
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a team to which they
|
||||
are related whatever the role even if the request is authenticated via
|
||||
a resource server.
|
||||
"""
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[user], service_providers=[service_provider])
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 1,
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"numchild": 0,
|
||||
"path": team.path,
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_other_service_provider(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve a team
|
||||
if the request is authenticated via a different resource server.
|
||||
"""
|
||||
user = UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
team = factories.TeamFactory(
|
||||
users=[user], service_providers=[other_service_provider]
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related_parent_same_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a parent team
|
||||
of a team to which they are related, only if they belong to the
|
||||
same organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=organization,
|
||||
)
|
||||
TeamAccessFactory(user=user, team=second_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{first_team.pk}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 2,
|
||||
"id": str(first_team.pk),
|
||||
"name": first_team.name,
|
||||
"numchild": 1,
|
||||
"path": first_team.path,
|
||||
"updated_at": first_team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related_parent_other_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a parent team
|
||||
of a team to which they are related, only if they belong to the
|
||||
same organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=other_organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=other_organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=other_organization,
|
||||
)
|
||||
TeamAccessFactory(user=user, team=second_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{first_team.pk}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related_child_same_organization(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should NOT be allowed to retrieve a child team
|
||||
of a team to which they are related, even if they belong to the
|
||||
same organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=organization,
|
||||
)
|
||||
TeamAccessFactory(user=user, team=first_team, role="member")
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.get(
|
||||
f"/resource-server/v1.0/teams/{second_team.pk}/",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.resource_server import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team."""
|
||||
team = factories.TeamFactory()
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = APIClient().put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_unrelated(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
team = factories.TeamFactory(service_providers=[service_provider])
|
||||
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated(
|
||||
client,
|
||||
force_login_via_resource_server,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to update a team to which they
|
||||
are related whatever the role even if the request is authenticated via
|
||||
a resource server.
|
||||
"""
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(
|
||||
name="Old name",
|
||||
users=[(user, "owner")],
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
data=serializers.TeamSerializer(instance=team).data
|
||||
| {
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.name == "New name"
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_other_resource_server(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to update a team for which they are directly
|
||||
owner, if the request is authenticated via a different service provider.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
other_service_provider = factories.ServiceProviderFactory(
|
||||
audience_id="some_service_provider"
|
||||
)
|
||||
team = factories.TeamFactory(
|
||||
name="Old name",
|
||||
users=[(user, "owner")],
|
||||
service_providers=[other_service_provider],
|
||||
)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
data=serializers.TeamSerializer(instance=team).data
|
||||
| {
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.name == "Old name"
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_members(
|
||||
client, force_login_via_resource_server
|
||||
):
|
||||
"""
|
||||
Users who are members of a team but not administrators should
|
||||
not be allowed to update it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, "member")], service_providers=[service_provider]
|
||||
)
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_teams_update_authenticated_administrators(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""Administrators or owners of a team should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(user, role)],
|
||||
service_providers=[service_provider],
|
||||
name="old name",
|
||||
)
|
||||
initial_created_at = team.created_at
|
||||
initial_updated_at = team.updated_at
|
||||
initial_pk = team.pk
|
||||
|
||||
# generate new random values
|
||||
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.pk == initial_pk
|
||||
assert team.name == new_values["name"]
|
||||
assert team.created_at == initial_created_at
|
||||
assert team.updated_at > initial_updated_at
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_teams_update_administrator_or_owner_of_another(
|
||||
client, force_login_via_resource_server, role
|
||||
):
|
||||
"""
|
||||
Being administrator or owner of a team should not grant authorization to update
|
||||
another team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
factories.TeamFactory(users=[(user, role)], service_providers=[service_provider])
|
||||
|
||||
team = factories.TeamFactory(name="Old name", service_providers=[service_provider])
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.put(
|
||||
f"/resource-server/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "member", "owner"])
|
||||
def test_api_teams_update_parent_team(client, force_login_via_resource_server, role):
|
||||
"""
|
||||
Belonging to a team should NOT grant authorization to update
|
||||
another parent team.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=organization,
|
||||
)
|
||||
factories.TeamAccessFactory(user=user, team=second_team, role=role)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.patch(
|
||||
f"/resource-server/v1.0/teams/{first_team.pk}/",
|
||||
{
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
first_team.refresh_from_db()
|
||||
assert first_team.name == "First"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "member", "owner"])
|
||||
def test_api_teams_update_child_team(client, force_login_via_resource_server, role):
|
||||
"""
|
||||
Belonging to a team should NOT grant authorization to update
|
||||
another child team.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
root_team = factories.TeamFactory(
|
||||
name="Root",
|
||||
organization=organization,
|
||||
)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First",
|
||||
parent_id=root_team.pk,
|
||||
organization=organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second",
|
||||
parent_id=first_team.pk,
|
||||
service_providers=[service_provider],
|
||||
organization=organization,
|
||||
)
|
||||
factories.TeamAccessFactory(user=user, team=first_team, role=role)
|
||||
|
||||
with force_login_via_resource_server(client, user, service_provider.audience_id):
|
||||
response = client.patch(
|
||||
f"/resource-server/v1.0/teams/{second_team.pk}/",
|
||||
{
|
||||
"name": "New name",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
second_team.refresh_from_db()
|
||||
assert second_team.name == "Second"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test for the service providers viewset.
|
||||
"""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Tests for Service Provider API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_service_providers_list_anonymous(client):
|
||||
"""Anonymous users should not be allowed to list service providers."""
|
||||
factories.ServiceProviderFactory.create_batch(2)
|
||||
|
||||
response = client.get("/api/v1.0/service-providers/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_providers_list_authenticated(client):
|
||||
"""
|
||||
Authenticated users should be able to list service providers
|
||||
of their organization.
|
||||
"""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
client.force_login(user)
|
||||
|
||||
service_provider_1 = factories.ServiceProviderFactory(
|
||||
name="A", organizations=[user.organization]
|
||||
)
|
||||
service_provider_2 = factories.ServiceProviderFactory(
|
||||
name="B", organizations=[user.organization]
|
||||
)
|
||||
|
||||
# Generate some not fetched data
|
||||
factories.ServiceProviderFactory.create_batch(
|
||||
2, organizations=[factories.OrganizationFactory(with_registration_id=True)]
|
||||
) # Other service providers
|
||||
factories.TeamFactory(
|
||||
users=[user], service_providers=[factories.ServiceProviderFactory()]
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/service-providers/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"count": 2,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"audience_id": str(service_provider_1.audience_id),
|
||||
"id": str(service_provider_1.pk),
|
||||
"name": "A",
|
||||
},
|
||||
{
|
||||
"audience_id": str(service_provider_2.audience_id),
|
||||
"id": str(service_provider_2.pk),
|
||||
"name": "B",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_providers_order(client):
|
||||
"""Test that the service providers are sorted as requested."""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
factories.ServiceProviderFactory(name="A", organizations=[user.organization])
|
||||
factories.ServiceProviderFactory(name="B", organizations=[user.organization])
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
# Test ordering by name descending
|
||||
response = client.get("/api/v1.0/service-providers/?ordering=-name")
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()["results"]
|
||||
assert response_data[0]["name"] == "B"
|
||||
assert response_data[1]["name"] == "A"
|
||||
|
||||
# Test ordering by creation date ascending
|
||||
response = client.get("/api/v1.0/service-providers/?ordering=created_at")
|
||||
response_data = response.json()["results"]
|
||||
assert response_data[0]["name"] == "A"
|
||||
assert response_data[1]["name"] == "B"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Tests for Service Provider API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_service_providers_retrieve_anonymous(client):
|
||||
"""Anonymous users should not be allowed to retrieve service providers."""
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_providers_retrieve_authenticated_allowed(client):
|
||||
"""
|
||||
Authenticated users should be able to retrieve service providers
|
||||
of their organization.
|
||||
"""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
client.force_login(user)
|
||||
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
organizations=[user.organization]
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"audience_id": str(service_provider.audience_id),
|
||||
"id": str(service_provider.pk),
|
||||
"name": service_provider.name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_providers_retrieve_authenticated_other_organization(client):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve service providers
|
||||
of other organization.
|
||||
"""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
client.force_login(user)
|
||||
|
||||
service_provider = factories.ServiceProviderFactory(
|
||||
organizations=[factories.OrganizationFactory(with_registration_id=True)]
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No ServiceProvider matches the given query."}
|
||||
|
||||
|
||||
def test_api_service_providers_retrieve_authenticated_on_teams(client):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve service providers
|
||||
just because it is related to one of their teams if it is not related
|
||||
to their organization (might change later if needed).
|
||||
"""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
client.force_login(user)
|
||||
|
||||
other_organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
service_provider = factories.ServiceProviderFactory()
|
||||
factories.TeamFactory(
|
||||
users=[user],
|
||||
organization=other_organization,
|
||||
service_providers=[service_provider],
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/service-providers/{service_provider.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No ServiceProvider matches the given query."}
|
||||
@@ -0,0 +1 @@
|
||||
"""Team accesses tests package."""
|
||||
@@ -338,14 +338,19 @@ def test_api_team_accesses_list_authenticated_ordering():
|
||||
def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
|
||||
"""Team accesses can be ordered by user's fields."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
john = factories.UserFactory(email="john.doe@example.com", name="John Doe")
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user)
|
||||
models.TeamAccess.objects.create(team=team, user=john)
|
||||
|
||||
# create 20 new team members
|
||||
for _ in range(20):
|
||||
extra_user = factories.UserFactory()
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
# create new team members
|
||||
dave = factories.UserFactory(email="bowbow@example.com", name="David Bowman")
|
||||
nicole = factories.UserFactory(
|
||||
email="nicole_foole@example.com", name="Nicole Foole"
|
||||
)
|
||||
frank = factories.UserFactory(email="frank_poole@example.com", name="Frank Poole")
|
||||
mary = factories.UserFactory(email="mary_pol@example.com", name="Mary Pol")
|
||||
for user in (dave, nicole, frank, mary):
|
||||
factories.TeamAccessFactory(team=team, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -354,14 +359,11 @@ def test_api_team_accesses_list_authenticated_ordering_user(ordering_field):
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=user__{ordering_field}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
def normalize(x):
|
||||
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
|
||||
return x.casefold().replace(" ", "")
|
||||
|
||||
results = [
|
||||
team_access["user"][ordering_field]
|
||||
for team_access in response.json()["results"]
|
||||
assert response.json()["count"] == 5
|
||||
assert [access["user"]["name"] for access in response.json()["results"]] == [
|
||||
dave.name,
|
||||
frank.name,
|
||||
john.name,
|
||||
mary.name,
|
||||
nicole.name,
|
||||
]
|
||||
assert sorted(results, key=normalize) == results
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Teams tests package."""
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_201_CREATED,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -28,7 +29,7 @@ def test_api_teams_create_anonymous():
|
||||
assert not Team.objects.exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated():
|
||||
def test_api_teams_create_authenticated(settings):
|
||||
"""
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team.
|
||||
@@ -39,6 +40,14 @@ def test_api_teams_create_authenticated():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.FEATURES = {
|
||||
"TEAMS_DISPLAY": True,
|
||||
"TEAMS_CREATE": True,
|
||||
"CONTACTS_DISPLAY": False,
|
||||
"CONTACTS_CREATE": False,
|
||||
"MAILBOXES_CREATE": False,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
@@ -54,6 +63,36 @@ def test_api_teams_create_authenticated():
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_feature_disabled(settings):
|
||||
"""
|
||||
Authenticated users should not be able to create teams when feature is disabled.
|
||||
"""
|
||||
organization = OrganizationFactory(with_registration_id=True)
|
||||
user = UserFactory(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.FEATURES = {
|
||||
"TEAMS_DISPLAY": True,
|
||||
"TEAMS_CREATE": False,
|
||||
"CONTACTS_DISPLAY": False,
|
||||
"CONTACTS_CREATE": False,
|
||||
"MAILBOXES_CREATE": False,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert not Team.objects.exists()
|
||||
|
||||
|
||||
def test_api_teams_create_cannot_override_organization():
|
||||
"""
|
||||
Authenticated users should be able to create teams and not
|
||||
|
||||
@@ -113,3 +113,59 @@ def test_api_teams_delete_authenticated_owner():
|
||||
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
assert models.Team.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator", "member"],
|
||||
)
|
||||
def test_api_teams_delete_authenticated_owner_parent_team(client, role):
|
||||
"""
|
||||
Authenticated users should not be able to delete a parent team they
|
||||
don't own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
root_team = factories.TeamFactory(name="Root")
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
|
||||
|
||||
# user is a member of the second team
|
||||
factories.TeamAccessFactory(team=second_team, user=user, role=role)
|
||||
|
||||
response = client.delete(f"/api/v1.0/teams/{first_team.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.Team.objects.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator", "member"],
|
||||
)
|
||||
def test_api_teams_delete_authenticated_owner_child_team(client, role):
|
||||
"""
|
||||
Authenticated users should not be able to delete a children team they
|
||||
don't own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
root_team = factories.TeamFactory(name="Root")
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
|
||||
|
||||
# user is a member of the first team
|
||||
factories.TeamAccessFactory(team=first_team, user=user, role=role)
|
||||
|
||||
response = client.delete(f"/api/v1.0/teams/{second_team.pk}/")
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
assert models.Team.objects.count() == 3
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
Tests for Teams API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -98,9 +95,9 @@ def test_api_teams_order():
|
||||
response_team_ids = [team["id"] for team in response_data]
|
||||
|
||||
team_ids.reverse()
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from newest to oldest"
|
||||
assert response_team_ids == team_ids, (
|
||||
"created_at values are not sorted from newest to oldest"
|
||||
)
|
||||
|
||||
|
||||
def test_api_teams_order_param():
|
||||
@@ -123,9 +120,206 @@ def test_api_teams_order_param():
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_team_ids = [team["id"] for team in response_data]
|
||||
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from oldest to newest"
|
||||
assert response_team_ids == team_ids, (
|
||||
"created_at values are not sorted from oldest to newest"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role,local_team_abilities",
|
||||
[
|
||||
(
|
||||
"owner",
|
||||
{
|
||||
"delete": True,
|
||||
"get": True,
|
||||
"manage_accesses": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
"administrator",
|
||||
{
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"manage_accesses": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
"member",
|
||||
{
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"manage_accesses": False,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_api_teams_list_authenticated_team_tree(client, role, local_team_abilities):
|
||||
"""
|
||||
Authenticated users should be able to list teams
|
||||
they are an owner/administrator/member of, or any parent teams.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
root_team = factories.TeamFactory(name="Root")
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
|
||||
third_team = factories.TeamFactory(name="Third", parent_id=second_team.pk)
|
||||
_fourth_team = factories.TeamFactory(name="Fourth", parent_id=third_team.pk)
|
||||
|
||||
# user is a member of the second team
|
||||
user_access = factories.TeamAccessFactory(team=second_team, user=user, role=role)
|
||||
|
||||
response = client.get("/api/v1.0/teams/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
# By default, the teams are sorted by 'created_at' descending
|
||||
assert response.json() == [
|
||||
{
|
||||
# I have the abilities only on the team I have a specific role
|
||||
"abilities": local_team_abilities,
|
||||
"accesses": [str(user_access.pk)],
|
||||
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": 3,
|
||||
"id": str(second_team.pk),
|
||||
"name": "Second",
|
||||
"numchild": 1,
|
||||
"path": second_team.path,
|
||||
"service_providers": [],
|
||||
"updated_at": second_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
# For parent teams, I only have the ability to list/retrieve
|
||||
"abilities": {
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"manage_accesses": False,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"created_at": first_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": 2,
|
||||
"id": str(first_team.pk),
|
||||
"name": "First",
|
||||
"numchild": 1,
|
||||
"path": first_team.path,
|
||||
"service_providers": [],
|
||||
"updated_at": first_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
{
|
||||
# For parent teams, I only have the ability to list/retrieve
|
||||
"abilities": {
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"manage_accesses": False,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"created_at": root_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": 1,
|
||||
"id": str(root_team.pk),
|
||||
"name": "Root",
|
||||
"numchild": 1,
|
||||
"path": root_team.path,
|
||||
"service_providers": [],
|
||||
"updated_at": root_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role,local_team_abilities",
|
||||
[
|
||||
(
|
||||
"owner",
|
||||
{
|
||||
"delete": True,
|
||||
"get": True,
|
||||
"manage_accesses": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
"administrator",
|
||||
{
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"manage_accesses": True,
|
||||
"patch": True,
|
||||
"put": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
"member",
|
||||
{
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"manage_accesses": False,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_api_teams_list_authenticated_team_different_organization(
|
||||
client, role, local_team_abilities
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list teams they
|
||||
are an owner/administrator/member of and any parent teams
|
||||
only if from the same organization.
|
||||
"""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
user = factories.UserFactory(organization=organization)
|
||||
|
||||
other_organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
root_team = factories.TeamFactory(name="Root", organization=other_organization)
|
||||
first_team = factories.TeamFactory(
|
||||
name="First", parent_id=root_team.pk, organization=other_organization
|
||||
)
|
||||
second_team = factories.TeamFactory(
|
||||
name="Second", parent_id=first_team.pk, organization=other_organization
|
||||
)
|
||||
third_team = factories.TeamFactory(
|
||||
name="Third", parent_id=second_team.pk, organization=other_organization
|
||||
)
|
||||
_fourth_team = factories.TeamFactory(
|
||||
name="Fourth", parent_id=third_team.pk, organization=other_organization
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
# user is a member of the second team
|
||||
user_access = factories.TeamAccessFactory(team=second_team, user=user, role=role)
|
||||
|
||||
response = client.get("/api/v1.0/teams/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == [
|
||||
{
|
||||
# I have the abilities only on the team I have a specific role
|
||||
"abilities": local_team_abilities,
|
||||
"accesses": [str(user_access.pk)],
|
||||
"created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"depth": 3,
|
||||
"id": str(second_team.pk),
|
||||
"name": "Second",
|
||||
"numchild": 1,
|
||||
"path": second_team.path,
|
||||
"service_providers": [],
|
||||
"updated_at": second_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -67,9 +67,80 @@ def test_api_teams_retrieve_authenticated_related():
|
||||
]
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"abilities": team.get_abilities(user),
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 1,
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"numchild": 0,
|
||||
"path": team.path,
|
||||
"service_providers": [],
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator", "member"],
|
||||
)
|
||||
def test_api_teams_retrieve_authenticated_related_parent(client, role):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a parent team
|
||||
to which they are related through the child team whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
root_team = factories.TeamFactory(name="Root")
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
|
||||
|
||||
# user is a member of the second team
|
||||
factories.TeamAccessFactory(team=second_team, user=user, role=role)
|
||||
|
||||
response = client.get(f"/api/v1.0/teams/{first_team.pk!s}/")
|
||||
|
||||
# the abilities enforces the "get" via the queryset
|
||||
abilities = first_team.get_abilities(user)
|
||||
abilities["get"] = True
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"abilities": abilities,
|
||||
"accesses": [],
|
||||
"created_at": first_team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 2,
|
||||
"id": str(first_team.pk),
|
||||
"name": first_team.name,
|
||||
"numchild": 1,
|
||||
"path": first_team.path,
|
||||
"service_providers": [],
|
||||
"updated_at": first_team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator", "member"],
|
||||
)
|
||||
def test_api_teams_retrieve_authenticated_related_children(client, role):
|
||||
"""
|
||||
Authenticated users should NOT be allowed to retrieve a child team
|
||||
to which they are related through the parent team whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
root_team = factories.TeamFactory(name="Root")
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
|
||||
|
||||
# user is a member of the first team
|
||||
factories.TeamAccessFactory(team=first_team, user=user, role=role)
|
||||
|
||||
response = client.get(f"/api/v1.0/teams/{second_team.pk!s}/")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
@@ -14,7 +14,7 @@ from rest_framework.status import (
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_api_teams_update_authenticated_administrators():
|
||||
team.refresh_from_db()
|
||||
final_values = serializers.TeamSerializer(instance=team).data
|
||||
for key, value in final_values.items():
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
if key in ["id", "accesses", "created_at", "depth", "path", "numchild"]:
|
||||
assert value == initial_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > initial_values[key]
|
||||
@@ -152,7 +152,7 @@ def test_api_teams_update_authenticated_owners():
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
for key, value in team_values.items():
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
if key in ["id", "accesses", "created_at", "depth", "path", "numchild"]:
|
||||
assert value == old_team_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_team_values[key]
|
||||
@@ -188,3 +188,106 @@ 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_authenticated_owners_add_service_providers():
|
||||
"""
|
||||
Owners of a team should be allowed to update its service providers.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
new_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
service_provider_1 = factories.ServiceProviderFactory()
|
||||
service_provider_2 = factories.ServiceProviderFactory()
|
||||
new_team_values["service_providers"] = [
|
||||
service_provider_1.pk,
|
||||
service_provider_2.pk,
|
||||
]
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
assert team.service_providers.count() == 2
|
||||
assert set(team.service_providers.all()) == {service_provider_1, service_provider_2}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator", "member"],
|
||||
)
|
||||
def test_api_teams_update_whatever_access_of_child_team(client, role):
|
||||
"""
|
||||
Being member, administrator or owner of a team should not grant
|
||||
authorization to update a parent team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
root_team = factories.TeamFactory(name="Root")
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
|
||||
|
||||
# user is a member of the second team
|
||||
factories.TeamAccessFactory(team=second_team, user=user, role=role)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/teams/{first_team.pk}/",
|
||||
{
|
||||
"name": "New name",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
first_team.refresh_from_db()
|
||||
assert first_team.name == "First"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator", "member"],
|
||||
)
|
||||
def test_api_teams_update_whatever_access_of_parent_team(client, role):
|
||||
"""
|
||||
Being member, administrator or owner of a team should not grant
|
||||
authorization to update a child team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
root_team = factories.TeamFactory(name="Root")
|
||||
first_team = factories.TeamFactory(name="First", parent_id=root_team.pk)
|
||||
second_team = factories.TeamFactory(name="Second", parent_id=first_team.pk)
|
||||
|
||||
# user is a member of the first team
|
||||
factories.TeamAccessFactory(team=first_team, user=user, role=role)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/teams/{second_team.pk}/",
|
||||
{
|
||||
"name": "New name",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
second_team.refresh_from_db()
|
||||
assert second_team.name == "Second"
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
CONTACT_DATA = {
|
||||
"emails": [
|
||||
{"type": "Work", "value": "john.doe@work.com"},
|
||||
{"type": "Home", "value": "john.doe@home.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
{"type": "Other", "value": "(987) 654-3210"},
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"type": "Home",
|
||||
"street": "123 Main St",
|
||||
"city": "Cityville",
|
||||
"state": "CA",
|
||||
"zip": "12345",
|
||||
"country": "USA",
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{"type": "Blog", "value": "http://personalwebsite.com"},
|
||||
{"type": "Website", "value": "http://workwebsite.com"},
|
||||
],
|
||||
"customFields": {"custom_field_1": "value1", "custom_field_2": "value2"},
|
||||
"organizations": [
|
||||
{
|
||||
"name": "ACME Corporation",
|
||||
"department": "IT",
|
||||
"jobTitle": "Software Engineer",
|
||||
},
|
||||
{
|
||||
"name": "XYZ Ltd",
|
||||
"department": "Marketing",
|
||||
"jobTitle": "Marketing Specialist",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_no_query():
|
||||
"""
|
||||
Authenticated users should be able to list contacts without applying a query.
|
||||
Profile and base contacts should be excluded.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
# Let's have 5 contacts in database:
|
||||
assert user.profile_contact is not None # Excluded because profile contact
|
||||
base_contact = factories.BaseContactFactory() # Excluded because overridden
|
||||
factories.ContactFactory(
|
||||
base=base_contact
|
||||
) # Excluded because belongs to other user
|
||||
contact2 = factories.ContactFactory(
|
||||
base=base_contact, owner=user, full_name="Bernard"
|
||||
) # Included
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"id": str(contact2.id),
|
||||
"base": str(base_contact.id),
|
||||
"owner": str(contact2.owner.id),
|
||||
"data": contact2.data,
|
||||
"full_name": contact2.full_name,
|
||||
"short_name": contact2.short_name,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_by_full_name():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case insensitive and
|
||||
partial query on the full name.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="David Bowman")
|
||||
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
|
||||
frank = factories.BaseContactFactory(full_name="Frank Poole")
|
||||
factories.BaseContactFactory(full_name="Heywood Floyd")
|
||||
|
||||
# Full query should work
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/contacts/?q=ank")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ole")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ool")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
|
||||
|
||||
# Unaccented full name
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=EEE")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=AAA")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_content():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented query should find unaccented content."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=ééé")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=ààà")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a user."""
|
||||
client = APIClient()
|
||||
contact = factories.ContactFactory()
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_owned():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"base": str(contact.base.id),
|
||||
"owner": str(contact.owner.id),
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve public contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"base": None,
|
||||
"owner": None,
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve another user's contacts.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_anonymous_forbidden():
|
||||
"""Anonymous users should not be able to create contacts via the API."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David",
|
||||
"short_name": "Bowman",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert not models.Contact.objects.exists()
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_missing_base():
|
||||
"""Anonymous users should be able to create users."""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert models.Contact.objects.exists() is False
|
||||
|
||||
assert response.json() == {"base": ["This field is required."]}
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_successful():
|
||||
"""Authenticated users should be able to create contacts."""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
base_contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Existing override for another user should not interfere
|
||||
factories.ContactFactory(base=base_contact)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"base": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Contact.objects.count() == 3
|
||||
|
||||
contact = models.Contact.objects.get(owner=user)
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"base": str(base_contact.id),
|
||||
"data": CONTACT_DATA,
|
||||
"full_name": "David Bowman",
|
||||
"owner": str(user.id),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
assert contact.full_name == "David Bowman"
|
||||
assert contact.short_name == "Dave"
|
||||
assert contact.data == CONTACT_DATA
|
||||
assert contact.base == base_contact
|
||||
assert contact.owner == user
|
||||
|
||||
|
||||
@override_settings(ALLOW_API_USER_CREATE=True)
|
||||
def test_api_contacts_create_authenticated_existing_override():
|
||||
"""
|
||||
Trying to create a contact for base contact that is already overridden by the user
|
||||
should receive a 400 error.
|
||||
"""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
base_contact = factories.BaseContactFactory()
|
||||
factories.ContactFactory(base=base_contact, owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"base": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
assert response.json() == {
|
||||
"__all__": ["Contact with this Owner and Base already exists."]
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_owned():
|
||||
"""
|
||||
Authenticated users should be allowed to update their own contacts.
|
||||
"""
|
||||
user = factories.UserFactory(profile_contact=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["base", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to update their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["base", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to update contacts owned by other users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory() # owned by another user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a list of contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 4
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of contacts."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = client.delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert models.Contact.objects.count() == 4
|
||||
|
||||
|
||||
def test_api_contacts_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a public contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be allowed to delete a contact they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Contact.objects.count() == 1
|
||||
assert models.Contact.objects.filter(id=contact.id).exists() is False
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to delete their profile contact.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(owner=user, base=None)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Contact.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a contact they don't own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 2
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Test stats endpoint
|
||||
"""
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories as core_factories
|
||||
|
||||
from mailbox_manager import factories as domains_factories
|
||||
from mailbox_manager import models as domains_models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_stats__anonymous(django_assert_num_queries):
|
||||
"""Stats endpoint should be available even when not connected."""
|
||||
|
||||
domains_factories.MailDomainFactory.create_batch(5)
|
||||
core_factories.TeamFactory.create_batch(3)
|
||||
|
||||
# clear cache to allow stats count
|
||||
cache.clear()
|
||||
with django_assert_num_queries(5):
|
||||
response = APIClient().get("/api/v1.0/stats/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_users": 0,
|
||||
"mau": 0,
|
||||
"domains": 5,
|
||||
"mailboxes": 0,
|
||||
"teams": 3,
|
||||
}
|
||||
# no new request made due to caching
|
||||
with django_assert_num_queries(0):
|
||||
response = APIClient().get("/api/v1.0/stats/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_users": 0,
|
||||
"mau": 0,
|
||||
"domains": 5,
|
||||
"mailboxes": 0,
|
||||
"teams": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_api_stats__expected_count():
|
||||
"""Objects should be correctly counted."""
|
||||
|
||||
core_factories.UserFactory.create_batch(4)
|
||||
logged_in_users = core_factories.UserFactory.create_batch(6)
|
||||
client = APIClient()
|
||||
for user in logged_in_users:
|
||||
client.force_login(user)
|
||||
|
||||
core_factories.TeamFactory.create_batch(3)
|
||||
domains_factories.MailDomainFactory.create_batch(2)
|
||||
domains_factories.MailboxFactory.create_batch(
|
||||
10, domain=domains_models.MailDomain.objects.all()[1]
|
||||
)
|
||||
|
||||
# clear cache to allow stats count
|
||||
cache.clear()
|
||||
response = APIClient().get("/api/v1.0/stats/")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"total_users": 10,
|
||||
"mau": 6,
|
||||
"domains": 2,
|
||||
"mailboxes": 10,
|
||||
"teams": 3,
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@@ -1,848 +0,0 @@
|
||||
"""
|
||||
Test users API endpoints in the People core app.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.api.viewsets import Pagination
|
||||
from core.factories import TeamAccessFactory
|
||||
|
||||
from mailbox_manager.factories import MailDomainAccessFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_users_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list users."""
|
||||
factories.UserFactory()
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/users/")
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert "Authentication credentials were not provided." in response.content.decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list all users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(response.json()["results"]) == 3
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_email():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(email="david.bowman@work.com", name=None)
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com", name=None)
|
||||
frank = factories.UserFactory(email="frank_poole@work.com", name=None)
|
||||
factories.UserFactory(email="heywood_floyd@work.com", name=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(dave.id)
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/users/?q=fran")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.id)
|
||||
|
||||
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"]]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.is_device,
|
||||
"is_staff": frank.is_staff,
|
||||
"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),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_name():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(name="Dave bowman", email=None)
|
||||
nicole = factories.UserFactory(name="nicole foole", email=None)
|
||||
frank = factories.UserFactory(name="frank poolé", email=None)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=dave",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(dave.id)
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/users/?q=fran")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.id)
|
||||
|
||||
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"]]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=oole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.is_device,
|
||||
"is_staff": frank.is_staff,
|
||||
"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),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_name_and_email():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name and email.
|
||||
"""
|
||||
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com", name="nicole foole")
|
||||
oleg = factories.UserFactory(email="oleg_poole@work.com", name=None)
|
||||
david = factories.UserFactory(email=None, name="david role")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
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"]]
|
||||
|
||||
assert user_ids == [str(david.id), str(oleg.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_exclude_users_already_in_team(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to search users
|
||||
but the result should exclude all users already in the given team.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(name="dave bowman", email=None)
|
||||
nicole = factories.UserFactory(name="nicole foole", email=None)
|
||||
frank = factories.UserFactory(name="frank poole", email=None)
|
||||
mary = factories.UserFactory(name="mary poole", email=None)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
factories.UserFactory(name="Andrei Smyslov", email=None)
|
||||
factories.TeamFactory.create_batch(10)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Add Dave and Frank in the same team
|
||||
team = factories.TeamFactory(
|
||||
users=[
|
||||
(dave, models.RoleChoices.MEMBER),
|
||||
(frank, models.RoleChoices.MEMBER),
|
||||
]
|
||||
)
|
||||
factories.TeamFactory(users=[(nicole, models.RoleChoices.MEMBER)])
|
||||
|
||||
# Search user to add him/her to a team, we give a team id to the request
|
||||
# to exclude all users already in the team
|
||||
|
||||
# We can't find David Bowman because he is already a member of the given team
|
||||
# 2 queries are needed here:
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?q=bowman&team_id={team.id}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == []
|
||||
|
||||
# We can only find Nicole and Mary because Frank is already a member of the given team
|
||||
# 4 queries are needed here:
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
# - User
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?q=ool&team_id={team.id}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = sorted([user["id"] for user in response.json()["results"]])
|
||||
assert user_ids == sorted([str(mary.id), str(nicole.id)])
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="eva karl")
|
||||
dave = factories.UserFactory(
|
||||
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full address
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@intensework.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
# Partial query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="eva karl")
|
||||
dave = factories.UserFactory(email="david.bowman@work.com", name="david bowman")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Full uppercase query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
# Partial uppercase email
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
helene = factories.UserFactory(email="helene.bowman@work.com", name="helene bowman")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Accented full query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(helene.id)]
|
||||
|
||||
# Unaccented partial email
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(helene.id)]
|
||||
|
||||
|
||||
@mock.patch.object(Pagination, "get_page_size", return_value=3)
|
||||
def test_api_users_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(4)
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 5
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/users/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 5
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/users/"
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", [1, 10, 99, 100])
|
||||
def test_api_users_list_pagination_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for i in range(page_size):
|
||||
factories.UserFactory.create(email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == page_size + 1
|
||||
assert len(content["results"]) == page_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", [101, 200])
|
||||
def test_api_users_list_pagination_wrong_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should be limited to "max_page_size"."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for i in range(page_size):
|
||||
factories.UserFactory.create(email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == page_size + 1
|
||||
|
||||
# Length should not exceed "max_page_size" default value
|
||||
assert len(content["results"]) == 100
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
"""Anonymous users should not be allowed to list users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated():
|
||||
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Define profile contact
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/me/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"name": str(user.name),
|
||||
"email": str(user.email),
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"abilities": {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": False, "can_view": False},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated_abilities():
|
||||
"""
|
||||
Authenticated users should be able to retrieve their own user via the "/users/me" path
|
||||
with the proper abilities.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Define profile contact
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
# Test the mailboxes abilities
|
||||
mail_domain_access = MailDomainAccessFactory(user=user)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
}
|
||||
|
||||
# Test the teams abilities - user is not an admin/owner
|
||||
team_access = TeamAccessFactory(user=user, role=models.RoleChoices.MEMBER)
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
}
|
||||
|
||||
# Test the teams abilities - user is an admin/owner
|
||||
team_access.role = models.RoleChoices.ADMIN
|
||||
team_access.save()
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
}
|
||||
|
||||
# Test the mailboxes abilities - user has no mail domain access anymore
|
||||
mail_domain_access.delete()
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": False, "can_view": False},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a user."""
|
||||
client = APIClient()
|
||||
user = factories.UserFactory()
|
||||
response = client.get(f"/api/v1.0/users/{user.id!s}/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve their own user.
|
||||
The returned object should not contain the password.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "GET" not allowed.'}
|
||||
|
||||
|
||||
def test_api_users_retrieve_authenticated_other():
|
||||
"""
|
||||
Authenticated users should be able to retrieve another user's detail view with
|
||||
limited information.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "GET" not allowed.'}
|
||||
|
||||
|
||||
def test_api_users_create_anonymous():
|
||||
"""Anonymous users should not be able to create users via the API."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/users/",
|
||||
{
|
||||
"language": "fr-fr",
|
||||
"password": "mypassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert "Authentication credentials were not provided." in response.content.decode(
|
||||
"utf-8"
|
||||
)
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_users_create_authenticated():
|
||||
"""Authenticated users should not be able to create users via the API."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/users/",
|
||||
{
|
||||
"language": "fr-fr",
|
||||
"password": "mypassword",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "POST" not allowed.'}
|
||||
assert models.User.objects.exclude(id=user.id).exists() is False
|
||||
|
||||
|
||||
def test_api_users_update_anonymous():
|
||||
"""Anonymous users should not be able to update users via the API."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
new_user_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_update_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to update their own user but only "language"
|
||||
and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
new_user_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
if key in ["language", "timezone"]:
|
||||
assert value == new_user_values[key]
|
||||
else:
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_update_authenticated_other():
|
||||
"""Authenticated users should not be allowed to update other users."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
new_user_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_patch_anonymous():
|
||||
"""Anonymous users should not be able to patch users via the API."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = APIClient().patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_patch_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to patch their own user but only "language"
|
||||
and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
if key in ["language", "timezone"]:
|
||||
assert value == new_user_values[key]
|
||||
else:
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_patch_authenticated_other():
|
||||
"""Authenticated users should not be allowed to patch other users."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=other_user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
other_user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=other_user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_delete_list_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a list of users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete("/api/v1.0/users/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.User.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_users_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of users."""
|
||||
user = factories.UserFactory()
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 3
|
||||
|
||||
|
||||
def test_api_users_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a user."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_users_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a user other than themselves.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_users_delete_self():
|
||||
"""Authenticated users should not be able to delete their own user."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 1
|
||||
@@ -17,58 +17,52 @@ def test_models_contacts_str_full_name():
|
||||
assert str(contact) == "David Bowman"
|
||||
|
||||
|
||||
def test_models_contacts_str_short_name():
|
||||
"""The str representation should be the contact's short name if full name is not set."""
|
||||
contact = factories.ContactFactory(full_name=None, short_name="Dave")
|
||||
assert str(contact) == "Dave"
|
||||
|
||||
|
||||
def test_models_contacts_base_self():
|
||||
"""A contact should not point to itself as a base contact."""
|
||||
"""A contact should not override itself."""
|
||||
contact = factories.ContactFactory()
|
||||
contact.base = contact
|
||||
contact.override = contact
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
contact.save()
|
||||
|
||||
error_message = (
|
||||
"{'__all__': ['A contact cannot point to a base contact that itself points to a "
|
||||
"base contact.', 'A contact cannot be based on itself.']}"
|
||||
"{'__all__': ['A contact cannot override a contact that itself overrides a contact.',"
|
||||
" 'A contact cannot override itself.']}"
|
||||
)
|
||||
assert str(excinfo.value) == error_message
|
||||
|
||||
|
||||
def test_models_contacts_base_to_base():
|
||||
"""A contact should not point to a base contact that is itself derived from a base contact."""
|
||||
contact = factories.ContactFactory()
|
||||
"""A contact should not override a contact that is itself overrides contact."""
|
||||
contact = factories.OverrideContactFactory()
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.ContactFactory(base=contact)
|
||||
factories.OverrideContactFactory(override=contact)
|
||||
|
||||
error_message = (
|
||||
"{'__all__': ['A contact cannot point to a base contact that itself points to a "
|
||||
"base contact.']}"
|
||||
"{'__all__': ['A contact cannot override a contact that "
|
||||
"itself overrides a contact.']}"
|
||||
)
|
||||
assert str(excinfo.value) == error_message
|
||||
|
||||
|
||||
def test_models_contacts_owner_base_unique():
|
||||
"""There should be only one contact deriving from a given base contact for a given owner."""
|
||||
contact = factories.ContactFactory()
|
||||
"""There should be only one contact overriding a contact for a given owner."""
|
||||
contact = factories.OverrideContactFactory()
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.ContactFactory(base=contact.base, owner=contact.owner)
|
||||
factories.OverrideContactFactory(override=contact.override, owner=contact.owner)
|
||||
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== "{'__all__': ['Contact with this Owner and Base already exists.']}"
|
||||
== "{'__all__': ['Contact with this Owner and Override already exists.']}"
|
||||
)
|
||||
|
||||
|
||||
def test_models_contacts_base_not_owned():
|
||||
"""A contact cannot have a base and not be owned."""
|
||||
"""A contact cannot override a contact without being owned."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.ContactFactory(owner=None)
|
||||
factories.OverrideContactFactory(owner=None)
|
||||
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
@@ -78,10 +72,8 @@ def test_models_contacts_base_not_owned():
|
||||
|
||||
def test_models_contacts_profile_not_owned():
|
||||
"""A contact cannot be defined as profile for a user if is not owned."""
|
||||
base_contact = factories.ContactFactory(owner=None, base=None)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.UserFactory(profile_contact=base_contact)
|
||||
factories.ContactFactory(owner=None, user=factories.UserFactory())
|
||||
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
@@ -94,7 +86,8 @@ def test_models_contacts_profile_owned_by_other():
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.UserFactory(profile_contact=contact)
|
||||
contact.user = factories.UserFactory()
|
||||
contact.save()
|
||||
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
|
||||
@@ -248,7 +248,9 @@ def test_models_team_invitations_email():
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
factories.TeamAccessFactory(team=team)
|
||||
invitation = factories.InvitationFactory(team=team, email="john@people.com")
|
||||
invitation = factories.InvitationFactory(
|
||||
team=team, email="john@people.com", issuer__language="fr-fr"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
@@ -257,10 +259,10 @@ def test_models_team_invitations_email():
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == [invitation.email]
|
||||
assert email.subject == "Invitation to join Desk!"
|
||||
assert email.subject == "Invitation à rejoindre La Régie!"
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation to join Desk!" in email_content
|
||||
assert "Invitation à rejoindre La Régie!" in email_content
|
||||
assert "[//example.com]" in email_content
|
||||
|
||||
|
||||
|
||||
@@ -134,3 +134,86 @@ def test_models_teams_get_abilities_preset_role(django_assert_num_queries):
|
||||
"put": False,
|
||||
"manage_accesses": False,
|
||||
}
|
||||
|
||||
|
||||
# test trees
|
||||
|
||||
|
||||
def test_models_teams_create_root_team():
|
||||
"""Create a root team."""
|
||||
team = models.Team.add_root(name="Root Team")
|
||||
assert team.is_root()
|
||||
assert team.name == "Root Team"
|
||||
|
||||
|
||||
def test_models_teams_create_child_team():
|
||||
"""Create a child team."""
|
||||
root_team = models.Team.add_root(name="Root Team")
|
||||
child_team = root_team.add_child(name="Child Team")
|
||||
assert child_team.is_child_of(root_team)
|
||||
assert child_team.name == "Child Team"
|
||||
assert child_team.get_parent() == root_team
|
||||
|
||||
|
||||
def test_models_teams_create_grandchild_team():
|
||||
"""Create a grandchild team."""
|
||||
root_team = models.Team.add_root(name="Root Team")
|
||||
child_team = root_team.add_child(name="Child Team")
|
||||
grandchild_team = child_team.add_child(name="Grandchild Team")
|
||||
assert grandchild_team.is_child_of(child_team)
|
||||
assert grandchild_team.name == "Grandchild Team"
|
||||
assert grandchild_team.get_parent() == child_team
|
||||
|
||||
|
||||
def test_models_teams_move_team():
|
||||
"""Move a team to another parent."""
|
||||
root_team = models.Team.add_root(name="Root Team")
|
||||
child_team = root_team.add_child(name="Child Team")
|
||||
new_root_team = models.Team.add_root(name="New Root Team")
|
||||
child_team.move(new_root_team, pos="first-child")
|
||||
child_team.refresh_from_db()
|
||||
assert child_team.get_parent(update=True) == new_root_team
|
||||
|
||||
|
||||
def test_models_teams_delete_team():
|
||||
"""
|
||||
Delete a parent team also deletes children.
|
||||
|
||||
This might not be what we want, but it's the default behavior of treebeard.
|
||||
"""
|
||||
root_team = models.Team.add_root(name="Root Team")
|
||||
root_team.add_child(name="Child Team")
|
||||
|
||||
assert models.Team.objects.all().count() == 2
|
||||
root_team.delete()
|
||||
|
||||
assert models.Team.objects.all().count() == 0
|
||||
|
||||
|
||||
def test_models_teams_manager_create():
|
||||
"""Create a team using the manager."""
|
||||
team = models.Team.objects.create(name="Team")
|
||||
assert team.is_root()
|
||||
assert team.name == "Team"
|
||||
|
||||
child_team = models.Team.objects.create(name="Child Team", parent_id=team.pk)
|
||||
assert child_team.is_child_of(team)
|
||||
assert child_team.name == "Child Team"
|
||||
|
||||
|
||||
def test_models_teams_tree_alphabet():
|
||||
"""Test the creation of teams with treebeard methods."""
|
||||
organization = factories.OrganizationFactory(with_registration_id=True)
|
||||
models.Team.load_bulk(
|
||||
[
|
||||
{
|
||||
"data": {
|
||||
"name": f"team-{i}",
|
||||
"organization_id": organization.pk,
|
||||
}
|
||||
}
|
||||
for i in range(len(models.Team.alphabet) * 2)
|
||||
]
|
||||
)
|
||||
|
||||
assert models.Team.objects.count() == len(models.Team.alphabet) * 2
|
||||
|
||||
@@ -133,11 +133,11 @@ def test_models_users_email_not_unique():
|
||||
def test_models_users_profile_not_owned():
|
||||
"""A user cannot declare as profile a contact that not is owned."""
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory(base=None, owner=None)
|
||||
contact = factories.ContactFactory(override=None, owner=None)
|
||||
|
||||
user.profile_contact = contact
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
user.save()
|
||||
contact.user = user
|
||||
contact.save()
|
||||
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
@@ -150,9 +150,9 @@ def test_models_users_profile_owned_by_other():
|
||||
user = factories.UserFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
user.profile_contact = contact
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
user.save()
|
||||
contact.user = user
|
||||
contact.save()
|
||||
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Tests for the raw SQL utility functions."""
|
||||
|
||||
from django.db.models.expressions import RawSQL
|
||||
|
||||
from core.models import Contact
|
||||
from core.utils.raw_sql import gen_sql_filter_json_array
|
||||
|
||||
|
||||
def test_gen_sql_filter_json_array():
|
||||
"""Test the generation of a raw SQL query to filter on a JSON array."""
|
||||
raw_sql = gen_sql_filter_json_array(Contact, "data->'emails'", "value", "blah")
|
||||
|
||||
assert isinstance(raw_sql, RawSQL)
|
||||
assert raw_sql.sql == (
|
||||
"SELECT people_contact.id FROM people_contact, "
|
||||
"jsonb_array_elements(data->'emails') AS temp_filter_table WHERE "
|
||||
"unaccent(temp_filter_table->>'value') ILIKE unaccent(%s)"
|
||||
)
|
||||
assert raw_sql.params == ["%blah%"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Users tests package."""
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Test users API endpoints in the People core app: focus on "create" action
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_users_create_anonymous():
|
||||
"""Anonymous users should not be able to create users via the API."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/users/",
|
||||
{
|
||||
"language": "fr-fr",
|
||||
"password": "mypassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert "Authentication credentials were not provided." in response.content.decode(
|
||||
"utf-8"
|
||||
)
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_users_create_authenticated():
|
||||
"""Authenticated users should not be able to create users via the API."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/users/",
|
||||
{
|
||||
"language": "fr-fr",
|
||||
"password": "mypassword",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "POST" not allowed.'}
|
||||
assert models.User.objects.exclude(id=user.id).exists() is False
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Test users API endpoints in the People core app: focus on "delete" action
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_users_delete_list_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a list of users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete("/api/v1.0/users/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.User.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_users_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of users."""
|
||||
user = factories.UserFactory()
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 3
|
||||
|
||||
|
||||
def test_api_users_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a user."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_users_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a user other than themselves.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_users_delete_self():
|
||||
"""Authenticated users should not be able to delete their own user."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 1
|
||||
@@ -0,0 +1,528 @@
|
||||
"""
|
||||
Test users API endpoints in the People core app: focus on "list" action
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import jq
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api.client.viewsets import Pagination
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_users_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list users."""
|
||||
factories.UserFactory()
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/users/")
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert "Authentication credentials were not provided." in response.content.decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list all users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(response.json()["results"]) == 3
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_response_content(
|
||||
client, django_assert_num_queries
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list all users with the expected output.
|
||||
"""
|
||||
user_organization = factories.OrganizationFactory(
|
||||
with_registration_id=True, name="HAL 9000"
|
||||
)
|
||||
user = factories.UserFactory(
|
||||
organization=user_organization,
|
||||
email="kylefields@example.net",
|
||||
name="Mr. Christopher Curtis",
|
||||
language="en-us",
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
other_user_organization = factories.OrganizationFactory(
|
||||
with_registration_id=True, name="Corp. Inc."
|
||||
)
|
||||
other_user = factories.UserFactory(
|
||||
organization=other_user_organization,
|
||||
email="sara83@example.com",
|
||||
name="Christopher Thompson",
|
||||
language="fr-fr",
|
||||
)
|
||||
|
||||
with django_assert_num_queries(3): # get User, Count, Users
|
||||
response = client.get("/api/v1.0/users/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
json = response.json()
|
||||
edited_json = (
|
||||
jq.compile(".results[] |= (.organization |= del(.registration_id_list))")
|
||||
.input(json)
|
||||
.first()
|
||||
)
|
||||
assert edited_json == {
|
||||
"count": 2,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"email": "sara83@example.com",
|
||||
"id": str(other_user.pk),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"language": "fr-fr",
|
||||
"name": "Christopher Thompson",
|
||||
"organization": {
|
||||
"id": str(other_user.organization.pk),
|
||||
"name": "Corp. Inc.",
|
||||
},
|
||||
"timezone": "UTC",
|
||||
},
|
||||
{
|
||||
"email": "kylefields@example.net",
|
||||
"id": str(user.pk),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"language": "en-us",
|
||||
"name": "Mr. Christopher Curtis",
|
||||
"organization": {
|
||||
"id": str(user.organization.pk),
|
||||
"name": "HAL 9000",
|
||||
},
|
||||
"timezone": "UTC",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_email():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(email="david.bowman@work.com", name=None)
|
||||
nicole = factories.UserFactory(
|
||||
email="nicole_foole@work.com", name=None, with_organization=True
|
||||
)
|
||||
frank = factories.UserFactory(
|
||||
email="frank_poole@work.com", name=None, with_organization=True
|
||||
)
|
||||
factories.UserFactory(email="heywood_floyd@work.com", name=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(dave.id)
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/users/?q=fran")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.id)
|
||||
|
||||
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"]]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
json = response.json()
|
||||
edited_json = (
|
||||
jq.compile(".results[] |= (.organization |= del(.registration_id_list))")
|
||||
.input(json)
|
||||
.first()
|
||||
)
|
||||
assert edited_json["results"] == [
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.is_device,
|
||||
"is_staff": frank.is_staff,
|
||||
"language": frank.language,
|
||||
"timezone": str(frank.timezone),
|
||||
"organization": {
|
||||
"id": str(frank.organization.pk),
|
||||
"name": frank.organization.name,
|
||||
},
|
||||
},
|
||||
{
|
||||
"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),
|
||||
"organization": {
|
||||
"id": str(nicole.organization.pk),
|
||||
"name": nicole.organization.name,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_name():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(name="Dave bowman", email=None)
|
||||
nicole = factories.UserFactory(
|
||||
name="nicole foole", email=None, with_organization=True
|
||||
)
|
||||
frank = factories.UserFactory(
|
||||
name="frank poolé", email=None, with_organization=True
|
||||
)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=dave",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(dave.id)
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/users/?q=fran")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.id)
|
||||
|
||||
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"]]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=oole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
json = response.json()
|
||||
edited_json = (
|
||||
jq.compile(".results[] |= (.organization |= del(.registration_id_list))")
|
||||
.input(json)
|
||||
.first()
|
||||
)
|
||||
assert edited_json["results"] == [
|
||||
{
|
||||
"id": str(frank.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.is_device,
|
||||
"is_staff": frank.is_staff,
|
||||
"language": frank.language,
|
||||
"timezone": str(frank.timezone),
|
||||
"organization": {
|
||||
"id": str(frank.organization.pk),
|
||||
"name": frank.organization.name,
|
||||
},
|
||||
},
|
||||
{
|
||||
"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),
|
||||
"organization": {
|
||||
"id": str(nicole.organization.pk),
|
||||
"name": nicole.organization.name,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_name_and_email():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name and email.
|
||||
"""
|
||||
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com", name="nicole foole")
|
||||
oleg = factories.UserFactory(email="oleg_poole@work.com", name=None)
|
||||
david = factories.UserFactory(email=None, name="david role")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
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"]]
|
||||
|
||||
assert user_ids == [str(david.id), str(oleg.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_exclude_users_already_in_team(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to search users
|
||||
but the result should exclude all users already in the given team.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
dave = factories.UserFactory(name="dave bowman", email=None)
|
||||
nicole = factories.UserFactory(name="nicole foole", email=None)
|
||||
frank = factories.UserFactory(name="frank poole", email=None)
|
||||
mary = factories.UserFactory(name="mary poole", email=None)
|
||||
factories.UserFactory(name="heywood floyd", email=None)
|
||||
factories.UserFactory(name="Andrei Smyslov", email=None)
|
||||
factories.TeamFactory.create_batch(10)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Add Dave and Frank in the same team
|
||||
team = factories.TeamFactory(
|
||||
users=[
|
||||
(dave, models.RoleChoices.MEMBER),
|
||||
(frank, models.RoleChoices.MEMBER),
|
||||
]
|
||||
)
|
||||
factories.TeamFactory(users=[(nicole, models.RoleChoices.MEMBER)])
|
||||
|
||||
# Search user to add him/her to a team, we give a team id to the request
|
||||
# to exclude all users already in the team
|
||||
|
||||
# We can't find David Bowman because he is already a member of the given team
|
||||
# 2 queries are needed here:
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?q=bowman&team_id={team.id}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == []
|
||||
|
||||
# We can only find Nicole and Mary because Frank is already a member of the given team
|
||||
# 4 queries are needed here:
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
# - User
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?q=ool&team_id={team.id}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = sorted([user["id"] for user in response.json()["results"]])
|
||||
assert user_ids == sorted([str(mary.id), str(nicole.id)])
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="eva karl")
|
||||
dave = factories.UserFactory(
|
||||
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full address
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@intensework.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
# Partial query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="eva karl")
|
||||
dave = factories.UserFactory(email="david.bowman@work.com", name="david bowman")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Full uppercase query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
# Partial uppercase email
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr", name="john doe")
|
||||
helene = factories.UserFactory(email="helene.bowman@work.com", name="helene bowman")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Accented full query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(helene.id)]
|
||||
|
||||
# Unaccented partial email
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(helene.id)]
|
||||
|
||||
|
||||
@mock.patch.object(Pagination, "get_page_size", return_value=3)
|
||||
def test_api_users_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(4)
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 5
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/users/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 5
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/users/"
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", [1, 10, 99, 100])
|
||||
def test_api_users_list_pagination_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for i in range(page_size):
|
||||
factories.UserFactory.create(email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == page_size + 1
|
||||
assert len(content["results"]) == page_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", [101, 200])
|
||||
def test_api_users_list_pagination_wrong_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should be limited to "max_page_size"."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for i in range(page_size):
|
||||
factories.UserFactory.create(email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == page_size + 1
|
||||
|
||||
# Length should not exceed "max_page_size" default value
|
||||
assert len(content["results"]) == 100
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Test users API endpoints in the People core app: focus on "retrieve" action
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.factories import TeamAccessFactory
|
||||
|
||||
from mailbox_manager.factories import MailDomainAccessFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
"""Anonymous users should not be allowed to list users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated():
|
||||
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
|
||||
user = factories.UserFactory(with_organization=True)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Define profile contact
|
||||
factories.ContactFactory(owner=user, user=user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/me/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"name": str(user.name),
|
||||
"email": str(user.email),
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"abilities": {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": False, "can_view": False},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
},
|
||||
"organization": {
|
||||
"id": str(user.organization.pk),
|
||||
"name": user.organization.name,
|
||||
"registration_id_list": user.organization.registration_id_list,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated_abilities(settings):
|
||||
"""
|
||||
Authenticated users should be able to retrieve their own user via the "/users/me" path
|
||||
with the proper abilities.
|
||||
"""
|
||||
settings.FEATURES = {
|
||||
"TEAMS_DISPLAY": False,
|
||||
"TEAMS_CREATE": True,
|
||||
"CONTACTS_DISPLAY": True,
|
||||
"CONTACTS_CREATE": True,
|
||||
"MAILBOXES_CREATE": True,
|
||||
}
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Define profile contact
|
||||
factories.ContactFactory(owner=user, user=user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
# Test the mailboxes abilities
|
||||
mail_domain_access = MailDomainAccessFactory(user=user)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
}
|
||||
|
||||
# Test the teams abilities - user is not an admin/owner
|
||||
team_access = TeamAccessFactory(user=user, role=models.RoleChoices.MEMBER)
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": False, "can_view": False},
|
||||
}
|
||||
|
||||
# Test the teams abilities - user is an admin/owner
|
||||
team_access.role = models.RoleChoices.ADMIN
|
||||
team_access.save()
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": True, "can_view": True},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
}
|
||||
|
||||
# Test the mailboxes abilities - user has no mail domain access anymore
|
||||
mail_domain_access.delete()
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["abilities"] == {
|
||||
"contacts": {"can_create": True, "can_view": True},
|
||||
"mailboxes": {"can_create": False, "can_view": False},
|
||||
"teams": {"can_create": True, "can_view": True},
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a user."""
|
||||
client = APIClient()
|
||||
user = factories.UserFactory()
|
||||
response = client.get(f"/api/v1.0/users/{user.id!s}/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve their own user.
|
||||
The returned object should not contain the password.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "GET" not allowed.'}
|
||||
|
||||
|
||||
def test_api_users_retrieve_authenticated_other():
|
||||
"""
|
||||
Authenticated users should be able to retrieve another user's detail view with
|
||||
limited information.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
)
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "GET" not allowed.'}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Test users API endpoints in the People core app: focus on "update" action
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.client import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_users_update_anonymous():
|
||||
"""Anonymous users should not be able to update users via the API."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
new_user_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_update_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to update their own user but only "language"
|
||||
and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
new_user_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
if key in ["language", "timezone"]:
|
||||
assert value == new_user_values[key]
|
||||
else:
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_update_authenticated_other():
|
||||
"""Authenticated users should not be allowed to update other users."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
new_user_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_patch_anonymous():
|
||||
"""Anonymous users should not be able to patch users via the API."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = APIClient().patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_patch_authenticated_self():
|
||||
"""
|
||||
Authenticated users should be able to patch their own user but only "language"
|
||||
and "timezone" fields.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
for key, value in user_values.items():
|
||||
if key in ["language", "timezone"]:
|
||||
assert value == new_user_values[key]
|
||||
else:
|
||||
assert value == old_user_values[key]
|
||||
|
||||
|
||||
def test_api_users_patch_authenticated_other():
|
||||
"""Authenticated users should not be allowed to patch other users."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=other_user).data)
|
||||
new_user_values = dict(
|
||||
serializers.UserSerializer(instance=factories.UserFactory()).data
|
||||
)
|
||||
|
||||
for key, new_value in new_user_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
other_user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=other_user).data)
|
||||
for key, value in user_values.items():
|
||||
assert value == old_user_values[key]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Helper functions to generate raw SQL queries for Django models."""
|
||||
|
||||
from typing import Type
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.expressions import RawSQL
|
||||
|
||||
|
||||
def gen_sql_filter_json_array(
|
||||
model: Type[models.Model],
|
||||
lookup_path: str,
|
||||
nested_key: str,
|
||||
lookup_value: str,
|
||||
) -> RawSQL:
|
||||
"""
|
||||
Filter a queryset on a nested JSON key in an array field with
|
||||
a case-insensitive and unaccent match.
|
||||
|
||||
Highly inspired from
|
||||
https://gist.github.com/TobeTek/df2e9783a64e431c228c513441eaa8df#file-utils-py
|
||||
|
||||
:param Type[models.Model] model: Your Django model to filter on
|
||||
:param str lookup_path: The lookup path of the array field/key in
|
||||
Postgres format e.g `data->"sub-key1"->"sub-key2"`
|
||||
:param str nested_key: The name of the nested key to filter on
|
||||
:param str lookup_value: The value to match/filter the queryset on
|
||||
"""
|
||||
# Disabling S608 Possible SQL injection vector through string-based query construction
|
||||
# because we are using Django's RawSQL with parameters.
|
||||
# Disabling S611 Use of `RawSQL` can lead to SQL injection vulnerabilities
|
||||
# for the same reason.
|
||||
|
||||
table_name = model._meta.db_table # noqa: SLF001
|
||||
|
||||
query = (
|
||||
f"SELECT {table_name}.id FROM {table_name}, " # noqa: S608
|
||||
f"jsonb_array_elements({lookup_path}) AS temp_filter_table "
|
||||
f"WHERE unaccent(temp_filter_table->>'{nested_key}') ILIKE unaccent(%s)"
|
||||
)
|
||||
|
||||
return RawSQL(sql=query, params=[f"%{lookup_value}%"]) # noqa: S611
|
||||
@@ -13,6 +13,7 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.text import slugify
|
||||
|
||||
from faker import Faker
|
||||
from treebeard.mp_tree import MP_Node
|
||||
|
||||
from core import models
|
||||
|
||||
@@ -45,7 +46,33 @@ class BulkQueue:
|
||||
if not objects:
|
||||
return
|
||||
|
||||
objects[0]._meta.model.objects.bulk_create(objects, ignore_conflicts=False) # noqa: SLF001
|
||||
objects_model = objects[0]._meta.model # noqa: SLF001
|
||||
if issubclass(objects_model, MP_Node):
|
||||
# For treebeard models, we need to create the tree structure
|
||||
# in a specific way. This is not perfect but it works for the
|
||||
# current use case.
|
||||
model_fields = {
|
||||
field
|
||||
for field in objects_model._meta.concrete_fields # noqa: SLF001
|
||||
if field.name not in {"depth", "numchild", "path"}
|
||||
}
|
||||
bulk_data = [
|
||||
{
|
||||
"data": {
|
||||
field.name: field.value_from_object(obj)
|
||||
for field in model_fields
|
||||
if field.value_from_object(obj)
|
||||
}
|
||||
}
|
||||
for obj in objects
|
||||
]
|
||||
objects_model.load_bulk(bulk_data)
|
||||
else:
|
||||
objects_model.objects.bulk_create(
|
||||
objects,
|
||||
ignore_conflicts=False,
|
||||
)
|
||||
|
||||
# In debug mode, Django keeps query cache which creates a memory leak in this case
|
||||
db.reset_queries()
|
||||
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
|
||||
@@ -192,21 +219,15 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
)
|
||||
|
||||
with Timeit(stdout, "Creating domains"):
|
||||
created = set()
|
||||
for _i in range(defaults.NB_OBJECTS["domains"]):
|
||||
name = fake.domain_name()
|
||||
if name in created:
|
||||
continue
|
||||
created.add(name)
|
||||
|
||||
slug = slugify(name)
|
||||
for i in range(defaults.NB_OBJECTS["domains"]):
|
||||
name = f"{fake.domain_name()}-i{i:d}"
|
||||
|
||||
queue.push(
|
||||
mailbox_models.MailDomain(
|
||||
name=name,
|
||||
# slug should be automatic but bulk_create doesn't use save
|
||||
slug=slug,
|
||||
status=random.choice(MailDomainStatusChoices.choices)[0],
|
||||
slug=slugify(name),
|
||||
status=random.choice(MailDomainStatusChoices.values),
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
@@ -233,8 +254,8 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
for role in models.RoleChoices.values:
|
||||
team_user = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.team-{role}@example.com",
|
||||
name=f"Jean Group {role.capitalize()}",
|
||||
email=f"e2e.team-{role}@example.com",
|
||||
name=f"E2E Group {role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
@@ -249,8 +270,8 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
for role in models.RoleChoices.values:
|
||||
user_with_mail = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.mail-{role}@example.com",
|
||||
name=f"Jean Mail {role.capitalize()}",
|
||||
email=f"e2e.mail-{role}@example.com",
|
||||
name=f"E2E Mail {role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
@@ -270,8 +291,8 @@ def create_demo(stdout): # pylint: disable=too-many-locals
|
||||
for domain_role in models.RoleChoices.values:
|
||||
team_mail_user = models.User(
|
||||
sub=uuid4(),
|
||||
email=f"jean.team-{team_role}-mail-{domain_role}@example.com",
|
||||
name=f"Jean Group {team_role.capitalize()} Mail {domain_role.capitalize()}",
|
||||
email=f"e2e.team-{team_role}-mail-{domain_role}@example.com",
|
||||
name=f"E2E Group {team_role.capitalize()} Mail {domain_role.capitalize()}",
|
||||
password="!",
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
|
||||
@@ -12,16 +12,17 @@ from core import models
|
||||
from demo import defaults
|
||||
from mailbox_manager import models as mailbox_models
|
||||
|
||||
TEST_NB_OBJECTS = {
|
||||
"users": 5,
|
||||
"teams": 3,
|
||||
"max_users_per_team": 5,
|
||||
"domains": 2,
|
||||
}
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
TEST_NB_OBJECTS = {
|
||||
"users": 100,
|
||||
"teams": 100,
|
||||
"max_users_per_team": 5,
|
||||
"domains": 100,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
@mock.patch.dict(defaults.NB_OBJECTS, TEST_NB_OBJECTS)
|
||||
def test_commands_create_demo():
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-11-18 23:02+0000\n"
|
||||
"POT-Creation-Date: 2025-02-03 10:27+0000\n"
|
||||
"PO-Revision-Date: 2024-01-03 23:15\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
@@ -17,314 +17,359 @@ msgstr ""
|
||||
"X-Crowdin-File: backend.pot\n"
|
||||
"X-Crowdin-File-ID: 2\n"
|
||||
|
||||
#: core/admin.py:55
|
||||
#: core/admin.py:63
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: core/admin.py:57
|
||||
#: core/admin.py:65
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:69
|
||||
#: core/admin.py:77
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/admin.py:108
|
||||
#: core/admin.py:116
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
msgstr "Utilisateur"
|
||||
|
||||
#: core/authentication/backends.py:89
|
||||
#: core/admin.py:218
|
||||
msgid "Run post creation plugins"
|
||||
msgstr "Exécuter les plugins de post-création"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Post creation plugins have been run for the selected organizations."
|
||||
msgstr ""
|
||||
"Les plugins de post-création ont été exécutés pour les organisations "
|
||||
"sélectionnées."
|
||||
|
||||
#: core/authentication/backends.py:94
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
"Les informations de l'utilisateur ne contiennent aucune identification "
|
||||
"reconnaissable"
|
||||
|
||||
#: core/authentication/backends.py:111
|
||||
#: core/authentication/backends.py:116
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
msgstr "Le compte de l'utilisateur est désactivé"
|
||||
|
||||
#: core/authentication/backends.py:157
|
||||
#: core/authentication/backends.py:162
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
"Les claims ne contiennent aucune identification reconnaissable pour "
|
||||
"l'utilisateur"
|
||||
|
||||
#: core/authentication/backends.py:176
|
||||
#: core/authentication/backends.py:181
|
||||
msgid "Claims contained no recognizable organization identification"
|
||||
msgstr ""
|
||||
"Les claims ne contiennent aucune identification reconnaissable pour "
|
||||
"l'organisation"
|
||||
|
||||
#: core/enums.py:23
|
||||
msgid "Failure"
|
||||
msgstr ""
|
||||
msgstr "En échec"
|
||||
|
||||
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:30
|
||||
#: core/enums.py:24 mailbox_manager/enums.py:21 mailbox_manager/enums.py:31
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/enums.py:25
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
msgstr "Réussi"
|
||||
|
||||
#: core/models.py:46
|
||||
#: core/models.py:47
|
||||
msgid "Member"
|
||||
msgstr ""
|
||||
msgstr "Membre"
|
||||
|
||||
#: core/models.py:47 core/models.py:59 mailbox_manager/enums.py:14
|
||||
#: core/models.py:48 core/models.py:60 mailbox_manager/enums.py:14
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:48 mailbox_manager/enums.py:15
|
||||
#: core/models.py:49 mailbox_manager/enums.py:15
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:71
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:72
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
msgid "id"
|
||||
msgstr "identifiant"
|
||||
|
||||
#: core/models.py:78
|
||||
msgid "created at"
|
||||
msgstr ""
|
||||
#: core/models.py:73
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Clé primaire pour l'enregistrement en tant que UUID"
|
||||
|
||||
#: core/models.py:79
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
msgid "created at"
|
||||
msgstr "Créé le"
|
||||
|
||||
#: core/models.py:84
|
||||
msgid "updated at"
|
||||
msgstr ""
|
||||
#: core/models.py:80
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Date et heure de création de l'enregistrement"
|
||||
|
||||
#: core/models.py:85
|
||||
msgid "updated at"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:86
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
|
||||
|
||||
#: core/models.py:116
|
||||
#: core/models.py:125
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:126
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:122
|
||||
#: core/models.py:129
|
||||
msgid "notes"
|
||||
msgstr "notes"
|
||||
|
||||
#: core/models.py:131
|
||||
msgid "contact information"
|
||||
msgstr ""
|
||||
msgstr "informations de contact"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:132
|
||||
msgid "A JSON object containing the contact information"
|
||||
msgstr ""
|
||||
msgstr "Un objet JSON contenant les informations de contact"
|
||||
|
||||
#: core/models.py:137
|
||||
#: core/models.py:146
|
||||
msgid "contact"
|
||||
msgstr ""
|
||||
msgstr "contact"
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:147
|
||||
msgid "contacts"
|
||||
msgstr ""
|
||||
msgstr "contacts"
|
||||
|
||||
#: core/models.py:262 core/models.py:331 mailbox_manager/models.py:24
|
||||
#: core/models.py:221 core/models.py:319 core/models.py:441
|
||||
#: mailbox_manager/models.py:24
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
#: core/models.py:223
|
||||
msgid "audience id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:228
|
||||
msgid "service provider"
|
||||
msgstr "fournisseur de service"
|
||||
|
||||
#: core/models.py:229
|
||||
msgid "service providers"
|
||||
msgstr "fournisseurs de service"
|
||||
|
||||
#: core/models.py:327
|
||||
msgid "registration ID list"
|
||||
msgstr ""
|
||||
msgstr "liste d'identifiants d'inscription"
|
||||
|
||||
#: core/models.py:279
|
||||
#: core/models.py:334
|
||||
msgid "domain list"
|
||||
msgstr ""
|
||||
msgstr "liste de domaines"
|
||||
|
||||
#: core/models.py:289
|
||||
#: core/models.py:350
|
||||
msgid "organization"
|
||||
msgstr ""
|
||||
msgstr "organisation"
|
||||
|
||||
#: core/models.py:290
|
||||
#: core/models.py:351
|
||||
msgid "organizations"
|
||||
msgstr ""
|
||||
msgstr "organisations"
|
||||
|
||||
#: core/models.py:297
|
||||
#: core/models.py:358
|
||||
msgid "An organization must have at least a registration ID or a domain."
|
||||
msgstr ""
|
||||
"Une organisation doit avoir au moins un identifiant d'inscription ou un "
|
||||
"domaine."
|
||||
|
||||
#: core/models.py:316
|
||||
#: core/models.py:426
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Entrez une sub valide. Cette valeur peut contenir uniquement des lettres, "
|
||||
"des chiffres et des caractères @/./+/-/_"
|
||||
|
||||
#: core/models.py:322
|
||||
#: core/models.py:432
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:434
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Requis. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_ "
|
||||
"uniquement."
|
||||
|
||||
#: core/models.py:330 core/models.py:737
|
||||
#: core/models.py:440 core/models.py:880
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
msgstr "adresse email"
|
||||
|
||||
#: core/models.py:343
|
||||
#: core/models.py:446
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:344
|
||||
#: core/models.py:447
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
|
||||
#: core/models.py:350
|
||||
#: core/models.py:453
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: core/models.py:353
|
||||
#: core/models.py:456
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:355
|
||||
#: core/models.py:458
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: core/models.py:358
|
||||
#: core/models.py:461
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:360
|
||||
#: core/models.py:463
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
msgstr "Si l'utilisateur peut se connecter à cette interface d'administration."
|
||||
|
||||
#: core/models.py:363
|
||||
#: core/models.py:466
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:366
|
||||
#: core/models.py:469
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Si cet utilisateur doit être considéré comme actif. Désélectionnez cette "
|
||||
"option au lieu de supprimer les comptes."
|
||||
|
||||
#: core/models.py:385
|
||||
#: core/models.py:488
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:386
|
||||
#: core/models.py:489
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:525
|
||||
#: core/models.py:623
|
||||
msgid "Organization/user relation"
|
||||
msgstr ""
|
||||
msgstr "Relation entre une organisation et un utilisateur"
|
||||
|
||||
#: core/models.py:526
|
||||
#: core/models.py:624
|
||||
msgid "Organization/user relations"
|
||||
msgstr ""
|
||||
msgstr "Relations entre une organisation et un utilisateur"
|
||||
|
||||
#: core/models.py:531
|
||||
#: core/models.py:629
|
||||
msgid "This user is already in this organization."
|
||||
msgstr ""
|
||||
msgstr "Cet utilisateur est déjà dans cette organisation."
|
||||
|
||||
#: core/models.py:563
|
||||
#: core/models.py:706
|
||||
msgid "Team"
|
||||
msgstr ""
|
||||
msgstr "Equipe"
|
||||
|
||||
#: core/models.py:564
|
||||
#: core/models.py:707
|
||||
msgid "Teams"
|
||||
msgstr ""
|
||||
msgstr "Equipes"
|
||||
|
||||
#: core/models.py:615
|
||||
#: core/models.py:758
|
||||
msgid "Team/user relation"
|
||||
msgstr ""
|
||||
msgstr "Relation entre une équipe et un utilisateur"
|
||||
|
||||
#: core/models.py:616
|
||||
#: core/models.py:759
|
||||
msgid "Team/user relations"
|
||||
msgstr ""
|
||||
msgstr "Relations entre une équipe et un utilisateur"
|
||||
|
||||
#: core/models.py:621
|
||||
#: core/models.py:764
|
||||
msgid "This user is already in this team."
|
||||
msgstr ""
|
||||
msgstr "Cet utilisateur est déjà dans cette équipe."
|
||||
|
||||
#: core/models.py:710
|
||||
#: core/models.py:853
|
||||
msgid "url"
|
||||
msgstr ""
|
||||
msgstr "url"
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:854
|
||||
msgid "secret"
|
||||
msgstr ""
|
||||
msgstr "secret"
|
||||
|
||||
#: core/models.py:720
|
||||
#: core/models.py:863
|
||||
msgid "Team webhook"
|
||||
msgstr ""
|
||||
msgstr "Webhook d'équipe"
|
||||
|
||||
#: core/models.py:721
|
||||
#: core/models.py:864
|
||||
msgid "Team webhooks"
|
||||
msgstr ""
|
||||
msgstr "Webhooks d'équipe"
|
||||
|
||||
#: core/models.py:754
|
||||
#: core/models.py:897
|
||||
msgid "Team invitation"
|
||||
msgstr ""
|
||||
msgstr "Invitation d'équipe"
|
||||
|
||||
#: core/models.py:755
|
||||
#: core/models.py:898
|
||||
msgid "Team invitations"
|
||||
msgstr ""
|
||||
msgstr "Invitations d'équipe"
|
||||
|
||||
#: core/models.py:780
|
||||
#: core/models.py:923
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
msgstr "Cette adresse email est déjà associée à un utilisateur enregistré."
|
||||
|
||||
#: core/models.py:822 core/models.py:828
|
||||
msgid "Invitation to join Desk!"
|
||||
msgstr ""
|
||||
#: core/models.py:965 core/models.py:971
|
||||
msgid "Invitation to join La Régie!"
|
||||
msgstr "Invitation à rejoindre La Régie!"
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
msgstr "Logo de l'entreprise"
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
msgstr "Bonjour %(name)s"
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
msgstr "Bonjour"
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
msgstr "Merci beaucoup pour votre visite!"
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
"Cette mail a été envoyée à %(email)s par <a href=\"%(href)s\">%(name)s</a>"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
msgstr "La Suite Numérique"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "Invitation to join a team"
|
||||
msgstr ""
|
||||
msgstr "Invitation à rejoindre une équipe"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:198
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "Welcome to"
|
||||
msgstr ""
|
||||
msgstr "Bienvenue sur"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:216
|
||||
#: core/templates/mail/text/invitation.txt:12
|
||||
msgid "Logo"
|
||||
msgstr ""
|
||||
msgstr "Logo"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
"We are delighted to welcome you to our community on Régie, your new "
|
||||
"We are delighted to welcome you to our community on La Régie, your new "
|
||||
"companion to simplify the management of your groups efficiently, "
|
||||
"intuitively, and securely."
|
||||
msgstr ""
|
||||
"Nous sommes ravis de vous accueillir dans notre communauté sur Régie, votre "
|
||||
"nouveau compagnon pour simplifier la gestion de vos groupes de manière "
|
||||
"efficace, intuitive et sécurisée."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
@@ -332,21 +377,25 @@ msgid ""
|
||||
"Our application is designed to help you organize, collaborate, and manage "
|
||||
"permissions."
|
||||
msgstr ""
|
||||
"Notre application est conçue pour vous aider à organiser, collaborer et "
|
||||
"gérer les permissions."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:236
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "With Régie, you will be able to:"
|
||||
msgstr ""
|
||||
msgid "With La Régie, you will be able to:"
|
||||
msgstr "Avec La Régie, vous pourrez :"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Create customized groups according to your specific needs."
|
||||
msgstr ""
|
||||
"Créer des groupes personnalisés en fonction de vos besoins spécifiques."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Invite members of your team or community in just a few clicks."
|
||||
msgstr ""
|
||||
"Inviter des membres de votre équipe ou de votre communauté en quelques clics."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
@@ -354,11 +403,15 @@ msgid ""
|
||||
"Plan events, meetings, or activities effortlessly with our integrated "
|
||||
"calendar."
|
||||
msgstr ""
|
||||
"Planifier des événements, des réunions ou des activités sans effort avec "
|
||||
"notre calendrier intégré."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:240
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Share documents, photos, and important information securely."
|
||||
msgstr ""
|
||||
"Partager des documents, des photos et des informations importantes de "
|
||||
"manière sécurisée."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:241
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
@@ -366,18 +419,22 @@ msgid ""
|
||||
"Facilitate exchanges and communication with our messaging and group "
|
||||
"discussion tools."
|
||||
msgstr ""
|
||||
"Faciliter les échanges et la communication avec nos outils de messagerie et "
|
||||
"de discussion de groupe."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:252
|
||||
#: core/templates/mail/text/invitation.txt:23
|
||||
msgid "Visit Régie"
|
||||
msgstr ""
|
||||
msgid "Visit La Régie"
|
||||
msgstr "Visiter La Régie"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:261
|
||||
#: core/templates/mail/text/invitation.txt:25
|
||||
msgid ""
|
||||
"We are confident that Régie will help you increase efficiency and "
|
||||
"We are confident that La Régie will help you increase efficiency and "
|
||||
"productivity while strengthening the bond among members."
|
||||
msgstr ""
|
||||
"Nous sommes convaincus que La Régie vous aidera à augmenter l'efficacité et "
|
||||
"la productivité tout en renforçant le lien entre les membres."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:266
|
||||
#: core/templates/mail/text/invitation.txt:26
|
||||
@@ -386,6 +443,10 @@ msgid ""
|
||||
"feedback and suggestions with us. Your feedback is valuable to us and will "
|
||||
"enable us to continually improve our service."
|
||||
msgstr ""
|
||||
"N'hésitez pas à explorer toutes les fonctionnalités de l'application et à "
|
||||
"partager vos commentaires et suggestions avec nous. Vos retours sont "
|
||||
"précieux pour nous et nous permettront de continuer à améliorer notre "
|
||||
"service."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:271
|
||||
#: core/templates/mail/text/invitation.txt:27
|
||||
@@ -393,6 +454,8 @@ msgid ""
|
||||
"Once again, welcome aboard! We are eager to accompany you on this group "
|
||||
"management adventure."
|
||||
msgstr ""
|
||||
"Encore une fois, bienvenue parmi nous ! Nous sommes impatients de vous "
|
||||
"accompagner dans cette aventure de gestion de groupe."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:278
|
||||
#: core/templates/mail/html/new_mailbox.html:272
|
||||
@@ -454,82 +517,118 @@ msgstr "L'équipe de La Suite"
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
msgstr "Cette mail a été envoyée à %(email)s par %(name)s [%(href)s]"
|
||||
|
||||
#: mailbox_manager/admin.py:12
|
||||
#: mailbox_manager/admin.py:13
|
||||
msgid "Synchronise from dimail"
|
||||
msgstr ""
|
||||
msgstr "Synchroniser à partir de dimail"
|
||||
|
||||
#: mailbox_manager/admin.py:23
|
||||
#: mailbox_manager/admin.py:24
|
||||
#, python-brace-format
|
||||
msgid "Synchronisation failed for {domain.name} with message: [{err}]"
|
||||
msgstr ""
|
||||
msgstr "Synchronisation échouée pour {domain.name} avec le message: [{err}]"
|
||||
|
||||
#: mailbox_manager/admin.py:29
|
||||
#: mailbox_manager/admin.py:30
|
||||
#, python-brace-format
|
||||
msgid "Synchronisation succeed for {domain.name}. "
|
||||
msgstr "Synchronisation réussie pour {domain.name}. "
|
||||
|
||||
#: mailbox_manager/admin.py:36
|
||||
msgid "Check and update status from dimail"
|
||||
msgstr "Vérification et mise à jour de l'état à partir de dimail"
|
||||
|
||||
#: mailbox_manager/admin.py:52
|
||||
#, python-brace-format
|
||||
msgid "- <b>{domain.name}</b> with message: '{err}'"
|
||||
msgstr "- <b>{domain.name}</b> avec le message: '{err}'"
|
||||
|
||||
#: mailbox_manager/admin.py:63
|
||||
msgid "Check domains done with success."
|
||||
msgstr "Vérification des domains réalisée avec success."
|
||||
|
||||
#: mailbox_manager/admin.py:64
|
||||
msgid "Domains updated: {', '.join(domains_updated)}"
|
||||
msgstr "Domaines mis à jour : {', '.join(domains_updated)}"
|
||||
|
||||
#: mailbox_manager/admin.py:66
|
||||
msgid "No domain updated."
|
||||
msgstr "Aucun domain n'a été mis à jour."
|
||||
|
||||
#: mailbox_manager/admin.py:70
|
||||
msgid "Check domain failed for:"
|
||||
msgstr "La vérification de domain a échoué pour :"
|
||||
|
||||
#: mailbox_manager/admin.py:76
|
||||
msgid "Domains disabled are excluded from check: {', '.join(excluded_domains)}"
|
||||
msgstr ""
|
||||
"Les domains désactivés sont exclus de la vérification : {', '."
|
||||
"join(excluded_domains)}"
|
||||
|
||||
#: mailbox_manager/enums.py:13
|
||||
msgid "Viewer"
|
||||
msgstr ""
|
||||
msgstr "Lecteur"
|
||||
|
||||
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:31
|
||||
#: mailbox_manager/enums.py:22 mailbox_manager/enums.py:32
|
||||
msgid "Enabled"
|
||||
msgstr "Actif"
|
||||
|
||||
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:32
|
||||
#: mailbox_manager/enums.py:23 mailbox_manager/enums.py:33
|
||||
msgid "Failed"
|
||||
msgstr "En échec"
|
||||
|
||||
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:33
|
||||
#: mailbox_manager/enums.py:24 mailbox_manager/enums.py:34
|
||||
msgid "Disabled"
|
||||
msgstr "Désactivé"
|
||||
|
||||
#: mailbox_manager/enums.py:25
|
||||
msgid "Action required"
|
||||
msgstr "Action requise"
|
||||
|
||||
#: mailbox_manager/models.py:35
|
||||
msgid "Mail domain"
|
||||
msgstr ""
|
||||
msgstr "Domaine de messagerie"
|
||||
|
||||
#: mailbox_manager/models.py:36
|
||||
msgid "Mail domains"
|
||||
msgstr ""
|
||||
msgstr "Domaines de messagerie"
|
||||
|
||||
#: mailbox_manager/models.py:102
|
||||
msgid "User/mail domain relation"
|
||||
msgstr ""
|
||||
msgstr "Relation entre un utilisateur et un domaine de mail"
|
||||
|
||||
#: mailbox_manager/models.py:103
|
||||
msgid "User/mail domain relations"
|
||||
msgstr ""
|
||||
msgstr "Relations entre un utilisateur et un domaine de mail"
|
||||
|
||||
#: mailbox_manager/models.py:175
|
||||
msgid "local_part"
|
||||
msgstr ""
|
||||
msgstr "local_part"
|
||||
|
||||
#: mailbox_manager/models.py:189
|
||||
msgid "secondary email address"
|
||||
msgstr ""
|
||||
msgstr "adresse email secondaire"
|
||||
|
||||
#: mailbox_manager/models.py:199
|
||||
msgid "Mailbox"
|
||||
msgstr ""
|
||||
msgstr "Boîte mail"
|
||||
|
||||
#: mailbox_manager/models.py:200
|
||||
msgid "Mailboxes"
|
||||
msgstr ""
|
||||
msgstr "Boîtes mails"
|
||||
|
||||
#: mailbox_manager/models.py:224
|
||||
msgid "You can't create a mailbox for a disabled domain."
|
||||
msgstr "Vous ne pouvez pas créer de boîte mail pour un domain désactivé."
|
||||
msgid "You can't create or update a mailbox for a disabled domain."
|
||||
msgstr ""
|
||||
"Vous ne pouvez pas créer ou modifier une boîte mail pour un domain désactivé."
|
||||
|
||||
#: mailbox_manager/utils/dimail.py:183
|
||||
#: mailbox_manager/utils/dimail.py:266
|
||||
msgid "Your new mailbox information"
|
||||
msgstr "Informations concernant votre nouvelle boîte mail"
|
||||
|
||||
#: people/settings.py:135
|
||||
#: people/settings.py:146
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
msgstr "Anglais"
|
||||
|
||||
#: people/settings.py:136
|
||||
#: people/settings.py:147
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
msgstr "Français"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user