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