Compare commits
353 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb9895d63f | |||
| 6a053b19fb | |||
| c6b1eb12cc | |||
| f8bce32b93 | |||
| a373704569 | |||
| 4cbe3c8a49 | |||
| 6d0dbf0d13 | |||
| 4048855b1b | |||
| e2a682cae4 | |||
| 366f10689b | |||
| 922719b13e | |||
| 3c481e75bb | |||
| 9a7a8e4a34 | |||
| 905b673413 | |||
| 21981c6478 | |||
| e56c63676e | |||
| 187005d441 | |||
| 54b7a637fe | |||
| 35a897fa60 | |||
| b4bafb6efb | |||
| 23778fda0d | |||
| 0a8c488649 | |||
| e6b5f32a61 | |||
| 8af47283c8 | |||
| 8a44718e6b | |||
| 6e7f20eda9 | |||
| b3779b5979 | |||
| 4b80b288f9 | |||
| fbec4af261 | |||
| b8499a539e | |||
| c7d1312f89 | |||
| 4636c611c6 | |||
| 211d89cae0 | |||
| 915731e218 | |||
| c534048e97 | |||
| 5d1e2bd39d | |||
| 67d3e58c82 | |||
| e0739689e6 | |||
| 04717fd629 | |||
| 087bbf74f6 | |||
| 63a875bd5b | |||
| 7a26f377e3 | |||
| 05d9a09d63 | |||
| 735db606f6 | |||
| 2bf85539f1 | |||
| 6981ef17df | |||
| 37d32888f5 | |||
| d4e0f74d30 | |||
| d2c7eaaa4b | |||
| 46aaf7351d | |||
| 76e9d58b6c | |||
| 9b198d0bab | |||
| 3f1b446e8e | |||
| 5049c9b732 | |||
| 7f2adb8d2f | |||
| b12992f125 | |||
| 001673f973 | |||
| 4b4bbc4c0a | |||
| b7b90d1bf3 | |||
| c599757d7a | |||
| a0992b6ba9 | |||
| d88f6a5a51 | |||
| a45408c93c | |||
| c94888fe09 | |||
| 4551d20d67 | |||
| c7f257daa0 | |||
| 32e6996b68 | |||
| 8fbc4e936e | |||
| cda59fecec | |||
| c5ba87e422 | |||
| df24c24da1 | |||
| ac81e86c88 | |||
| 082fb99bd5 | |||
| 1704ba1707 | |||
| cca6c77f00 | |||
| a1f9cf0854 | |||
| 2f1805b721 | |||
| 711abcb49f | |||
| e68370bfcd | |||
| ae1acd8840 | |||
| 54386fcdd3 | |||
| 45a3e7936d | |||
| ebf58f42c9 | |||
| 7ea6342a01 | |||
| 6d807113bc | |||
| 5455c589ef | |||
| 9ec7eddaed | |||
| 7db2faa072 | |||
| 6e0b329b09 | |||
| c7aae51d25 | |||
| db40efb360 | |||
| 3ddc519d9e | |||
| e20960e3e1 | |||
| 1223732fa9 | |||
| e8180bc49b | |||
| ffe997a658 | |||
| d4c23ce5b9 | |||
| 5ed05b96a5 | |||
| df15b41a87 | |||
| 591045b0ec | |||
| 775b32ff45 | |||
| e9a628f816 | |||
| bf1450cfa7 | |||
| 480d8277cc | |||
| 97cec8901c | |||
| e8aba29a68 | |||
| ebaa1360e7 | |||
| 6d8e05b746 | |||
| e7049632ab | |||
| a54bcbcb1e | |||
| 4af4c4de50 | |||
| 1c5499b2ab | |||
| 91f755306b | |||
| 224025c3fb | |||
| 724bbe550c | |||
| 016232ad2d | |||
| 6de24d973b | |||
| 04c107cfdb | |||
| cbfc67f010 | |||
| 0fe0175622 | |||
| fab1329712 | |||
| f27b347d1c | |||
| cc35757c9e | |||
| a1065031ee | |||
| 7c488a9807 | |||
| 1c4efd523b | |||
| 2345250c4f | |||
| 6b2fb4169c | |||
| 73e58e274c | |||
| 55fbd661b0 | |||
| f591c95a92 | |||
| 25af872a2a | |||
| 832dae789e | |||
| 904fae469d | |||
| da081b9887 | |||
| 0bb5c0c5c2 | |||
| a9bb556dfd | |||
| 32fa653c12 | |||
| 7d9032b6ec | |||
| 897b68038f | |||
| bb9edd21da | |||
| bbf2695dee | |||
| 8b63807f38 | |||
| 88e38c4c7f | |||
| 8ea7b53286 | |||
| 7347565f8d | |||
| 2d50920a48 | |||
| 36161972d7 | |||
| f9fde490e8 | |||
| 1b3869c1e9 | |||
| f6d5f737f4 | |||
| 522914b47a | |||
| 1919dce3a9 | |||
| 0141aa220f | |||
| 159f112713 | |||
| faf699544b | |||
| b8427d865f | |||
| a48dbde0ea | |||
| e9848bd199 | |||
| 1ad6ef8f96 | |||
| 97752e1d5f | |||
| 99cee241f9 | |||
| 6de0d013c3 | |||
| 1de743e18a | |||
| 756867da19 | |||
| d15adb4421 | |||
| 340ddf8b1a | |||
| 2d0fb0ef70 | |||
| 7ef67037c3 | |||
| f1124f6c09 | |||
| 2f8801f7eb | |||
| 4a141736ff | |||
| bdddbb84a5 | |||
| de4551ab30 | |||
| e8a241adbc | |||
| b3b1343796 | |||
| d49cc11ef1 | |||
| 28adf987f7 | |||
| c6b8e47b29 | |||
| a8a001e1e4 | |||
| bbd8e1b48d | |||
| f21966cca9 | |||
| e4a6b33366 | |||
| 44b5999df8 | |||
| f503120b3c | |||
| 079968b532 | |||
| 8e76a0ee79 | |||
| a2ff33663b | |||
| 78459df962 | |||
| 4579e668b6 | |||
| 6ee39a01af | |||
| 3378d4b892 | |||
| c40f656622 | |||
| 1a3b396230 | |||
| 759c06a289 | |||
| 97d9714a0d | |||
| c9e4d47d9d | |||
| b30bb6ce2f | |||
| 8ae7b4e8e9 | |||
| 8b014e289a | |||
| 7d65de1938 | |||
| b2d68df646 | |||
| 4f9f49ac9a | |||
| 421ef899da | |||
| b416c57bbe | |||
| fa88f70cee | |||
| b2956e42d3 | |||
| 18971a10e0 | |||
| 62758763df | |||
| a15e46a2f9 | |||
| e15c7cb2f4 | |||
| 0648c2e8d3 | |||
| b0d3f73ba2 | |||
| 9be973a776 | |||
| 150258b5a4 | |||
| b41fd1ab69 | |||
| b5ce19a28e | |||
| 163f987132 | |||
| e9482a985f | |||
| 43d802a73b | |||
| b4e4940fd7 | |||
| 5ec0dcf206 | |||
| dad81c8d73 | |||
| b010a7b5a7 | |||
| e16f51ca20 | |||
| 9d30bc88f1 | |||
| 1da978e121 | |||
| 3bf8965209 | |||
| 5b9d2cccc5 | |||
| 81243cfc9a | |||
| 70b1b996df | |||
| 29d274ab7c | |||
| f17771fc9b | |||
| 65e78cde68 | |||
| 33288ab225 | |||
| a3c0069697 | |||
| b307b373bb | |||
| f21740e5e5 | |||
| 035a7a1fcc | |||
| 3f7e5c88bc | |||
| 51064ec236 | |||
| 195e738c3c | |||
| 54497c1261 | |||
| 8d7c545d1a | |||
| 8cbfb38cc4 | |||
| 4bd8095975 | |||
| fc8dc24ba2 | |||
| 95219a33b3 | |||
| 26fbe9fbe7 | |||
| 4cacfd3a45 | |||
| 38c4d33791 | |||
| ec28c28d47 | |||
| 927d0e5a22 | |||
| 699854e76b | |||
| 63e059a4e6 | |||
| 5113eb013b | |||
| 45d05873e2 | |||
| 1f3ab759d7 | |||
| 8b5f5bf092 | |||
| cd2efbe40d | |||
| 8b0c20dbdc | |||
| d0562029e8 | |||
| 77efb1a89c | |||
| 4566e132e1 | |||
| f818715a45 | |||
| 7d90092020 | |||
| 469903c9eb | |||
| 1f1253ab21 | |||
| 6620932371 | |||
| 8e537d962c | |||
| 6080af961a | |||
| aba376702f | |||
| 1e38174c1b | |||
| 0ab9f16cb3 | |||
| c71463a385 | |||
| 47ffa60a94 | |||
| fafffd2391 | |||
| 36e2dc2378 | |||
| 4f0465fd32 | |||
| 148ea81aa9 | |||
| 9981b9c615 | |||
| d1cc1942dc | |||
| a7d72d0fab | |||
| 46ad7435c8 | |||
| 1d4d4ee902 | |||
| c6ea7c0831 | |||
| d2bf44d2fd | |||
| c117f67952 | |||
| ec2fcaa1dd | |||
| 0b703cda97 | |||
| fc6487ddc1 | |||
| 66dbea3c6d | |||
| cbe356214d | |||
| ce55721b5d | |||
| 32e42e126d | |||
| 8043d12315 | |||
| 801cb98e15 | |||
| 3d0824e023 | |||
| 7add42f525 | |||
| 01b7ad3f30 | |||
| 6a0ed04b0d | |||
| 21550fe01d | |||
| 92e3e11daf | |||
| c54d457fa6 | |||
| 6f18713a7e | |||
| a4ac5304d7 | |||
| 3aba9a4419 | |||
| 5b0b2933a2 | |||
| 31a5518a5c | |||
| 13a58d6fa0 | |||
| 83d9310c26 | |||
| 6abcf98ad2 | |||
| ab7d466823 | |||
| ab9aac08b0 | |||
| d5f16cddb0 | |||
| 54f64838a0 | |||
| 269ba42204 | |||
| 8f2f47d3b1 | |||
| c2c6ae88db | |||
| 3b155b708c | |||
| e8186408a0 | |||
| 38997fef6a | |||
| 5062cac623 | |||
| 5b4fe1e77f | |||
| 30721b9ef9 | |||
| e2618d0e11 | |||
| 97e7d99c02 | |||
| 9ee39e1068 | |||
| c6823ba698 | |||
| da851f508a | |||
| 5f280ae3fc | |||
| 2ef31a424a | |||
| fc7747dddf | |||
| ba21784eab | |||
| 0c550ebd1c | |||
| cfc35ac23e | |||
| 65cfe7ff2b | |||
| 8b026078bc | |||
| e1688b923e | |||
| 5aca2c48e3 | |||
| d0b2f9c171 | |||
| bf1b7736bb | |||
| ae07bc9246 | |||
| 05d9f6430d | |||
| 58f99545c0 | |||
| f4ff27636d | |||
| 4999472005 | |||
| e4b0ca86e5 | |||
| 7713225fc8 | |||
| 875b7cd866 | |||
| b5a46eba33 | |||
| 8ebfb8715d | |||
| eeec372957 |
+1
-1
@@ -32,5 +32,5 @@ db.sqlite3
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
|
||||
# Frontend
|
||||
# Frontend dependencies
|
||||
node_modules
|
||||
|
||||
@@ -18,7 +18,7 @@ A clear and concise description of what you expected to happen (or code).
|
||||
3. And then the bug happens!
|
||||
|
||||
**Environment**
|
||||
- Impress version:
|
||||
- People version:
|
||||
- Platform:
|
||||
|
||||
**Possible Solution**
|
||||
|
||||
@@ -9,9 +9,9 @@ We primarily use GitHub as an issue tracker. If however you're encountering an i
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/impress).
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/impress/issues).
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "impress,secrets"
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'sccon'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
@@ -27,38 +25,32 @@ jobs:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "impress,secrets"
|
||||
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/impress/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/impress-backend
|
||||
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
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
@@ -77,95 +69,35 @@ jobs:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "impress,secrets"
|
||||
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/impress/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/impress-frontend
|
||||
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: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: |
|
||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
FRONTEND_THEME=open-desk
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-y-provider:
|
||||
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: "impress,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/impress/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/impress-y-provider
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: y-provider
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -176,8 +108,7 @@ jobs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -186,7 +117,7 @@ jobs:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "impress,secrets"
|
||||
repositories: "people,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
@@ -197,7 +128,7 @@ jobs:
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
|
||||
secret-file: secrets/numerique-gouv/people/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
|
||||
@@ -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: "impress,secrets"
|
||||
@@ -1,253 +0,0 @@
|
||||
name: Frontend Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Build CI App
|
||||
run: cd src/frontend/ && yarn ci:build
|
||||
|
||||
- name: Cache build frontend
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Test App
|
||||
run: cd src/frontend/ && yarn app:test
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
|
||||
test-e2e-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-front
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make data/media
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build the Docker images
|
||||
uses: docker/bake-action@v4
|
||||
with:
|
||||
targets: |
|
||||
app-dev
|
||||
y-provider
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=cached-stage
|
||||
*.cache-to=type=gha,scope=cached-stage,mode=max
|
||||
|
||||
- name: Start Docker services
|
||||
run: |
|
||||
make run
|
||||
|
||||
- name: Start Nginx for the frontend
|
||||
run: |
|
||||
docker compose up --force-recreate -d nginx-front
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
- name: Add dummy data
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-chromium-report
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
|
||||
test-e2e-other-browser:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-front
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make data/media
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/impress/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build the Docker images
|
||||
uses: docker/bake-action@v4
|
||||
with:
|
||||
targets: |
|
||||
app-dev
|
||||
y-provider
|
||||
load: true
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=cached-stage
|
||||
*.cache-to=type=gha,scope=cached-stage,mode=max
|
||||
|
||||
- name: Start Docker services
|
||||
run: |
|
||||
make run
|
||||
|
||||
- name: Start Nginx for the frontend
|
||||
run: |
|
||||
docker compose up --force-recreate -d nginx-front
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
- name: Add dummy data
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-other-report
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
@@ -1,216 +0,0 @@
|
||||
name: Main Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
if [ $max_line_length -ge 80 ]; then
|
||||
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Install yarn
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Install node dependencies
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build mails
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn build
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: impress
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
env:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
DJANGO_SETTINGS_MODULE: impress.settings
|
||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
DB_HOST: localhost
|
||||
DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: impress
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v4
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=impress" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
minio/minio server --console-address :9001 /data
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set impress http://localhost:9000 impress password && \
|
||||
mc alias ls && \
|
||||
mc mb impress/impress-media-storage && \
|
||||
mc version enable impress/impress-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Install gettext (required to compile messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
@@ -0,0 +1,384 @@
|
||||
name: People Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
if [ $max_line_length -ge 80 ]; then
|
||||
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install-front:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Build CI App
|
||||
run: cd src/frontend/ && yarn ci:build
|
||||
|
||||
- name: Cache build frontend
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Test App
|
||||
run: cd src/frontend/ && yarn app:test
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-mails, build-front]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Download mails' templates
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
- name: Build and Start Docker Servers
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
run: |
|
||||
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make run
|
||||
|
||||
- name: Apply DRF migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
- name: Add dummy data
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: src/frontend/apps/e2e/report/
|
||||
retention-days: 7
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
- name: Install node dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build mails
|
||||
run: yarn build
|
||||
- name: Persist mails' templates
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: people
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
env:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
DJANGO_SETTINGS_MODULE: people.settings
|
||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
DB_HOST: localhost
|
||||
DB_NAME: people
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
- name: Download mails' templates
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install gettext (required to compile messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
|
||||
i18n-crowdin:
|
||||
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: Install gettext (required to make messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install development dependencies
|
||||
working-directory: src/backend
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Generate the translation base file
|
||||
run: ~/.local/bin/django-admin makemessages --keep-pot --all
|
||||
|
||||
-
|
||||
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: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: src/frontend/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Download sources from Crowdin to stay synchronized
|
||||
run: |
|
||||
docker run \
|
||||
--rm \
|
||||
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
|
||||
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
|
||||
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
|
||||
-v "${{ github.workspace }}:/app" \
|
||||
crowdin/cli:3.16.0 \
|
||||
crowdin download sources -c /app/crowdin/config.yml
|
||||
|
||||
- name: Extract the frontend translation
|
||||
run: make frontend-i18n-extract
|
||||
|
||||
- name: Upload files to Crowdin
|
||||
run: |
|
||||
docker run \
|
||||
--rm \
|
||||
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
|
||||
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
|
||||
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
|
||||
-v "${{ github.workspace }}:/app" \
|
||||
crowdin/cli:3.16.0 \
|
||||
crowdin upload sources -c /app/crowdin/config.yml
|
||||
+6
-1
@@ -33,6 +33,7 @@ MANIFEST
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
@@ -49,6 +50,9 @@ node_modules
|
||||
# Mails
|
||||
src/backend/core/templates/mail/
|
||||
|
||||
# Typescript client
|
||||
src/frontend/tsclient
|
||||
|
||||
# Swagger
|
||||
**/swagger.json
|
||||
|
||||
@@ -74,4 +78,5 @@ db.sqlite3
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.devcontainer
|
||||
.devcontainer/
|
||||
.tool-versions
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
creation_rules:
|
||||
- path_regex: ./*
|
||||
key_groups:
|
||||
- age:
|
||||
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
|
||||
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
|
||||
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
|
||||
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
|
||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||
|
||||
-197
@@ -6,201 +6,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
||||
and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Changed
|
||||
|
||||
♻️(frontend) More multi theme friendly #325
|
||||
|
||||
|
||||
## [1.5.1] - 2024-10-10
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(db) fix users duplicate #316
|
||||
|
||||
|
||||
## [1.5.0] - 2024-10-09
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) add name fields to the user synchronized with OIDC #301
|
||||
- ✨(ci) add security scan #291
|
||||
- ♻️(frontend) Add versions #277
|
||||
- ✨(frontend) one-click document creation #275
|
||||
- ✨(frontend) edit title inline #275
|
||||
- 📱(frontend) mobile responsive #304
|
||||
- 🌐(frontend) Update translation #308
|
||||
|
||||
## Changed
|
||||
|
||||
- 💄(frontend) error alert closeable on editor #284
|
||||
- ♻️(backend) Change email content #283
|
||||
- 🛂(frontend) viewers and editors can access share modal #302
|
||||
- ♻️(frontend) remove footer on doc editor #313
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🛂(frontend) match email if no existing user matches the sub
|
||||
- 🐛(backend) gitlab oicd userinfo endpoint #232
|
||||
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
|
||||
- ♻️(backend) getting list of document versions available for a user #258
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #297
|
||||
- 🐛(frontend) fix editor break line not working #302
|
||||
|
||||
|
||||
## [1.4.0] - 2024-09-17
|
||||
|
||||
## Added
|
||||
|
||||
- ✨Add link public/authenticated/restricted access with read/editor roles #234
|
||||
- ✨(frontend) add copy link button #235
|
||||
- 🛂(frontend) access public docs without being logged #235
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(backend) Allow null titles on documents for easier creation #234
|
||||
- 🛂(backend) stop to list public doc to everyone #234
|
||||
- 🚚(frontend) change visibility in share modal #235
|
||||
- ⚡️(frontend) Improve summary #244
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
|
||||
- 🐛 Rebuild frontend dev container from makefile #248
|
||||
|
||||
|
||||
## [1.3.0] - 2024-09-05
|
||||
|
||||
## Added
|
||||
|
||||
- ✨Add image attachments with access control
|
||||
- ✨(frontend) Upload image to a document #211
|
||||
- ✨(frontend) Summary #223
|
||||
- ✨(frontend) update meta title for docs page #231
|
||||
- 🌐(frontend) Add German translation #255
|
||||
|
||||
## Changed
|
||||
|
||||
- 💄(frontend) code background darkened on editor #214
|
||||
- 🔥(frontend) hide markdown button if not text #213
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛 Fix emoticon in pdf export #225
|
||||
- 🐛 Fix collaboration on document #226
|
||||
- 🐛 (docker) Fix compatibility with mac #230
|
||||
|
||||
## Removed
|
||||
|
||||
- 🔥(frontend) remove saving modal #213
|
||||
|
||||
|
||||
## [1.2.1] - 2024-08-23
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️ Change ordering docs datagrid #195
|
||||
- 🔥(helm) use scaleway email #194
|
||||
|
||||
|
||||
## [1.2.0] - 2024-08-22
|
||||
|
||||
## Added
|
||||
|
||||
- 🎨(frontend) better conversion editor to pdf #151
|
||||
- ✨Export docx (word) #161
|
||||
- 🌐Internationalize invitation email #167
|
||||
- ✨(frontend) White branding #164
|
||||
- ✨Email invitation when add user to doc #171
|
||||
- ✨Invitation management #174
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(y-webrtc) fix prob connection #147
|
||||
- ⚡️(frontend) improve select share stability #159
|
||||
- 🐛(backend) enable SSL when sending email #165
|
||||
|
||||
## Changed
|
||||
|
||||
- 🎨(frontend) stop limit layout height to screen size #158
|
||||
- ⚡️(CI) only e2e chrome mandatory #177
|
||||
|
||||
## Removed
|
||||
- 🔥(helm) remove htaccess #181
|
||||
|
||||
|
||||
## [1.1.0] - 2024-07-15
|
||||
|
||||
## Added
|
||||
|
||||
- 🤡(demo) generate dummy documents on dev users #120
|
||||
- ✨(frontend) create side modal component #134
|
||||
- ✨(frontend) Doc grid actions (update / delete) #136
|
||||
- ✨(frontend) Doc editor header information #137
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) replace docs panel with docs grid #120
|
||||
- ♻️(frontend) create a doc from a modal #132
|
||||
- ♻️(frontend) manage members from the share modal #140
|
||||
|
||||
|
||||
## [1.0.0] - 2024-07-02
|
||||
|
||||
## Added
|
||||
|
||||
- 🛂(frontend) Manage the document's right (#75)
|
||||
- ✨(frontend) Update document (#68)
|
||||
- ✨(frontend) Remove document (#68)
|
||||
- 🐳(docker) dockerize dev frontend (#63)
|
||||
- 👔(backend) list users with email filtering (#79)
|
||||
- ✨(frontend) add user to a document (#52)
|
||||
- ✨(frontend) invite user to a document (#52)
|
||||
- 🛂(frontend) manage members (update role / list / remove) (#81)
|
||||
- ✨(frontend) offline mode (#88)
|
||||
- 🌐(frontend) translate cgu (#83)
|
||||
- ✨(service-worker) offline doc management (#94)
|
||||
- ⚗️(frontend) Add beta tag on logo (#121)
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) Change site from Impress to Docs (#76)
|
||||
- ✨(frontend) Generate PDF from a modal (#68)
|
||||
- 🔧(helm) sticky session by request_uri for signaling server (#78)
|
||||
- ♻️(frontend) change logo (#84)
|
||||
- ♻️(frontend) pdf has title doc (#84)
|
||||
- ⚡️(e2e) unique login between tests (#80)
|
||||
- ⚡️(CI) improve e2e job (#86)
|
||||
- ♻️(frontend) improve the error and message info ui (#93)
|
||||
- ✏️(frontend) change all occurences of pad to doc (#99)
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) Fix the break line when generate PDF (#84)
|
||||
|
||||
## Delete
|
||||
|
||||
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
|
||||
- 🔥(frontend) Remove coming soon page (#121)
|
||||
|
||||
|
||||
## [0.1.0] - 2024-05-24
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.5.1...main
|
||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
||||
|
||||
+86
-32
@@ -1,14 +1,64 @@
|
||||
# Django impress
|
||||
# Django People
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.12.6-alpine3.20 as base
|
||||
FROM python:3.10-slim-bullseye as base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
### ---- Front-end dependencies image ----
|
||||
FROM node:20 as frontend-deps
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
COPY ./src/frontend/package.json ./package.json
|
||||
COPY ./src/frontend/yarn.lock ./yarn.lock
|
||||
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
|
||||
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
|
||||
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
|
||||
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
### ---- Front-end builder dev image ----
|
||||
FROM node:20 as frontend-builder-dev
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
COPY --from=frontend-deps /deps/node_modules ./node_modules
|
||||
COPY ./src/frontend .
|
||||
|
||||
WORKDIR ./apps/desk
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM frontend-builder-dev as frontend-builder
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY --from=frontend-builder \
|
||||
/builder/apps/desk/out \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
@@ -35,17 +85,19 @@ RUN yarn install --frozen-lockfile && \
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base as link-collector
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
# Install pango & rdfind
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
# Install libpangocairo & rdfind
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libpangocairo-1.0-0 \
|
||||
rdfind && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
# Copy people application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
@@ -56,7 +108,7 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
|
||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||
# final image
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${PEOPLE_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base as core
|
||||
@@ -64,16 +116,16 @@ FROM base as core
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apk add \
|
||||
gettext \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
pango \
|
||||
pandoc \
|
||||
font-noto-emoji \
|
||||
font-noto \
|
||||
shared-mime-info
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gettext \
|
||||
libcairo2 \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
shared-mime-info && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -86,7 +138,7 @@ RUN chmod g=u /etc/passwd
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
# Copy people application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
@@ -103,11 +155,13 @@ FROM core as backend-development
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Uninstall impress and re-install it in editable mode along with development
|
||||
# Uninstall people and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
RUN pip uninstall -y impress
|
||||
RUN pip uninstall -y people
|
||||
RUN pip install -e .[dev]
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
@@ -125,21 +179,21 @@ CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
# Gunicorn
|
||||
RUN mkdir -p /usr/local/etc/gunicorn
|
||||
COPY docker/files/usr/local/etc/gunicorn/impress.py /usr/local/etc/gunicorn/impress.py
|
||||
COPY docker/files/usr/local/etc/gunicorn/people.py /usr/local/etc/gunicorn/people.py
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Copy statics
|
||||
COPY --from=link-collector ${IMPRESS_STATIC_ROOT} ${IMPRESS_STATIC_ROOT}
|
||||
COPY --from=link-collector ${PEOPLE_STATIC_ROOT} ${PEOPLE_STATIC_ROOT}
|
||||
|
||||
# Copy impress mails
|
||||
# Copy people mails
|
||||
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
|
||||
|
||||
# The default command runs gunicorn WSGI server in impress's main module
|
||||
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/impress.py", "impress.wsgi:application"]
|
||||
# The default command runs gunicorn WSGI server in people's main module
|
||||
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/people.py", "people.wsgi:application"]
|
||||
|
||||
@@ -45,14 +45,16 @@ COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
||||
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
PATH_FRONT_IMPRESS = $(PATH_FRONT)/apps/impress
|
||||
PATH_FRONT_DESK = $(PATH_FRONT)/apps/desk
|
||||
|
||||
# ==============================================================================
|
||||
# RULES
|
||||
@@ -75,24 +77,23 @@ create-env-files: \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project
|
||||
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
|
||||
bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run-frontend-dev \
|
||||
run \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build
|
||||
mails-build \
|
||||
install-front-desk
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev --no-cache
|
||||
@$(COMPOSE) build frontend-dev --no-cache
|
||||
@$(COMPOSE) build app-dev
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -104,9 +105,12 @@ logs: ## display app-dev logs (follow mode)
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@$(COMPOSE) up --force-recreate -d app-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@$(COMPOSE) up --force-recreate -d keycloak
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_KC_DB)
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run
|
||||
|
||||
@@ -162,23 +166,30 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
makemigrations: ## run django makemigrations for the impress project.
|
||||
makemigrations: ## run django makemigrations for the people project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) makemigrations
|
||||
@$(MANAGE) makemigrations $(ARGS)
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the impress project.
|
||||
migrate: ## run django migrations for the people project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) migrate
|
||||
@$(MANAGE) migrate $(ARGS)
|
||||
.PHONY: migrate
|
||||
|
||||
showmigrations: ## run django showmigrations for the people project.
|
||||
@echo "$(BOLD)Running showmigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) showmigrations $(ARGS)
|
||||
.PHONY: showmigrations
|
||||
|
||||
superuser: ## Create an admin superuser with password "admin"
|
||||
@echo "$(BOLD)Creating a Django superuser$(RESET)"
|
||||
@$(MANAGE) createsuperuser --email admin@example.com --password admin
|
||||
@$(MANAGE) createsuperuser --admin_email admin@example.com --password admin
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@@ -186,7 +197,7 @@ back-i18n-compile: ## compile the gettext files
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
@$(MANAGE) makemessages -a --keep-pot --all
|
||||
@$(MANAGE) makemessages -a --keep-pot
|
||||
.PHONY: back-i18n-generate
|
||||
|
||||
shell: ## connect to database shell
|
||||
@@ -220,7 +231,7 @@ env.d/development/kc_postgresql:
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
|
||||
crowdin-download: ## Download translated message from crowdin
|
||||
crowdin-download: ## Download translated message from Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
.PHONY: crowdin-download
|
||||
|
||||
@@ -228,7 +239,7 @@ crowdin-download-sources: ## Download sources from Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-download-sources
|
||||
|
||||
crowdin-upload: ## Upload source translations to crowdin
|
||||
crowdin-upload: ## Upload source translations to Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-upload
|
||||
|
||||
@@ -275,21 +286,35 @@ mails-install: ## install the mail generator
|
||||
@$(MAIL_YARN) install
|
||||
.PHONY: mails-install
|
||||
|
||||
# -- TS client generator
|
||||
|
||||
tsclient-install: ## Install the Typescript API client generator
|
||||
@$(TSCLIENT_YARN) install
|
||||
.PHONY: tsclient-install
|
||||
|
||||
tsclient: tsclient-install ## Generate a Typescript API client
|
||||
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
|
||||
.PHONY: tsclient-install
|
||||
|
||||
# -- Misc
|
||||
clean: ## restore repository state as it was freshly cloned
|
||||
git clean -idx
|
||||
.PHONY: clean
|
||||
|
||||
help:
|
||||
@echo "$(BOLD)impress Makefile"
|
||||
@echo "$(BOLD)People Makefile"
|
||||
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
|
||||
.PHONY: help
|
||||
|
||||
# Front
|
||||
run-frontend-dev: ## Install and run the frontend dev
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
.PHONY: run-frontend-dev
|
||||
install-front-desk: ## Install the frontend dependencies of app Desk
|
||||
cd $(PATH_FRONT_DESK) && yarn
|
||||
.PHONY: install-front-desk
|
||||
|
||||
run-front-desk: ## Start app Desk
|
||||
cd $(PATH_FRONT_DESK) && yarn dev
|
||||
.PHONY: run-front-desk
|
||||
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
@@ -306,21 +331,10 @@ frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
|
||||
.PHONY: frontend-i18n-compile
|
||||
|
||||
# -- K8S
|
||||
build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
start-kind: ## Create the kubernetes cluster
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
.PHONY: start-kind
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
tilt-up: ## start tilt - k8s local development
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
VERSION_TYPE ?= minor
|
||||
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
|
||||
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||
.PHONY: bump-packages-version
|
||||
.PHONY: tilt-up
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
# Impress
|
||||
# People
|
||||
|
||||
Impress is a web application for real-time collaborative text editing with user and role based access rights.
|
||||
Features include :
|
||||
- User authentication through OIDC
|
||||
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
|
||||
- Document export to pdf and docx from predefined templates
|
||||
- Granular document permissions
|
||||
- Public link sharing
|
||||
- Offline mode
|
||||
People is an application to handle users and teams.
|
||||
|
||||
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
|
||||
As of today, this project is **not yet ready for production**. Expect breaking changes.
|
||||
|
||||
People is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/).
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -34,28 +30,20 @@ $ docker compose -v
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```bash
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
$ make bootstrap
|
||||
```
|
||||
|
||||
Then you can access to the project in development mode by going to http://localhost:3000.
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
username: impress
|
||||
password: impress
|
||||
```
|
||||
---
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-releated or migration-releated issues.
|
||||
dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
Note that if you need to run them afterward, you can use the eponym Make rule:
|
||||
|
||||
```bash
|
||||
$ make run-frontend-dev
|
||||
$ make run
|
||||
```
|
||||
|
||||
### Adding content
|
||||
@@ -81,6 +69,24 @@ You first need to create a superuser account:
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
You can then login with credentials `admin@example` / `admin`.
|
||||
|
||||
### Run frontend
|
||||
|
||||
Run the front with:
|
||||
|
||||
```bash
|
||||
$ make run-front-desk
|
||||
```
|
||||
|
||||
Then access at
|
||||
[http://localhost:3000](http://localhost:3000)
|
||||
|
||||
user: people
|
||||
|
||||
password: people
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
|
||||
+15
-25
@@ -1,9 +1,9 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('impress')
|
||||
namespace_create('desk')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/impress-backend:latest',
|
||||
'localhost:5001/people-backend:latest',
|
||||
context='..',
|
||||
dockerfile='../Dockerfile',
|
||||
only=['./src/backend', './src/mail', './docker'],
|
||||
@@ -18,38 +18,28 @@ docker_build(
|
||||
)
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/impress-y-provider:latest',
|
||||
'localhost:5001/people-frontend:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend/', './docker/', './.dockerignore'],
|
||||
target = 'y-provider',
|
||||
dockerfile='../Dockerfile',
|
||||
build_args={'ENV': 'dev'},
|
||||
only=['./src/frontend', './src/mail', './docker'],
|
||||
target = 'frontend-builder-dev',
|
||||
live_update=[
|
||||
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
|
||||
sync('../src/frontend', '/builder'),
|
||||
]
|
||||
)
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/impress-frontend:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target = 'impress',
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery impress-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n impress exec "$POD_NAME" -- python manage.py makemigrations
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py makemigrations
|
||||
'''
|
||||
cmd_button('Make migration',
|
||||
argv=['sh', '-c', migration],
|
||||
resource='impress-backend',
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Run makemigration',
|
||||
)
|
||||
@@ -57,12 +47,12 @@ cmd_button('Make migration',
|
||||
pod_migrate = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
POD_NAME="$(tilt get kubernetesdiscovery impress-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n impress exec "$POD_NAME" -- python manage.py migrate --no-input
|
||||
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
|
||||
kubectl -n desk exec "$POD_NAME" -- python manage.py migrate --no-input
|
||||
'''
|
||||
cmd_button('Migrate db',
|
||||
argv=['sh', '-c', pod_migrate],
|
||||
resource='impress-backend',
|
||||
resource='desk-backend',
|
||||
icon_name='developer_board',
|
||||
text='Run database migration',
|
||||
)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_PROJECT="impress"
|
||||
COMPOSE_PROJECT="people"
|
||||
|
||||
|
||||
# _set_user: set (or unset) default user id used to run docker commands
|
||||
|
||||
Executable → Regular
+7
-8
@@ -3,25 +3,25 @@ set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
echo "0. Create ca"
|
||||
# 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
|
||||
|
||||
echo "1. Create registry container unless it already exists"
|
||||
# 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=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
||||
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
||||
registry:2
|
||||
fi
|
||||
|
||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# 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.
|
||||
#
|
||||
@@ -58,8 +58,8 @@ nodes:
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
echo "3. Add the registry config to the nodes"
|
||||
# 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.
|
||||
@@ -75,15 +75,15 @@ for node in $(kind get nodes); do
|
||||
EOF
|
||||
done
|
||||
|
||||
echo "4. Connect the registry to the cluster network if not already connected"
|
||||
# 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
|
||||
|
||||
echo "5. Document the local registry"
|
||||
# 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
|
||||
@@ -97,7 +97,6 @@ data:
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
EOF
|
||||
|
||||
echo "6. Install ingress-nginx"
|
||||
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"}]'
|
||||
|
||||
@@ -7,6 +7,6 @@ _dc_run \
|
||||
app-dev \
|
||||
python manage.py spectacular \
|
||||
--api-version 'v1.0' \
|
||||
--urlconf 'impress.api_urls' \
|
||||
--urlconf 'people.api_urls' \
|
||||
--format openapi-json \
|
||||
--file /app/core/tests/swagger/swagger.json
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
||||
+14
-13
@@ -14,16 +14,17 @@ preserve_hierarchy: true
|
||||
#
|
||||
# Files configuration
|
||||
#
|
||||
files: [
|
||||
{
|
||||
source : "/backend/locale/django.pot",
|
||||
dest: "/backend-impress.pot",
|
||||
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
||||
},
|
||||
{
|
||||
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
|
||||
dest: "/frontend-impress.json",
|
||||
translation: "/frontend/packages/i18n/locales/impress/%two_letters_code%/translations.json",
|
||||
skip_untranslated_strings: true,
|
||||
},
|
||||
]
|
||||
files:
|
||||
[
|
||||
{
|
||||
source: "/backend/locale/django.pot",
|
||||
dest: "/backend.pot",
|
||||
translation: "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po",
|
||||
},
|
||||
{
|
||||
source: "/frontend/packages/i18n/locales/desk/translations-crowdin.json",
|
||||
dest: "/desk.json",
|
||||
translation: "/frontend/packages/i18n/locales/desk/%two_letters_code%/translations.json",
|
||||
skip_untranslated_strings: true,
|
||||
},
|
||||
]
|
||||
|
||||
+30
-72
@@ -1,3 +1,5 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
@@ -14,31 +16,6 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=impress
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set impress http://minio:9000 impress password && \
|
||||
/usr/bin/mc mb impress/impress-media-storage && \
|
||||
/usr/bin/mc version enable impress/impress-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -46,7 +23,7 @@ services:
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: impress:backend-development
|
||||
image: people:backend-development
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
@@ -57,18 +34,17 @@ services:
|
||||
- "8071:8000"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
- nginx
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: impress:backend-development
|
||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
|
||||
image: people:backend-development
|
||||
command: ["celery", "-A", "people.celery_app", "worker", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
@@ -76,6 +52,7 @@ services:
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/media:/data/media
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- app-dev
|
||||
@@ -87,21 +64,22 @@ services:
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: impress:backend-production
|
||||
image: people:backend-production
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./data/media:/data/media
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
- minio
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: impress:backend-production
|
||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
|
||||
image: people:backend-production
|
||||
command: ["celery", "-A", "people.celery_app", "worker", "-l", "INFO"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
@@ -117,16 +95,9 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- app
|
||||
- keycloak
|
||||
|
||||
nginx-front:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
|
||||
@@ -147,41 +118,28 @@ services:
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
y-provider:
|
||||
terraform-state:
|
||||
image: hashicorp/terraform:1.6
|
||||
environment:
|
||||
- TF_WORKSPACE=${PROJECT:-} # avoid env conflict in local state
|
||||
user: ${DOCKER_USER:-1000}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: y-provider
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4444:4444"
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
|
||||
- /home/frontend/servers/y-provider/node_modules/
|
||||
- /home/frontend/servers/y-provider/dist/
|
||||
- ./src/terraform/create_state_bucket:/app
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: impress-dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
terraform:
|
||||
image: hashicorp/terraform:1.6
|
||||
user: ${DOCKER_USER:-1000}
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./src/frontend/apps/impress:/home/frontend/apps/impress
|
||||
- /home/frontend/node_modules/
|
||||
depends_on:
|
||||
- y-provider
|
||||
- celery-dev
|
||||
- ./src/terraform:/app
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
@@ -203,7 +161,7 @@ services:
|
||||
KC_DB_URL_HOST: kc_postgresql
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: impress
|
||||
KC_DB_USERNAME: people
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
|
||||
+14
-29
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "ccf4fd40-4286-474d-854a-4714282a8bec",
|
||||
"realm": "impress",
|
||||
"realm": "people",
|
||||
"notBefore": 0,
|
||||
"defaultSignatureAlgorithm": "RS256",
|
||||
"revokeRefreshToken": false,
|
||||
@@ -45,15 +45,15 @@
|
||||
"failureFactor": 30,
|
||||
"users": [
|
||||
{
|
||||
"username": "impress",
|
||||
"email": "impress@impress.world",
|
||||
"username": "people",
|
||||
"email": "people@people.world",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "impress"
|
||||
"value": "people"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
{
|
||||
"id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99",
|
||||
"name": "default-roles-impress",
|
||||
"name": "default-roles-people",
|
||||
"description": "${role_default-roles}",
|
||||
"composite": true,
|
||||
"composites": {
|
||||
@@ -359,7 +359,7 @@
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"impress": [],
|
||||
"people": [],
|
||||
"account": [
|
||||
{
|
||||
"id": "63b1a4e1-a594-4571-99c3-7c5c3efd61ce",
|
||||
@@ -449,7 +449,7 @@
|
||||
"groups": [],
|
||||
"defaultRole": {
|
||||
"id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99",
|
||||
"name": "default-roles-impress",
|
||||
"name": "default-roles-people",
|
||||
"description": "${role_default-roles}",
|
||||
"composite": true,
|
||||
"clientRole": false,
|
||||
@@ -504,12 +504,12 @@
|
||||
"clientId": "account",
|
||||
"name": "${client_account}",
|
||||
"rootUrl": "${authBaseUrl}",
|
||||
"baseUrl": "/realms/impress/account/",
|
||||
"baseUrl": "/realms/people/account/",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/realms/impress/account/*"],
|
||||
"redirectUris": ["/realms/people/account/*"],
|
||||
"webOrigins": [],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
@@ -546,12 +546,12 @@
|
||||
"clientId": "account-console",
|
||||
"name": "${client_account-console}",
|
||||
"rootUrl": "${authBaseUrl}",
|
||||
"baseUrl": "/realms/impress/account/",
|
||||
"baseUrl": "/realms/people/account/",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/realms/impress/account/*"],
|
||||
"redirectUris": ["/realms/people/account/*"],
|
||||
"webOrigins": [],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
@@ -676,7 +676,7 @@
|
||||
},
|
||||
{
|
||||
"id": "869481d0-5774-4e64-bc30-fedc7c58958f",
|
||||
"clientId": "impress",
|
||||
"clientId": "people",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
@@ -791,12 +791,12 @@
|
||||
"clientId": "security-admin-console",
|
||||
"name": "${client_security-admin-console}",
|
||||
"rootUrl": "${authAdminUrl}",
|
||||
"baseUrl": "/admin/impress/console/",
|
||||
"baseUrl": "/admin/people/console/",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/admin/impress/console/*"],
|
||||
"redirectUris": ["/admin/people/console/*"],
|
||||
"webOrigins": ["+"],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
@@ -1339,21 +1339,6 @@
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "qb109597-e31e-46d7-7844-62e5fcf32ac8",
|
||||
"name": "email sub",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"userinfo.token.claim": "true",
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "sub",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "61c135e5-2447-494b-bc70-9612f383be27",
|
||||
"name": "email verified",
|
||||
|
||||
@@ -4,36 +4,6 @@ server {
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
auth_request /auth;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
|
||||
# Pass specific headers from the auth response
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/impress-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
}
|
||||
|
||||
location /auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
# Prevent the body from being passed
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#
|
||||
# To pass environment variables, you can either use the -e option of the docker run command:
|
||||
#
|
||||
# docker run --rm -e USER_NAME=foo -e HOME='/home/foo' impress:latest python manage.py migrate
|
||||
# docker run --rm -e USER_NAME=foo -e HOME='/home/foo' people:latest python manage.py migrate
|
||||
#
|
||||
# or define new variables in an environment file to use with docker or docker compose:
|
||||
#
|
||||
@@ -21,7 +21,7 @@
|
||||
# USER_NAME=foo
|
||||
# HOME=/home/foo
|
||||
#
|
||||
# docker run --rm --env-file env.d/production impress:latest python manage.py migrate
|
||||
# docker run --rm --env-file env.d/production people:latest python manage.py migrate
|
||||
#
|
||||
|
||||
echo "🐳(entrypoint) creating user running in the container..."
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Gunicorn-django settings
|
||||
bind = ["0.0.0.0:8000"]
|
||||
name = "impress"
|
||||
name = "people"
|
||||
python_path = "/app"
|
||||
|
||||
# Run
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
# What is People?
|
||||
|
||||
Space Odyssey is a dynamic organization. They use the People application to enhance teamwork and
|
||||
streamline communication among their co-workers. Let's explore how this application helps them
|
||||
interact efficiently.
|
||||
|
||||
Let's see how we could interact with Django's shell to recreate David's environment in the app.
|
||||
|
||||
## Base contacts from the organization records
|
||||
|
||||
David Bowman is an exemplary employee at Space Odyssey Corporation. His email is
|
||||
`david.bowman@spaceodyssey.com` and he is registered in the organization's records via a base
|
||||
contact as follows:
|
||||
|
||||
```python
|
||||
david_base_contact = Contact.objects.create(
|
||||
full_name="David Bowman",
|
||||
short_name="David",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"type": "Work",
|
||||
"street": "123 Main St",
|
||||
"city": "Cityville",
|
||||
"state": "CA",
|
||||
"zip": "12345",
|
||||
"country": "USA",
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{"type": "Website", "value": "http://www.spaceodyssey.com"},
|
||||
{"type": "Twitter", "value": "https://www.twitter.com/dbowman"},
|
||||
],
|
||||
"organizations": [
|
||||
{
|
||||
"name": "Space Odyssey Corporation",
|
||||
"department": "IT",
|
||||
"jobTitle": "AI Engineer",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
When David logs-in to the People application for the first time using the corporation's OIDC
|
||||
Single Sign-On service. A user is created for him on the fly by the system, together with an
|
||||
identity record representing the OIDC session:
|
||||
|
||||
```python
|
||||
david_user = User.objects.create(
|
||||
language="en-us",
|
||||
timezone="America/Los_Angeles",
|
||||
)
|
||||
david_identity = Identity.objects.create(
|
||||
"user": david_user,
|
||||
"sub": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"email" : "david.bowman@spaceodyssey.com",
|
||||
"is_main": True,
|
||||
)
|
||||
```
|
||||
|
||||
## Profile contact
|
||||
|
||||
The system identifies Dave through the email associated with his OIDC session and prompts him to
|
||||
confirm the details of the base contact stored in the database.
|
||||
|
||||
When David confirms, giving an alternative short name that he prefers, a contact override is
|
||||
created on top of the organization's base contact. This new contact is marked as David's profile
|
||||
on the user:
|
||||
|
||||
```python
|
||||
david_contact = Contact.objects.create(
|
||||
base=david_base_contact,
|
||||
owner=david_user,
|
||||
full_name="David Bowman",
|
||||
short_name="Dave",
|
||||
data={}
|
||||
)
|
||||
david_user.profile_contact = david_contact
|
||||
david_user.save()
|
||||
```
|
||||
|
||||
If Dave had not had any existing contact in the organization's records, the profile contact would
|
||||
have been created independently, without any connection to a base contact:
|
||||
|
||||
```python
|
||||
david_contact = Contact.objects.create(
|
||||
base=None,
|
||||
owner=david_user,
|
||||
full_name="David Bowman",
|
||||
short_name="Dave",
|
||||
data={}
|
||||
)
|
||||
```
|
||||
|
||||
Now, Dave feels like sharing his mobile phone number with his colleagues. He can do this
|
||||
by editing his contact in the application:
|
||||
|
||||
```python
|
||||
contact.data["phones"] = [
|
||||
{"type": "Mobile", "value": "(123) 456-7890"},
|
||||
]
|
||||
contact.save()
|
||||
```
|
||||
|
||||
## Contact override
|
||||
|
||||
During a Space conference he attended, Dave met Dr Ryan Stone, a medical engineer who gave him
|
||||
her professional email address. Ryan is already present in the system but her email is missing.
|
||||
Dave can add it to his private version of the contact:
|
||||
|
||||
```python
|
||||
ryan_base_contact = Contact.objects.create(
|
||||
full_name="Ryan Stone",
|
||||
data={}
|
||||
)
|
||||
ryan_contact = Contact.objects.create(
|
||||
base=ryan_base_contact,
|
||||
owner=david_user,
|
||||
full_name="Ryan Stone",
|
||||
short_name="Dr Ryan",
|
||||
data={
|
||||
"emails": [
|
||||
{"type": "Work", "value": "ryan.stone@hubblestation.com"},
|
||||
],
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
Dave wants to form a team with Ryan and other colleagues to work together better on using the organization's digital tools for their projects.
|
||||
|
||||
Dave would like to create a team with Ryan and some other colleagues, to enhance collaboration
|
||||
throughout their projects:
|
||||
|
||||
```python
|
||||
projectx = Team.objects.create(name="Project X")
|
||||
```
|
||||
|
||||
A team can for example be used to create an email alias or to define role based access rights
|
||||
(RBAC) in a specific application or all applications of the organization's digital Suite.
|
||||
|
||||
Having created he team, Dave is automatically assigned the "owner" role. He invites Ryan,
|
||||
granting an "administrator" role to her so she can invite her own colleagues. Both of them can
|
||||
then proceed to invite other colleagues as simple members. If Ryan wants, she can upgrade a
|
||||
colleague to "administrator" but only David can upgrade someone to the "owner" status:
|
||||
|
||||
```python
|
||||
TeamAccess.objects.create(user=david_user, team=projectx, role="owner")
|
||||
TeamAccess.objects.create(user=ryan_user, team=projectx, role="administrator")
|
||||
TeamAccess.objects.create(user=julie_user, team=projectx, role="member")
|
||||
```
|
||||
|
||||
| Role | Member | Administrator | Owner |
|
||||
|-----------------------------------|--------|---------------|-------|
|
||||
| Can view team | ✔ | ✔ | ✔ |
|
||||
| Can set roles except for owners | | ✔ | ✔ |
|
||||
| Can set roles for owners | | | ✔ |
|
||||
| Can delete team | | | ✔ |
|
||||
|
||||
Importantly, the system ensures that there is always at least one owner left to maintain control
|
||||
of the team.
|
||||
|
||||
# Models overview
|
||||
|
||||
The following graph represents the application's models and their relationships:
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
%% Models
|
||||
|
||||
Contact {
|
||||
UUID id PK
|
||||
Contact base
|
||||
User owner
|
||||
string full_name
|
||||
string short_name
|
||||
json data
|
||||
DateTime created_at
|
||||
DateTime updated_at
|
||||
}
|
||||
|
||||
User {
|
||||
UUID id PK
|
||||
Contact profile_contact
|
||||
string language
|
||||
string timezone
|
||||
boolean is_device
|
||||
boolean is_staff
|
||||
boolean is_active
|
||||
DateTime created_at
|
||||
DateTime updated_at
|
||||
}
|
||||
|
||||
Identity {
|
||||
UUID id PK
|
||||
User user
|
||||
string sub
|
||||
Email email
|
||||
boolean is_main
|
||||
DateTime created_at
|
||||
DateTime updated_at
|
||||
}
|
||||
|
||||
Team {
|
||||
UUID id PK
|
||||
string name
|
||||
DateTime created_at
|
||||
DateTime updated_at
|
||||
}
|
||||
|
||||
TeamAccess {
|
||||
UUID id PK
|
||||
Team team
|
||||
User user
|
||||
string role
|
||||
DateTime created_at
|
||||
DateTime updated_at
|
||||
}
|
||||
|
||||
%% Relations
|
||||
User ||--o{ Contact : "owns"
|
||||
Contact ||--o{ User : "profile for"
|
||||
User ||--o{ TeamAccess : ""
|
||||
Team ||--o{ TeamAccess : ""
|
||||
Identity ||--o{ User : "connects"
|
||||
Contact }o--|| Contact : "overrides"
|
||||
```
|
||||
@@ -1,72 +0,0 @@
|
||||
# Releasing a new version
|
||||
|
||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
2. Bump the release number for backend project, frontend projects, and Helm files:
|
||||
|
||||
- for backend, update the version number by hand in `pyproject.toml`,
|
||||
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
|
||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: lasuite/impress-backend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
|
||||
...
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: lasuite/impress-frontend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1"
|
||||
|
||||
y-provider:
|
||||
image:
|
||||
repository: lasuite/impress-y-provider
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1"
|
||||
```
|
||||
|
||||
The new images don't exist _yet_: they will be created automatically later in the process.
|
||||
|
||||
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
|
||||
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
|
||||
```text
|
||||
🔖(minor) bump release to 4.18.0
|
||||
```
|
||||
|
||||
5. Open a pull request, wait for an approval from your peers and merge it.
|
||||
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
|
||||
7. Tag and push your commit:
|
||||
|
||||
```bash
|
||||
git tag v4.18.1 && git push origin tag v4.18.1
|
||||
```
|
||||
|
||||
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
|
||||
|
||||
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/impress-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/impress-frontend/tags) image tags are on Docker Hub.
|
||||
9. The release is now done!
|
||||
|
||||
# Deploying
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
Making a new release doesn't publish it automatically in production.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
|
||||
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
|
||||
|
||||
To publish the release you just made:
|
||||
|
||||
```bash
|
||||
git tag --force production v4.18.1
|
||||
git push --force origin production
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# Api client TypeScript
|
||||
|
||||
The backend application can automatically create a TypeScript client to be used in frontend
|
||||
applications. It is used in the People front application itself.
|
||||
|
||||
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
|
||||
and People's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
|
||||
|
||||
## Requirements
|
||||
|
||||
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
|
||||
install the backend application.
|
||||
|
||||
## Install openApiClientJs
|
||||
|
||||
```sh
|
||||
$ cd src/tsclient
|
||||
$ yarn install
|
||||
```
|
||||
|
||||
## Generate the client
|
||||
|
||||
```sh
|
||||
yarn generate:api:client:local <output_path_for_generated_client>
|
||||
```
|
||||
@@ -1,34 +1,28 @@
|
||||
# Django
|
||||
DJANGO_ALLOWED_HOSTS=*
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=impress.settings
|
||||
DJANGO_SETTINGS_MODULE=people.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
|
||||
# impress settings
|
||||
# People settings
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
|
||||
# Backend url
|
||||
IMPRESS_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=impress
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
PEOPLE_BASE_URL="http://localhost:8072"
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/people/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/userinfo
|
||||
|
||||
OIDC_RP_CLIENT_ID=impress
|
||||
OIDC_RP_CLIENT_ID=people
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=keycloak
|
||||
POSTGRES_USER=impress
|
||||
POSTGRES_USER=people
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=kc_postgresql
|
||||
DB_NAME=keycloak
|
||||
DB_USER=impress
|
||||
DB_USER=people
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5433
|
||||
DB_PORT=5433
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=impress
|
||||
POSTGRES_DB=people
|
||||
POSTGRES_USER=dinum
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=postgresql
|
||||
DB_NAME=impress
|
||||
DB_NAME=people
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5432
|
||||
DB_PORT=5432
|
||||
|
||||
@@ -24,7 +24,7 @@ class GitmojiTitle(LineRule):
|
||||
|
||||
def validate(self, title, _commit):
|
||||
"""
|
||||
Download the list possible gitmojis from the project's github repository and check that
|
||||
Download the list possible gitmojis from the project's GitHub repository and check that
|
||||
title contains one of them.
|
||||
"""
|
||||
gitmojis = requests.get(
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
||||
"matchPackageNames": ["node", "node-fetch", "i18next-parser", "eslint"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+1
-1
Submodule secrets updated: 38594182e8...d7cfe7bcdc
@@ -172,7 +172,7 @@ ignore-on-opaque-inference=yes
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local,responses,
|
||||
Template,Contact
|
||||
Team,Contact
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
@@ -447,10 +447,10 @@ max-bool-expr=5
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=20
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=10
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
include LICENSE
|
||||
include README.md
|
||||
recursive-include src/backend/impress *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2
|
||||
recursive-include src/backend/people *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2
|
||||
|
||||
+84
-69
@@ -1,5 +1,6 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
"""Admin classes and registrations for People's core app."""
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -7,11 +8,58 @@ from django.utils.translation import gettext_lazy as _
|
||||
from . import models
|
||||
|
||||
|
||||
class TemplateAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for template accesses."""
|
||||
class IdentityFormSet(forms.BaseInlineFormSet):
|
||||
"""
|
||||
Make the "is_main" field readonly when it is True so that declaring another identity
|
||||
works in the admin.
|
||||
"""
|
||||
|
||||
model = models.TemplateAccess
|
||||
def add_fields(self, form, index):
|
||||
"""Disable the "is_main" field when it is set to True"""
|
||||
super().add_fields(form, index)
|
||||
is_main_value = form.instance.is_main if form.instance else False
|
||||
form.fields["is_main"].disabled = is_main_value
|
||||
|
||||
|
||||
class IdentityInline(admin.TabularInline):
|
||||
"""Inline admin class for user identities."""
|
||||
|
||||
fields = (
|
||||
"sub",
|
||||
"email",
|
||||
"is_main",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
formset = IdentityFormSet
|
||||
model = models.Identity
|
||||
extra = 0
|
||||
readonly_fields = ("email", "created_at", "sub", "updated_at")
|
||||
|
||||
def has_add_permission(self, request, obj):
|
||||
"""
|
||||
Identities are automatically created on successful OIDC logins.
|
||||
Disable creating identities via the admin.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
class TeamAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for team accesses."""
|
||||
|
||||
extra = 0
|
||||
autocomplete_fields = ["user", "team"]
|
||||
model = models.TeamAccess
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
class TeamWebhookInline(admin.TabularInline):
|
||||
"""Inline admin class for team webhooks."""
|
||||
|
||||
extra = 0
|
||||
autocomplete_fields = ["team"]
|
||||
model = models.TeamWebhook
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
@@ -24,24 +72,11 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"admin_email",
|
||||
"password",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("admin_email", "language", "timezone")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -62,72 +97,38 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("email", "password1", "password2"),
|
||||
"fields": ("admin_email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (TemplateAccessInline,)
|
||||
inlines = (IdentityInline, TeamAccessInline)
|
||||
list_display = (
|
||||
"id",
|
||||
"sub",
|
||||
"full_name",
|
||||
"admin_email",
|
||||
"email",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_active",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = (
|
||||
"is_active",
|
||||
"-is_superuser",
|
||||
"-is_staff",
|
||||
"-is_device",
|
||||
"-updated_at",
|
||||
"full_name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
||||
readonly_fields = ("id", "created_at", "updated_at")
|
||||
search_fields = ("id", "admin_email", "identities__sub", "identities__email")
|
||||
|
||||
|
||||
@admin.register(models.Template)
|
||||
class TemplateAdmin(admin.ModelAdmin):
|
||||
"""Template admin interface declaration."""
|
||||
@admin.register(models.Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
"""Team admin interface declaration."""
|
||||
|
||||
inlines = (TemplateAccessInline,)
|
||||
|
||||
|
||||
class DocumentAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for template accesses."""
|
||||
|
||||
model = models.DocumentAccess
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.Document)
|
||||
class DocumentAdmin(admin.ModelAdmin):
|
||||
"""Document admin interface declaration."""
|
||||
|
||||
inlines = (DocumentAccessInline,)
|
||||
inlines = (TeamAccessInline, TeamWebhookInline)
|
||||
list_display = (
|
||||
"id",
|
||||
"title",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"name",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(models.Invitation)
|
||||
@@ -136,7 +137,7 @@ class InvitationAdmin(admin.ModelAdmin):
|
||||
|
||||
fields = (
|
||||
"email",
|
||||
"document",
|
||||
"team",
|
||||
"role",
|
||||
"created_at",
|
||||
"issuer",
|
||||
@@ -148,11 +149,25 @@ class InvitationAdmin(admin.ModelAdmin):
|
||||
)
|
||||
list_display = (
|
||||
"email",
|
||||
"document",
|
||||
"team",
|
||||
"created_at",
|
||||
"is_expired",
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj:
|
||||
# all fields read only = disable update
|
||||
return self.fields
|
||||
return self.readonly_fields
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Custom edit form. Remove 'save' buttons."""
|
||||
extra_context = extra_context or {}
|
||||
extra_context["show_save_and_continue"] = False
|
||||
extra_context["show_save"] = False
|
||||
extra_context["show_save_and_add_another"] = False
|
||||
return super().change_view(request, object_id, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.issuer = request.user
|
||||
obj.save()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Impress core API endpoints"""
|
||||
"""People core API endpoints"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -17,12 +17,14 @@ def exception_handler(exc, context):
|
||||
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
|
||||
"""
|
||||
if isinstance(exc, ValidationError):
|
||||
detail = exc.message_dict
|
||||
|
||||
if hasattr(exc, "message"):
|
||||
if hasattr(exc, "message_dict"):
|
||||
detail = exc.message_dict
|
||||
elif hasattr(exc, "message"):
|
||||
detail = exc.message
|
||||
elif hasattr(exc, "messages"):
|
||||
detail = exc.messages
|
||||
else:
|
||||
detail = ""
|
||||
|
||||
exc = drf_exceptions.ValidationError(detail=detail)
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""A JSONField for DRF to handle serialization/deserialization."""
|
||||
|
||||
import json
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class JSONField(serializers.Field):
|
||||
"""
|
||||
A custom field for handling JSON data.
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
"""
|
||||
Convert the JSON string to a Python dictionary for serialization.
|
||||
"""
|
||||
return value
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
Convert the Python dictionary to a JSON string for deserialization.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
return json.dumps(data)
|
||||
@@ -1,13 +1,9 @@
|
||||
"""Permission handlers for the impress core app."""
|
||||
"""Permission handlers for the People core app."""
|
||||
|
||||
from django.core import exceptions
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
}
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
@@ -16,16 +12,7 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.auth) or request.user.is_authenticated
|
||||
|
||||
|
||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
return bool(request.auth) if request.auth else request.user.is_authenticated
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
@@ -59,18 +46,10 @@ class IsOwnedOrPublic(IsAuthenticated):
|
||||
return False
|
||||
|
||||
|
||||
class AccessPermission(permissions.BasePermission):
|
||||
class AccessPermission(IsAuthenticated):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated or view.action != "create"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
abilities = obj.get_abilities(request.user)
|
||||
action = view.action
|
||||
try:
|
||||
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
||||
except KeyError:
|
||||
pass
|
||||
return abilities.get(action, False)
|
||||
return abilities.get(request.method.lower(), False)
|
||||
|
||||
+124
-237
@@ -1,30 +1,84 @@
|
||||
"""Client serializers for the impress core app."""
|
||||
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
"""Client serializers for the People core app."""
|
||||
|
||||
from rest_framework import exceptions, serializers
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class ContactSerializer(serializers.ModelSerializer):
|
||||
"""Serialize contacts."""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
fields = [
|
||||
"id",
|
||||
"base",
|
||||
"data",
|
||||
"full_name",
|
||||
"owner",
|
||||
"short_name",
|
||||
]
|
||||
read_only_fields = ["id", "owner"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "base" field readonly but only for update/patch."""
|
||||
validated_data.pop("base", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
A ModelSerializer that takes an additional `fields` argument that
|
||||
controls which fields should be displayed.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Don't pass the 'fields' arg up to the superclass
|
||||
fields = kwargs.pop("fields", None)
|
||||
|
||||
# Instantiate the superclass normally
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if fields is not None:
|
||||
# Drop any fields that are not specified in the `fields` argument.
|
||||
allowed = set(fields)
|
||||
existing = set(self.fields)
|
||||
for field_name in existing - allowed:
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class UserSerializer(DynamicFieldsModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
|
||||
email = serializers.ReadOnlyField()
|
||||
name = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = [
|
||||
"id",
|
||||
"email",
|
||||
"language",
|
||||
"name",
|
||||
"timezone",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
]
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
|
||||
|
||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize template accesses."""
|
||||
class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize team accesses."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.TeamAccess
|
||||
fields = ["id", "user", "role", "abilities"]
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "user" field is readonly but only on update."""
|
||||
validated_data.pop("user", None)
|
||||
@@ -53,103 +107,79 @@ class BaseAccessSerializer(serializers.ModelSerializer):
|
||||
message = (
|
||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
||||
if can_set_role_to
|
||||
else "You are not allowed to set this role for this template."
|
||||
else "You are not allowed to set this role for this team."
|
||||
)
|
||||
raise exceptions.PermissionDenied(message)
|
||||
|
||||
# Create
|
||||
else:
|
||||
try:
|
||||
resource_id = self.context["resource_id"]
|
||||
team_id = self.context["team_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a resource ID in kwargs to create a new access."
|
||||
"You must set a team ID in kwargs to create a new team access."
|
||||
) from exc
|
||||
|
||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
if not models.TeamAccess.objects.filter(
|
||||
team=team_id,
|
||||
user=user,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this resource."
|
||||
"You are not allowed to manage accesses for this team."
|
||||
)
|
||||
|
||||
if (
|
||||
role == models.RoleChoices.OWNER
|
||||
and not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
and not models.TeamAccess.objects.filter(
|
||||
team=team_id,
|
||||
user=user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
||||
).exists()
|
||||
):
|
||||
raise exceptions.PermissionDenied(
|
||||
"Only owners of a resource can assign other users as owners."
|
||||
"Only owners of a team can assign other users as owners."
|
||||
)
|
||||
|
||||
# pylint: disable=no-member
|
||||
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
|
||||
attrs["team_id"] = self.context["team_id"]
|
||||
return attrs
|
||||
|
||||
|
||||
class DocumentAccessSerializer(BaseAccessSerializer):
|
||||
"""Serialize document accesses."""
|
||||
class TeamAccessReadOnlySerializer(TeamAccessSerializer):
|
||||
"""Serialize team accesses for list and retrieve actions."""
|
||||
|
||||
user_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=models.User.objects.all(),
|
||||
write_only=True,
|
||||
source="user",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
user = UserSerializer(read_only=True)
|
||||
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
|
||||
|
||||
class Meta:
|
||||
model = models.DocumentAccess
|
||||
resource_field_name = "document"
|
||||
fields = ["id", "user", "user_id", "team", "role", "abilities"]
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
|
||||
class TemplateAccessSerializer(BaseAccessSerializer):
|
||||
"""Serialize template accesses."""
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
resource_field_name = "template"
|
||||
fields = ["id", "user", "team", "role", "abilities"]
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
|
||||
class BaseResourceSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class DocumentSerializer(BaseResourceSerializer):
|
||||
"""Serialize documents."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
accesses = DocumentAccessSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
model = models.TeamAccess
|
||||
fields = [
|
||||
"id",
|
||||
"content",
|
||||
"title",
|
||||
"user",
|
||||
"role",
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"abilities",
|
||||
]
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
slug = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -157,196 +187,53 @@ class DocumentSerializer(BaseResourceSerializer):
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
"""Dynamically make `id` read-only on PUT requests but writable on POST requests."""
|
||||
fields = super().get_fields()
|
||||
|
||||
def get_abilities(self, team) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request and request.method == "POST":
|
||||
fields["id"].read_only = False
|
||||
if request:
|
||||
return team.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
return fields
|
||||
|
||||
def validate_id(self, value):
|
||||
"""Ensure the provided ID does not already exist when creating a new document."""
|
||||
request = self.context.get("request")
|
||||
|
||||
# Only check this on POST (creation)
|
||||
if request and request.method == "POST":
|
||||
if models.Document.objects.filter(id=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A document with this ID already exists. You cannot override it."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class LinkDocumentSerializer(BaseResourceSerializer):
|
||||
"""
|
||||
Serialize link configuration for documents.
|
||||
We expose it separately from document in order to simplify and secure access control.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"link_role",
|
||||
"link_reach",
|
||||
]
|
||||
|
||||
|
||||
# Suppress the warning about not implementing `create` and `update` methods
|
||||
# since we don't use a model and only rely on the serializer for validation
|
||||
# pylint: disable=abstract-method
|
||||
class FileUploadSerializer(serializers.Serializer):
|
||||
"""Receive file upload requests."""
|
||||
|
||||
file = serializers.FileField()
|
||||
|
||||
def validate_file(self, file):
|
||||
"""Add file size and type constraints as defined in settings."""
|
||||
# Validate file size
|
||||
if file.size > settings.DOCUMENT_IMAGE_MAX_SIZE:
|
||||
max_size = settings.DOCUMENT_IMAGE_MAX_SIZE // (1024 * 1024)
|
||||
raise serializers.ValidationError(
|
||||
f"File size exceeds the maximum limit of {max_size:d} MB."
|
||||
)
|
||||
|
||||
# Validate file type
|
||||
mime_type, _ = mimetypes.guess_type(file.name)
|
||||
if mime_type not in settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES:
|
||||
mime_types = ", ".join(settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES)
|
||||
raise serializers.ValidationError(
|
||||
f"File type '{mime_type:s}' is not allowed. Allowed types are: {mime_types:s}"
|
||||
)
|
||||
|
||||
return file
|
||||
|
||||
|
||||
class TemplateSerializer(BaseResourceSerializer):
|
||||
"""Serialize templates."""
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"css",
|
||||
"code",
|
||||
"is_public",
|
||||
]
|
||||
read_only_fields = ["id", "accesses", "abilities"]
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class DocumentGenerationSerializer(serializers.Serializer):
|
||||
"""Serializer to receive a request to generate a document on a template."""
|
||||
|
||||
body = serializers.CharField(label=_("Body"))
|
||||
body_type = serializers.ChoiceField(
|
||||
choices=["html", "markdown"],
|
||||
label=_("Body type"),
|
||||
required=False,
|
||||
default="html",
|
||||
)
|
||||
format = serializers.ChoiceField(
|
||||
choices=["pdf", "docx"],
|
||||
label=_("Format"),
|
||||
required=False,
|
||||
default="pdf",
|
||||
)
|
||||
def get_slug(self, instance):
|
||||
"""Return slug from the team's name."""
|
||||
return instance.get_slug()
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Invitation
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"email",
|
||||
"document",
|
||||
"role",
|
||||
"issuer",
|
||||
"is_expired",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"document",
|
||||
"issuer",
|
||||
"is_expired",
|
||||
]
|
||||
|
||||
def get_abilities(self, invitation) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return invitation.get_abilities(request.user)
|
||||
return {}
|
||||
fields = ["id", "created_at", "email", "team", "role", "issuer", "is_expired"]
|
||||
read_only_fields = ["id", "created_at", "team", "issuer", "is_expired"]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate and restrict invitation to new user based on email."""
|
||||
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
role = attrs.get("role")
|
||||
|
||||
try:
|
||||
document_id = self.context["resource_id"]
|
||||
team_id = self.context["team_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a document ID in kwargs to create a new document invitation."
|
||||
"You must set a team ID in kwargs to create a new team invitation."
|
||||
) from exc
|
||||
|
||||
if not user and user.is_authenticated:
|
||||
raise exceptions.PermissionDenied(
|
||||
"Anonymous users are not allowed to create invitations."
|
||||
)
|
||||
|
||||
if not models.DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
document=document_id,
|
||||
if not models.TeamAccess.objects.filter(
|
||||
team=team_id,
|
||||
user=user,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage invitations for this document."
|
||||
"You are not allowed to manage invitation for this team."
|
||||
)
|
||||
|
||||
if (
|
||||
role == models.RoleChoices.OWNER
|
||||
and not models.DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
document=document_id,
|
||||
role=models.RoleChoices.OWNER,
|
||||
).exists()
|
||||
):
|
||||
raise exceptions.PermissionDenied(
|
||||
"Only owners of a document can invite other users as owners."
|
||||
)
|
||||
|
||||
attrs["document_id"] = document_id
|
||||
attrs["team_id"] = team_id
|
||||
attrs["issuer"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class VersionFilterSerializer(serializers.Serializer):
|
||||
"""Validate version filters applied to the list endpoint."""
|
||||
|
||||
version_id = serializers.CharField(required=False, allow_blank=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Util to generate S3 authorization headers for object storage access control"""
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our documents' content
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
+269
-554
@@ -1,53 +1,25 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models import (
|
||||
Min,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
)
|
||||
from django.http import Http404
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
filters,
|
||||
mixins,
|
||||
pagination,
|
||||
status,
|
||||
response,
|
||||
throttling,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
|
||||
from core import models
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from . import permissions, serializers
|
||||
|
||||
ATTACHMENTS_FOLDER = "attachments"
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
|
||||
MEDIA_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
|
||||
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
|
||||
)
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
ATTACHMENTS_FOLDER = "attachments"
|
||||
SIMILARITY_THRESHOLD = 0.04
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
@@ -125,37 +97,140 @@ class SerializerPerActionMixin:
|
||||
class Pagination(pagination.PageNumberPagination):
|
||||
"""Pagination to display no more than 100 objects per page sorted by creation date."""
|
||||
|
||||
ordering = "-created_on"
|
||||
max_page_size = 100
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
|
||||
class BurstRateThrottle(throttling.UserRateThrottle):
|
||||
"""
|
||||
Throttle rate for minutes. See DRF section in settings for default value.
|
||||
"""
|
||||
|
||||
scope = "burst"
|
||||
|
||||
|
||||
class SustainedRateThrottle(throttling.UserRateThrottle):
|
||||
"""
|
||||
Throttle rate for hours. See DRF section in settings for default value.
|
||||
"""
|
||||
|
||||
scope = "sustained"
|
||||
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
class ContactViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Contact ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsOwnedOrPublic]
|
||||
queryset = models.Contact.objects.all()
|
||||
serializer_class = serializers.ContactSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
|
||||
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:
|
||||
queryset = queryset.filter(
|
||||
# - belong to another user (keep public and owned contacts)
|
||||
Q(owner__isnull=True) | Q(owner=user),
|
||||
# - are profile contacts for a user
|
||||
user__isnull=True,
|
||||
# - are overriden base contacts
|
||||
overriding_contacts__isnull=True,
|
||||
)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
query = Func(Value(query), function="unaccent")
|
||||
similarity = TrigramSimilarity(
|
||||
Func("full_name", function="unaccent"),
|
||||
query,
|
||||
) + TrigramSimilarity(Func("short_name", function="unaccent"), query)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(
|
||||
similarity__gte=0.05
|
||||
) # Value determined by testing (test_api_contacts.py)
|
||||
.order_by("-similarity")
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created contact."""
|
||||
user = self.request.user
|
||||
serializer.validated_data["owner"] = user
|
||||
return super().perform_create(serializer)
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
|
||||
):
|
||||
"""User ViewSet"""
|
||||
"""
|
||||
User viewset for all interactions with user infos and teams.
|
||||
|
||||
GET /api/users/&q=query
|
||||
Return a list of users whose email matches the query. Similarity is
|
||||
calculated using trigram similarity, allowing for partial,
|
||||
case-insensitive matches and accented queries.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all()
|
||||
queryset = models.User.objects.all().order_by("-created_at")
|
||||
serializer_class = serializers.UserSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Limit listed users by querying the email field with a trigram similarity
|
||||
search if a query is provided.
|
||||
Limit listed users by excluding users already in the document if a document_id
|
||||
is provided.
|
||||
"""
|
||||
"""Limit listed users by a query. Pagination and throttle protection apply."""
|
||||
queryset = self.queryset
|
||||
|
||||
if self.action == "list":
|
||||
# Exclude all users already in the given document
|
||||
if document_id := self.request.GET.get("document_id", ""):
|
||||
queryset = queryset.exclude(documentaccess__document_id=document_id)
|
||||
# Exclude inactive contacts
|
||||
queryset = queryset.filter(
|
||||
is_active=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
"identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="_identities_main",
|
||||
)
|
||||
)
|
||||
|
||||
# Filter users by email similarity
|
||||
# Exclude all users already in the given team
|
||||
if team_id := self.request.GET.get("team_id", ""):
|
||||
queryset = queryset.exclude(teams__id=team_id)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
queryset = queryset.filter(email__trigram_word_similar=query)
|
||||
similarity = Max(
|
||||
TrigramSimilarity(
|
||||
Coalesce(
|
||||
Func("identities__email", function="unaccent"), Value("")
|
||||
),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(
|
||||
Func("identities__name", function="unaccent"), Value("")
|
||||
),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(similarity__gte=SIMILARITY_THRESHOLD)
|
||||
.order_by("-similarity")
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -164,59 +239,100 @@ class UserViewSet(
|
||||
methods=["get"],
|
||||
url_name="me",
|
||||
url_path="me",
|
||||
permission_classes=[permissions.IsAuthenticated],
|
||||
)
|
||||
def get_me(self, request):
|
||||
"""
|
||||
Return information on currently logged user
|
||||
"""
|
||||
context = {"request": request}
|
||||
return drf_response.Response(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
user = request.user
|
||||
return response.Response(
|
||||
self.serializer_class(user, context={"request": request}).data
|
||||
)
|
||||
|
||||
|
||||
class ResourceViewsetMixin:
|
||||
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
|
||||
class TeamViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Team ViewSet"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related resources."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
user_roles_query = (
|
||||
self.access_model_class.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
**{self.resource_field_name: OuterRef("pk")},
|
||||
)
|
||||
.values(self.resource_field_name)
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
"""Custom queryset to get user related teams."""
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=OuterRef("pk")
|
||||
).values("role")[:1]
|
||||
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
|
||||
user_role=Subquery(user_role_query)
|
||||
)
|
||||
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created object."""
|
||||
obj = serializer.save()
|
||||
self.access_model_class.objects.create(
|
||||
"""Set the current user as owner of the newly created team."""
|
||||
team = serializer.save()
|
||||
models.TeamAccess.objects.create(
|
||||
team=team,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
**{self.resource_field_name: obj},
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewsetMixin:
|
||||
"""Mixin with methods common to all access viewsets."""
|
||||
class TeamAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with team accesses.
|
||||
|
||||
GET /api/v1.0/teams/<team_id>/accesses/:<team_access_id>
|
||||
Return list of all team accesses related to the logged-in user or one
|
||||
team access if an id is provided.
|
||||
|
||||
POST /api/v1.0/teams/<team_id>/accesses/ with expected data:
|
||||
- user: str
|
||||
- role: str [owner|admin|member]
|
||||
Return newly created team access
|
||||
|
||||
PUT /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
|
||||
- role: str [owner|admin|member]
|
||||
Return updated team access
|
||||
|
||||
PATCH /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
|
||||
- role: str [owner|admin|member]
|
||||
Return partially updated team access
|
||||
|
||||
DELETE /api/v1.0/teams/<team_id>/accesses/<team_access_id>/
|
||||
Delete targeted team access
|
||||
"""
|
||||
|
||||
lookup_field = "pk"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = (
|
||||
models.TeamAccess.objects.all().select_related("user").order_by("-created_at")
|
||||
)
|
||||
list_serializer_class = serializers.TeamAccessReadOnlySerializer
|
||||
detail_serializer_class = serializers.TeamAccessSerializer
|
||||
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering = ["role"]
|
||||
ordering_fields = ["role", "email", "name"]
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list resource accesses"""
|
||||
"""User only needs to be authenticated to list team accesses"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
@@ -227,40 +343,48 @@ class ResourceAccessViewsetMixin:
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["resource_id"] = self.kwargs["resource_id"]
|
||||
context["team_id"] = self.kwargs["team_id"]
|
||||
return context
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in {"list", "retrieve"}:
|
||||
return self.list_serializer_class
|
||||
return self.detail_serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(
|
||||
**{self.resource_field_name: self.kwargs["resource_id"]}
|
||||
)
|
||||
queryset = queryset.filter(team=self.kwargs["team_id"])
|
||||
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
teams = user.teams
|
||||
user_roles_query = (
|
||||
queryset.filter(
|
||||
Q(user=user) | Q(team__in=teams),
|
||||
**{self.resource_field_name: self.kwargs["resource_id"]},
|
||||
)
|
||||
.values(self.resource_field_name)
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
if self.action in {"list", "retrieve"}:
|
||||
# Determine which role the logged-in user has in the team
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=self.kwargs["team_id"]
|
||||
).values("role")[:1]
|
||||
|
||||
user_main_identity_query = models.Identity.objects.filter(
|
||||
user=OuterRef("user_id"), is_main=True
|
||||
)
|
||||
|
||||
# Limit to resource access instances related to a resource THAT also has
|
||||
# a resource access
|
||||
# instance for the logged-in user (we don't want to list only the resource
|
||||
# access instances pointing to the logged-in user)
|
||||
queryset = (
|
||||
# The logged-in user should be part of a team to see its accesses
|
||||
queryset.filter(
|
||||
Q(**{f"{self.resource_field_name}__accesses__user": user})
|
||||
| Q(**{f"{self.resource_field_name}__accesses__team__in": teams}),
|
||||
**{self.resource_field_name: self.kwargs["resource_id"]},
|
||||
team__accesses__user=self.request.user,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"user__identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="_identities_main",
|
||||
)
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each team access
|
||||
.annotate(
|
||||
user_role=Subquery(user_role_query),
|
||||
email=Subquery(user_main_identity_query.values("email")[:1]),
|
||||
name=Subquery(user_main_identity_query.values("name")[:1]),
|
||||
)
|
||||
.annotate(user_roles=Subquery(user_roles_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
@@ -268,16 +392,13 @@ class ResourceAccessViewsetMixin:
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Forbid deleting the last owner access"""
|
||||
instance = self.get_object()
|
||||
resource = getattr(instance, self.resource_field_name)
|
||||
team = instance.team
|
||||
|
||||
# Check if the access being deleted is the last owner access for the resource
|
||||
if (
|
||||
instance.role == "owner"
|
||||
and resource.accesses.filter(role="owner").count() == 1
|
||||
):
|
||||
return drf_response.Response(
|
||||
{"detail": "Cannot delete the last owner access for the resource."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
# Check if the access being deleted is the last owner access for the team
|
||||
if instance.role == "owner" and team.accesses.filter(role="owner").count() == 1:
|
||||
return response.Response(
|
||||
{"detail": "Cannot delete the last owner access for the team."},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
@@ -291,493 +412,87 @@ class ResourceAccessViewsetMixin:
|
||||
"role" in self.request.data
|
||||
and self.request.data["role"] != models.RoleChoices.OWNER
|
||||
):
|
||||
resource = getattr(instance, self.resource_field_name)
|
||||
# Check if the access being updated is the last owner access for the resource
|
||||
team = instance.team
|
||||
# Check if the access being updated is the last owner access for the team
|
||||
if (
|
||||
instance.role == models.RoleChoices.OWNER
|
||||
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
|
||||
and team.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
|
||||
):
|
||||
message = "Cannot change the role to a non-owner role for the last owner access."
|
||||
raise exceptions.PermissionDenied({"detail": message})
|
||||
raise exceptions.ValidationError({"role": message})
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
class DocumentViewSet(
|
||||
ResourceViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Document ViewSet"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.AccessPermission,
|
||||
]
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
access_model_class = models.DocumentAccess
|
||||
resource_field_name = "document"
|
||||
queryset = models.Document.objects.all()
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict resources returned by the list endpoint"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
Q(accesses__user=user)
|
||||
| Q(accesses__team__in=user.teams)
|
||||
| (
|
||||
Q(link_traces__user=user)
|
||||
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
|
||||
)
|
||||
)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
Add a trace that the document was accessed by a user. This is used to list documents
|
||||
on a user's list view even though the user has no specific role in the document (link
|
||||
access when the link reach configuration of the document allows it).
|
||||
"""
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance)
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
try:
|
||||
# Add a trace that the user visited the document (this is needed to include
|
||||
# the document in the user's list view)
|
||||
models.LinkTrace.objects.create(
|
||||
document=instance,
|
||||
user=self.request.user,
|
||||
)
|
||||
except ValidationError:
|
||||
# The trace already exists, so we just pass without doing anything
|
||||
pass
|
||||
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
@decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Return the document's versions but only those created after the user got access
|
||||
to the document
|
||||
"""
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
raise exceptions.PermissionDenied("Authentication required.")
|
||||
|
||||
# Validate query parameters using dedicated serializer
|
||||
serializer = serializers.VersionFilterSerializer(data=request.query_params)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
document = self.get_object()
|
||||
|
||||
# Users should not see version history dating from before they gained access to the
|
||||
# document. Filter to get the minimum access date for the logged-in user
|
||||
access_queryset = document.accesses.filter(
|
||||
Q(user=user) | Q(team__in=user.teams)
|
||||
).aggregate(min_date=Min("created_at"))
|
||||
|
||||
# Handle the case where the user has no accesses
|
||||
min_datetime = access_queryset["min_date"]
|
||||
if not min_datetime:
|
||||
return exceptions.PermissionDenied(
|
||||
"Only users with specific access can see version history"
|
||||
)
|
||||
|
||||
versions_data = document.get_versions_slice(
|
||||
from_version_id=serializer.validated_data.get("version_id"),
|
||||
min_datetime=min_datetime,
|
||||
page_size=serializer.validated_data.get("page_size"),
|
||||
)
|
||||
|
||||
return drf_response.Response(versions_data)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["get", "delete"],
|
||||
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def versions_detail(self, request, pk, version_id, *args, **kwargs):
|
||||
"""Custom action to retrieve a specific version of a document"""
|
||||
document = self.get_object()
|
||||
|
||||
try:
|
||||
response = document.get_content_response(version_id=version_id)
|
||||
except (FileNotFoundError, ClientError) as err:
|
||||
raise Http404 from err
|
||||
|
||||
# Don't let users access versions that were created before they were given access
|
||||
# to the document
|
||||
user = request.user
|
||||
min_datetime = min(
|
||||
access.created_at
|
||||
for access in document.accesses.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
)
|
||||
)
|
||||
if response["LastModified"] < min_datetime:
|
||||
raise Http404
|
||||
|
||||
if request.method == "DELETE":
|
||||
response = document.delete_version(version_id)
|
||||
return drf_response.Response(
|
||||
status=response["ResponseMetadata"]["HTTPStatusCode"]
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"content": response["Body"].read().decode("utf-8"),
|
||||
"last_modified": response["LastModified"],
|
||||
"id": version_id,
|
||||
}
|
||||
)
|
||||
|
||||
@decorators.action(detail=True, methods=["put"], url_path="link-configuration")
|
||||
def link_configuration(self, request, *args, **kwargs):
|
||||
"""Update link configuration with specific rights (cf get_abilities)."""
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
|
||||
# Deserialize and validate the data
|
||||
serializer = serializers.LinkDocumentSerializer(
|
||||
document, data=request.data, partial=True
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
serializer.save()
|
||||
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
|
||||
def attachment_upload(self, request, *args, **kwargs):
|
||||
"""Upload a file related to a given document"""
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
|
||||
# Validate metadata in payload
|
||||
serializer = serializers.FileUploadSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
# Extract the file extension from the original filename
|
||||
file = serializer.validated_data["file"]
|
||||
extension = os.path.splitext(file.name)[1]
|
||||
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}{extension:s}"
|
||||
|
||||
default_storage.save(key, file)
|
||||
return drf_response.Response(
|
||||
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
|
||||
def retrieve_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a document's
|
||||
attachment file.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
|
||||
Based on the original url and the logged in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
|
||||
match = MEDIA_URL_PATTERN.search(original_url.path)
|
||||
|
||||
try:
|
||||
pk, attachment_key = match.groups()
|
||||
except AttributeError as excpt:
|
||||
raise exceptions.PermissionDenied() from excpt
|
||||
|
||||
# Check permission
|
||||
try:
|
||||
document = models.Document.objects.get(pk=pk)
|
||||
except models.Document.DoesNotExist as excpt:
|
||||
raise exceptions.PermissionDenied() from excpt
|
||||
|
||||
if not document.get_abilities(request.user).get("retrieve", False):
|
||||
raise exceptions.PermissionDenied()
|
||||
|
||||
# Generate authorization headers and return an authorization to proceed with the request
|
||||
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with document accesses.
|
||||
|
||||
GET /api/v1.0/documents/<resource_id>/accesses/:<document_access_id>
|
||||
Return list of all document accesses related to the logged-in user or one
|
||||
document access if an id is provided.
|
||||
|
||||
POST /api/v1.0/documents/<resource_id>/accesses/ with expected data:
|
||||
- user: str
|
||||
- role: str [administrator|editor|reader]
|
||||
Return newly created document access
|
||||
|
||||
PUT /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/ with expected data:
|
||||
- role: str [owner|admin|editor|reader]
|
||||
Return updated document access
|
||||
|
||||
PATCH /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/ with expected data:
|
||||
- role: str [owner|admin|editor|reader]
|
||||
Return partially updated document access
|
||||
|
||||
DELETE /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/
|
||||
Delete targeted document access
|
||||
"""
|
||||
|
||||
lookup_field = "pk"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
|
||||
queryset = models.DocumentAccess.objects.select_related("user").all()
|
||||
resource_field_name = "document"
|
||||
serializer_class = serializers.DocumentAccessSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Add a new access to the document and send an email to the new added user."""
|
||||
access = serializer.save()
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
access.document.email_invitation(
|
||||
language, access.user.email, access.role, self.request.user.email
|
||||
)
|
||||
|
||||
|
||||
class TemplateViewSet(
|
||||
ResourceViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Template ViewSet"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrSafe,
|
||||
permissions.AccessPermission,
|
||||
]
|
||||
serializer_class = serializers.TemplateSerializer
|
||||
access_model_class = models.TemplateAccess
|
||||
resource_field_name = "template"
|
||||
queryset = models.Template.objects.all()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict templates returned by the list endpoint"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
Q(accesses__user=user)
|
||||
| Q(accesses__team__in=user.teams)
|
||||
| Q(is_public=True)
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="generate-document",
|
||||
permission_classes=[permissions.AccessPermission],
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def generate_document(self, request, pk=None):
|
||||
"""
|
||||
Generate and return a document for this template around the
|
||||
body passed as argument.
|
||||
|
||||
2 types of body are accepted:
|
||||
- HTML: body_type = "html"
|
||||
- Markdown: body_type = "markdown"
|
||||
|
||||
2 types of documents can be generated:
|
||||
- PDF: format = "pdf"
|
||||
- Docx: format = "docx"
|
||||
"""
|
||||
serializer = serializers.DocumentGenerationSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
body = serializer.validated_data["body"]
|
||||
body_type = serializer.validated_data["body_type"]
|
||||
export_format = serializer.validated_data["format"]
|
||||
|
||||
template = self.get_object()
|
||||
return template.generate_document(body, body_type, export_format)
|
||||
|
||||
|
||||
class TemplateAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API ViewSet for all interactions with template accesses.
|
||||
|
||||
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
|
||||
Return list of all template accesses related to the logged-in user or one
|
||||
template access if an id is provided.
|
||||
|
||||
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
|
||||
- user: str
|
||||
- role: str [administrator|editor|reader]
|
||||
Return newly created template access
|
||||
|
||||
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
|
||||
- role: str [owner|admin|editor|reader]
|
||||
Return updated template access
|
||||
|
||||
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
|
||||
- role: str [owner|admin|editor|reader]
|
||||
Return partially updated template access
|
||||
|
||||
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
|
||||
Delete targeted template access
|
||||
"""
|
||||
|
||||
lookup_field = "pk"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
|
||||
queryset = models.TemplateAccess.objects.select_related("user").all()
|
||||
resource_field_name = "template"
|
||||
serializer_class = serializers.TemplateAccessSerializer
|
||||
|
||||
|
||||
class InvitationViewset(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to document.
|
||||
"""API ViewSet for user invitations to team.
|
||||
|
||||
GET /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/
|
||||
Return list of invitations related to that document or one
|
||||
document access if an id is provided.
|
||||
GET /api/v1.0/teams/<team_id>/invitations/:<invitation_id>/
|
||||
Return list of invitations related to that team or or one
|
||||
team access if an id is provided.
|
||||
|
||||
POST /api/v1.0/documents/<document_id>/invitations/ with expected data:
|
||||
POST /api/v1.0/teams/<team_id>/invitations/ with expected data:
|
||||
- email: str
|
||||
- role: str [administrator|editor|reader]
|
||||
Return newly created invitation (issuer and document are automatically set)
|
||||
- role: str [owner|admin|member]
|
||||
- issuer : User, automatically added from user making query, if allowed
|
||||
- team : Team, automatically added from requested URI
|
||||
Return newly created invitation
|
||||
|
||||
PATCH /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/ with expected data:
|
||||
- role: str [owner|admin|editor|reader]
|
||||
Return partially updated document invitation
|
||||
PUT / PATCH : Not permitted. Instead of updating your invitation,
|
||||
delete and create a new one.
|
||||
|
||||
DELETE /api/v1.0/documents/<document_id>/invitations/<invitation_id>/
|
||||
DELETE /api/v1.0/teams/<team_id>/invitations/<invitation_id>/
|
||||
Delete targeted invitation
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = (
|
||||
models.Invitation.objects.all()
|
||||
.select_related("document")
|
||||
.order_by("-created_at")
|
||||
models.Invitation.objects.all().select_related("team").order_by("-created_at")
|
||||
)
|
||||
serializer_class = serializers.InvitationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list invitations"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
return super().get_permissions()
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["resource_id"] = self.kwargs["resource_id"]
|
||||
context["team_id"] = self.kwargs["team_id"]
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(document=self.kwargs["resource_id"])
|
||||
queryset = queryset.filter(team=self.kwargs["team_id"])
|
||||
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
teams = user.teams
|
||||
|
||||
# Determine which role the logged-in user has in the document
|
||||
user_roles_query = (
|
||||
models.DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=teams),
|
||||
document=self.kwargs["resource_id"],
|
||||
)
|
||||
.values("document")
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
)
|
||||
# Determine which role the logged-in user has in the team
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=self.kwargs["team_id"]
|
||||
).values("role")[:1]
|
||||
|
||||
queryset = (
|
||||
# The logged-in user should be part of a document to see its accesses
|
||||
# The logged-in user should be part of a team to see its accesses
|
||||
queryset.filter(
|
||||
Q(document__accesses__user=user)
|
||||
| Q(document__accesses__team__in=teams),
|
||||
team__accesses__user=self.request.user,
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each document access
|
||||
.annotate(user_roles=Subquery(user_roles_query))
|
||||
# the user role on each team access
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save invitation to a document then send an email to the invited user."""
|
||||
invitation = serializer.save()
|
||||
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
invitation.document.email_invitation(
|
||||
language, invitation.email, invitation.role, self.request.user.email
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Impress Core application"""
|
||||
"""People Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the impress core app."""
|
||||
# """Configuration class for the People core app."""
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("impress core application")
|
||||
# verbose_name = _("People core application")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Authentication Backends for the Impress core app."""
|
||||
"""Authentication Backends for the People core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
@@ -9,7 +10,7 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
from core.models import Identity
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
@@ -46,75 +47,76 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
|
||||
try:
|
||||
userinfo = user_response.json()
|
||||
except ValueError:
|
||||
try:
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
except Exception as e:
|
||||
raise SuspiciousOperation(
|
||||
_("Invalid response format or token verification failed")
|
||||
) from e
|
||||
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Create a new user if no match is found."""
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
email = user_info.get("email")
|
||||
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
full_name = self.compute_full_name(user_info)
|
||||
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
"full_name": full_name,
|
||||
"short_name": short_name,
|
||||
}
|
||||
# Compute user name from OIDC name fields as defined in settings
|
||||
names_list = [
|
||||
user_info[field]
|
||||
for field in settings.USER_OIDC_FIELDS_TO_NAME
|
||||
if user_info.get(field)
|
||||
]
|
||||
user_info["name"] = " ".join(names_list) or None
|
||||
|
||||
sub = user_info.get("sub")
|
||||
if not sub:
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
user = (
|
||||
self.UserModel.objects.filter(identities__sub=sub)
|
||||
.annotate(
|
||||
identity_email=models.F("identities__email"),
|
||||
identity_name=models.F("identities__name"),
|
||||
)
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
if user:
|
||||
self.update_user_if_needed(user, claims)
|
||||
email = user_info.get("email")
|
||||
name = user_info.get("name")
|
||||
if (
|
||||
email
|
||||
and email != user.identity_email
|
||||
or name
|
||||
and name != user.identity_name
|
||||
):
|
||||
Identity.objects.filter(sub=sub).update(email=email, name=name)
|
||||
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
|
||||
user = self.create_user(user_info)
|
||||
|
||||
return user
|
||||
|
||||
def compute_full_name(self, user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
|
||||
full_name = " ".join(
|
||||
user_info[field] for field in name_fields if user_info.get(field)
|
||||
)
|
||||
return full_name or None
|
||||
def create_user(self, claims):
|
||||
"""Return a newly created User instance."""
|
||||
sub = claims.get("sub")
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email=email, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
has_changed = any(
|
||||
value and value != getattr(user, key) for key, value in claims.items()
|
||||
user = self.UserModel.objects.create(password="!") # noqa: S106
|
||||
Identity.objects.create(
|
||||
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
|
||||
)
|
||||
if has_changed:
|
||||
updated_claims = {key: value for key, value in claims.items() if value}
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
return user
|
||||
|
||||
@@ -3,6 +3,7 @@ Core application enums declaration
|
||||
"""
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
@@ -14,3 +15,11 @@ ALL_LANGUAGES = getattr(
|
||||
"ALL_LANGUAGES",
|
||||
[(language, _(name)) for language, name in global_settings.LANGUAGES],
|
||||
)
|
||||
|
||||
|
||||
class WebhookStatusChoices(models.TextChoices):
|
||||
"""Defines the possible statuses in which a webhook can be."""
|
||||
|
||||
FAILURE = "failure", _("Failure")
|
||||
PENDING = "pending", _("Pending")
|
||||
SUCCESS = "success", _("Success")
|
||||
|
||||
+144
-86
@@ -14,119 +14,177 @@ from core import models
|
||||
fake = Faker()
|
||||
|
||||
|
||||
class BaseContactFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create contacts for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
full_name = factory.Faker("name")
|
||||
short_name = factory.LazyAttributeSequence(
|
||||
lambda o, n: o.full_name.split()[0] if o.full_name else f"user{n!s}"
|
||||
)
|
||||
|
||||
data = factory.Dict(
|
||||
{
|
||||
"emails": factory.LazyAttribute(
|
||||
lambda x: [
|
||||
{
|
||||
"type": fake.random_element(["Home", "Work", "Other"]),
|
||||
"value": fake.email(),
|
||||
}
|
||||
for _ in range(fake.random_int(1, 3))
|
||||
]
|
||||
),
|
||||
"phones": factory.LazyAttribute(
|
||||
lambda x: [
|
||||
{
|
||||
"type": fake.random_element(
|
||||
[
|
||||
"Mobile",
|
||||
"Home",
|
||||
"Work",
|
||||
"Main",
|
||||
"Work Fax",
|
||||
"Home Fax",
|
||||
"Pager",
|
||||
"Other",
|
||||
]
|
||||
),
|
||||
"value": fake.phone_number(),
|
||||
}
|
||||
for _ in range(fake.random_int(1, 3))
|
||||
]
|
||||
),
|
||||
"addresses": factory.LazyAttribute(
|
||||
lambda x: [
|
||||
{
|
||||
"type": fake.random_element(["Home", "Work", "Other"]),
|
||||
"street": fake.street_address(),
|
||||
"city": fake.city(),
|
||||
"state": fake.state(),
|
||||
"zip": fake.zipcode(),
|
||||
"country": fake.country(),
|
||||
}
|
||||
for _ in range(fake.random_int(1, 3))
|
||||
]
|
||||
),
|
||||
"links": factory.LazyAttribute(
|
||||
lambda x: [
|
||||
{
|
||||
"type": fake.random_element(
|
||||
[
|
||||
"Profile",
|
||||
"Blog",
|
||||
"Website",
|
||||
"Twitter",
|
||||
"Facebook",
|
||||
"Instagram",
|
||||
"LinkedIn",
|
||||
"Other",
|
||||
]
|
||||
),
|
||||
"value": fake.url(),
|
||||
}
|
||||
for _ in range(fake.random_int(1, 3))
|
||||
]
|
||||
),
|
||||
"customFields": factory.LazyAttribute(
|
||||
lambda x: {
|
||||
f"custom_field_{i:d}": fake.word()
|
||||
for i in range(fake.random_int(1, 3))
|
||||
},
|
||||
),
|
||||
"organizations": factory.LazyAttribute(
|
||||
lambda x: [
|
||||
{
|
||||
"name": fake.company(),
|
||||
"department": fake.word(),
|
||||
"jobTitle": fake.job(),
|
||||
}
|
||||
for _ in range(fake.random_int(1, 3))
|
||||
]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ContactFactory(BaseContactFactory):
|
||||
"""A factory to create contacts for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Contact
|
||||
|
||||
base = factory.SubFactory("core.factories.ContactFactory", base=None, owner=None)
|
||||
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to random users for testing purposes."""
|
||||
"""A factory to create random users for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
django_get_or_create = ("admin_email",)
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
full_name = factory.Faker("name")
|
||||
short_name = factory.Faker("first_name")
|
||||
admin_email = factory.Faker("email")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
|
||||
class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create documents"""
|
||||
class IdentityFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create identities for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
django_get_or_create = ("title",)
|
||||
model = models.Identity
|
||||
django_get_or_create = ("sub",)
|
||||
|
||||
user = factory.SubFactory(UserFactory)
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
name = factory.Faker("name")
|
||||
|
||||
|
||||
class TeamFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create teams"""
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
django_get_or_create = ("name",)
|
||||
skip_postgeneration_save = True
|
||||
|
||||
title = factory.Sequence(lambda n: f"document{n}")
|
||||
content = factory.Sequence(lambda n: f"content{n}")
|
||||
link_reach = factory.fuzzy.FuzzyChoice(
|
||||
[a[0] for a in models.LinkReachChoices.choices]
|
||||
)
|
||||
link_role = factory.fuzzy.FuzzyChoice(
|
||||
[r[0] for r in models.LinkRoleChoices.choices]
|
||||
)
|
||||
name = factory.Sequence(lambda n: f"team{n}")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to document from a given list of users with or without roles."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
if isinstance(item, models.User):
|
||||
UserDocumentAccessFactory(document=self, user=item)
|
||||
else:
|
||||
UserDocumentAccessFactory(document=self, user=item[0], role=item[1])
|
||||
|
||||
@factory.post_generation
|
||||
def link_traces(self, create, extracted, **kwargs):
|
||||
"""Add link traces to document from a given list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.create(document=self, user=item)
|
||||
"""Add users to team from a given list of users with or without roles."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
for user_entry in extracted:
|
||||
if isinstance(user_entry, models.User):
|
||||
TeamAccessFactory(team=self, user=user_entry)
|
||||
else:
|
||||
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])
|
||||
|
||||
|
||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document user accesses for testing."""
|
||||
class TeamAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake team user accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.DocumentAccess
|
||||
model = models.TeamAccess
|
||||
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document team accesses for testing."""
|
||||
class TeamWebhookFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake team webhooks for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.DocumentAccess
|
||||
model = models.TeamWebhook
|
||||
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
team = factory.Sequence(lambda n: f"team{n}")
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class TemplateFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create templates"""
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
django_get_or_create = ("title",)
|
||||
skip_postgeneration_save = True
|
||||
|
||||
title = factory.Sequence(lambda n: f"template{n}")
|
||||
is_public = factory.Faker("boolean")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to template from a given list of users with or without roles."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
if isinstance(item, models.User):
|
||||
UserTemplateAccessFactory(template=self, user=item)
|
||||
else:
|
||||
UserTemplateAccessFactory(template=self, user=item[0], role=item[1])
|
||||
|
||||
|
||||
class UserTemplateAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake template user accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
|
||||
template = factory.SubFactory(TemplateFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake template team accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
|
||||
template = factory.SubFactory(TemplateFactory)
|
||||
team = factory.Sequence(lambda n: f"team{n}")
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
|
||||
|
||||
|
||||
class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
@@ -135,7 +193,7 @@ class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Invitation
|
||||
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
email = factory.Faker("email")
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||
issuer = factory.SubFactory(UserFactory)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Contact Information",
|
||||
"properties": {
|
||||
"emails": {
|
||||
"type": "array",
|
||||
"title": "Emails",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"title": "Type",
|
||||
"enum": ["Work", "Home", "Other"]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"title": "Email Address",
|
||||
"format": "email"
|
||||
}
|
||||
},
|
||||
"required": ["type", "value"]
|
||||
}
|
||||
},
|
||||
"phones": {
|
||||
"type": "array",
|
||||
"title": "Phones",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"title": "Type",
|
||||
"enum": ["Mobile", "Home", "Work", "Main", "Work Fax", "Home Fax", "Pager", "Other"]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"title": "Phone Number"
|
||||
}
|
||||
},
|
||||
"required": ["type", "value"]
|
||||
}
|
||||
},
|
||||
"addresses": {
|
||||
"type": "array",
|
||||
"title": "Addresses",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"title": "Type",
|
||||
"enum": ["Home", "Work", "Other"]
|
||||
},
|
||||
"street": {
|
||||
"type": "string",
|
||||
"title": "Street"
|
||||
},
|
||||
"city": {
|
||||
"type": "string",
|
||||
"title": "City"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"title": "State"
|
||||
},
|
||||
"zip": {
|
||||
"type": "string",
|
||||
"title": "ZIP Code"
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"title": "Country"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"type": "array",
|
||||
"title": "Links",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"title": "Type",
|
||||
"enum": ["Profile", "Blog", "Website", "Twitter", "Facebook", "Instagram", "LinkedIn", "Other"]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"title": "URL",
|
||||
"format": "uri"
|
||||
}
|
||||
},
|
||||
"required": ["type", "value"]
|
||||
}
|
||||
},
|
||||
"customFields": {
|
||||
"type": "object",
|
||||
"title": "Custom Fields",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"organizations": {
|
||||
"type": "array",
|
||||
"title": "Organizations",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Organization Name"
|
||||
},
|
||||
"department": {
|
||||
"type": "string",
|
||||
"title": "Department"
|
||||
},
|
||||
"jobTitle": {
|
||||
"type": "string",
|
||||
"title": "Job Title"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-28 20:29
|
||||
# Generated by Django 5.0.3 on 2024-03-25 22:58
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
@@ -18,39 +18,22 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL('CREATE EXTENSION IF NOT EXISTS pg_trgm;', 'DROP EXTENSION IF EXISTS pg_trgm;'),
|
||||
migrations.RunSQL('CREATE EXTENSION IF NOT EXISTS unaccent;', 'DROP EXTENSION IF EXISTS unaccent;'),
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
name='Team',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document',
|
||||
'verbose_name_plural': 'Documents',
|
||||
'db_table': 'impress_document',
|
||||
'ordering': ('title',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Template',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('code', models.TextField(blank=True, verbose_name='code')),
|
||||
('css', models.TextField(blank=True, verbose_name='css')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template',
|
||||
'verbose_name_plural': 'Templates',
|
||||
'db_table': 'impress_template',
|
||||
'ordering': ('title',),
|
||||
'verbose_name': 'Team',
|
||||
'verbose_name_plural': 'Teams',
|
||||
'db_table': 'people_team',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -60,10 +43,8 @@ class Migration(migrations.Migration):
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, 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='identity email address')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('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')),
|
||||
('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)),
|
||||
@@ -76,91 +57,132 @@ class Migration(migrations.Migration):
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'impress_user',
|
||||
'db_table': 'people_user',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentAccess',
|
||||
name='Contact',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('full_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='full name')),
|
||||
('short_name', models.CharField(blank=True, max_length=30, null=True, verbose_name='short name')),
|
||||
('data', models.JSONField(blank=True, help_text='A JSON object containing the contact information', verbose_name='contact information')),
|
||||
('base', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='overriding_contacts', to='core.contact')),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user relation',
|
||||
'verbose_name_plural': 'Document/user relations',
|
||||
'db_table': 'impress_document_access',
|
||||
'ordering': ('-created_at',),
|
||||
'verbose_name': 'contact',
|
||||
'verbose_name_plural': 'contacts',
|
||||
'db_table': 'people_contact',
|
||||
'ordering': ('full_name', 'short_name'),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='profile_contact',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='core.contact'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Identity',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('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')),
|
||||
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'identity',
|
||||
'verbose_name_plural': 'identities',
|
||||
'db_table': 'people_identity',
|
||||
'ordering': ('-is_main', 'email'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invitation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='email address')),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
|
||||
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document invitation',
|
||||
'verbose_name_plural': 'Document invitations',
|
||||
'db_table': 'impress_invitation',
|
||||
'verbose_name': 'Team invitation',
|
||||
'verbose_name_plural': 'Team invitations',
|
||||
'db_table': 'people_invitation',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TemplateAccess',
|
||||
name='TeamAccess',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.team')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template/user relation',
|
||||
'verbose_name_plural': 'Template/user relations',
|
||||
'db_table': 'impress_template_access',
|
||||
'ordering': ('-created_at',),
|
||||
'verbose_name': 'Team/user relation',
|
||||
'verbose_name_plural': 'Team/user relations',
|
||||
'db_table': 'people_team_access',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='users',
|
||||
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamWebhook',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('url', models.URLField(verbose_name='url')),
|
||||
('secret', models.CharField(blank=True, max_length=255, null=True, verbose_name='secret')),
|
||||
('status', models.CharField(choices=[('failure', 'Failure'), ('pending', 'Pending'), ('success', 'Success')], default='pending', max_length=10)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='core.team')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Team webhook',
|
||||
'verbose_name_plural': 'Team webhooks',
|
||||
'db_table': 'people_team_webhook',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
|
||||
model_name='contact',
|
||||
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contact',
|
||||
unique_together={('owner', 'base')},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
model_name='identity',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
|
||||
constraint=models.UniqueConstraint(fields=('email', 'team'), name='email_and_team_unique_together'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
model_name='teamaccess',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'team'), name='unique_team_user', violation_error_message='This user is already in this team.'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
|
||||
reverse_sql="DROP EXTENSION IF EXISTS pg_trgm;",
|
||||
),
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
# Generated by Django 5.1 on 2024-09-08 16:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_create_pg_trgm_extension'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_role',
|
||||
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='is_public',
|
||||
field=models.BooleanField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=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'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LinkTrace',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user link trace',
|
||||
'verbose_name_plural': 'Document/user link traces',
|
||||
'db_table': 'impress_link_trace',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.1 on 2024-09-08 17:04
|
||||
from django.db import migrations
|
||||
|
||||
def migrate_is_public_to_link_reach(apps, schema_editor):
|
||||
"""
|
||||
Forward migration: Migrate 'is_public' to 'link_reach'.
|
||||
If is_public == True, set link_reach to 'public'
|
||||
"""
|
||||
Document = apps.get_model('core', 'Document')
|
||||
Document.objects.filter(is_public=True).update(link_reach='public')
|
||||
|
||||
|
||||
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration: Migrate 'link_reach' back to 'is_public'.
|
||||
- If link_reach == 'public', set is_public to True
|
||||
- Else set is_public to False
|
||||
"""
|
||||
Document = apps.get_model('core', 'Document')
|
||||
Document.objects.filter(link_reach='public').update(is_public=True)
|
||||
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_document_link_reach_document_link_role_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_is_public_to_link_reach,
|
||||
reverse_migrate_link_reach_to_is_public
|
||||
),
|
||||
]
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.1 on 2024-09-09 17:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_migrate_is_public_to_link_reach'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-29 03:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='full_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='short_name',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=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'),
|
||||
),
|
||||
]
|
||||
@@ -1,128 +0,0 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-10 11:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
procedure = """
|
||||
DO $$
|
||||
DECLARE
|
||||
user_email TEXT;
|
||||
BEGIN
|
||||
-- Step 1: Create a temporary table (without the unique constraint)
|
||||
-- impress_document_access
|
||||
DROP TABLE IF EXISTS impress_document_access_tmp;
|
||||
CREATE TEMP TABLE impress_document_access_tmp AS
|
||||
SELECT * FROM impress_document_access;
|
||||
|
||||
-- impress_link_trace
|
||||
DROP TABLE IF EXISTS impress_link_trace_tmp;
|
||||
CREATE TEMP TABLE impress_link_trace_tmp AS
|
||||
SELECT * FROM impress_link_trace;
|
||||
|
||||
-- Step 2: Loop through each email that appears more than once
|
||||
FOR user_email IN
|
||||
SELECT email
|
||||
FROM impress_user
|
||||
GROUP BY email
|
||||
HAVING COUNT(email) > 1
|
||||
LOOP
|
||||
-- Step 3: Update user_id in the temporary table based on email
|
||||
-- For impress_document_access
|
||||
UPDATE impress_document_access_tmp
|
||||
SET user_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE user_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
-- For impress_link_trace
|
||||
UPDATE impress_link_trace_tmp
|
||||
SET user_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE user_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
-- update impress_invitation
|
||||
UPDATE impress_invitation
|
||||
SET issuer_id = (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE issuer_id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
);
|
||||
|
||||
DELETE FROM impress_user
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
)
|
||||
AND id != (
|
||||
SELECT id
|
||||
FROM impress_user
|
||||
WHERE email = user_email
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Processed updates for email: %', user_email;
|
||||
END LOOP;
|
||||
|
||||
-- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id)
|
||||
-- For impress_document_access
|
||||
DELETE FROM impress_document_access_tmp a
|
||||
USING impress_document_access_tmp b
|
||||
WHERE a.ctid < b.ctid -- Keep one row
|
||||
AND a.document_id = b.document_id
|
||||
AND a.user_id = b.user_id;
|
||||
|
||||
-- Step 5: Replace the original table with the cleaned-up temporary table
|
||||
TRUNCATE TABLE impress_document_access;
|
||||
|
||||
-- Insert cleaned-up data back into the original table
|
||||
INSERT INTO impress_document_access
|
||||
SELECT * FROM impress_document_access_tmp;
|
||||
|
||||
-- For impress_link_trace
|
||||
DELETE FROM impress_link_trace_tmp a
|
||||
USING impress_link_trace_tmp b
|
||||
WHERE a.ctid < b.ctid -- Keep one row
|
||||
AND a.document_id = b.document_id
|
||||
AND a.user_id = b.user_id;
|
||||
|
||||
-- Step 5: Replace the original table with the cleaned-up temporary table
|
||||
TRUNCATE TABLE impress_link_trace;
|
||||
|
||||
-- Insert cleaned-up data back into the original table
|
||||
INSERT INTO impress_link_trace
|
||||
SELECT * FROM impress_link_trace_tmp;
|
||||
|
||||
RAISE NOTICE 'Update and deduplication process completed.';
|
||||
END $$;
|
||||
"""
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_add_user_full_name_and_short_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(procedure),
|
||||
]
|
||||
+442
-668
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Generate Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Generate Document</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Generate PDF</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +1,12 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
import re
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
from core.factories import IdentityFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -23,10 +19,16 @@ def test_authentication_getter_existing_user_no_email(
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Create a user and its identity
|
||||
identity = IdentityFactory(name=None)
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
return {"sub": identity.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
@@ -35,60 +37,8 @@ def test_authentication_getter_existing_user_no_email(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email,
|
||||
the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub doesn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
identity.refresh_from_db()
|
||||
assert user == identity.user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
@@ -98,12 +48,19 @@ def test_authentication_getter_existing_user_with_email(
|
||||
When the user's info contains an email and targets an existing user,
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(full_name="John Doe", short_name="John")
|
||||
|
||||
identity = IdentityFactory(name="John Doe")
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": user.email,
|
||||
"sub": identity.sub,
|
||||
"email": identity.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
@@ -112,11 +69,11 @@ def test_authentication_getter_existing_user_with_email(
|
||||
|
||||
# Only 1 query because email and names have not changed
|
||||
with django_assert_num_queries(1):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
assert models.User.objects.get() == user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -132,16 +89,23 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the user when they change.
|
||||
It should update the email or name fields on the identity when they change.
|
||||
The email on the user should not be changed.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
|
||||
user_email = identity.user.admin_email
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"sub": identity.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
@@ -151,15 +115,17 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{first_name:s} {last_name:s}"
|
||||
assert user.short_name == first_name
|
||||
identity.refresh_from_db()
|
||||
assert identity.email == email
|
||||
assert identity.name == f"{first_name:s} {last_name:s}"
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
assert user == identity.user
|
||||
assert user.admin_email == user_email
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
@@ -178,10 +144,11 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email is None
|
||||
|
||||
assert user.admin_email is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
@@ -194,7 +161,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "impress@example.com"
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
@@ -205,15 +172,16 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email == email
|
||||
assert identity.name == "John Doe"
|
||||
|
||||
assert user.admin_email is None
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
|
||||
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
|
||||
"""The user's info doesn't contain a sub."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
@@ -234,74 +202,3 @@ def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypa
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_json_response():
|
||||
"""Test get_userinfo method with a JSON response."""
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
json={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "john.doe@example.com",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "John"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "john.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_token_response(monkeypatch):
|
||||
"""Test get_userinfo method with a token response."""
|
||||
|
||||
responses.add(
|
||||
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
|
||||
)
|
||||
|
||||
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||
return {
|
||||
"first_name": "Jane",
|
||||
"last_name": "Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "Jane"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "jane.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_invalid_response():
|
||||
"""
|
||||
Test get_userinfo method with an invalid JWT response that
|
||||
causes verify_token to raise an error.
|
||||
"""
|
||||
|
||||
responses.add(
|
||||
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Invalid response format or token verification failed",
|
||||
):
|
||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
@@ -38,7 +38,8 @@ def test_view_logout_anonymous():
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -59,7 +60,8 @@ def test_view_logout(mocked_oidc_logout_url):
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -94,7 +96,8 @@ def test_view_logout_callback_anonymous():
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -125,7 +128,8 @@ def test_view_logout_construct_oidc_logout_url(
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -155,7 +159,8 @@ def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -175,7 +180,8 @@ def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
@@ -201,7 +207,8 @@ def test_view_logout_callback_wrong_state(initial_state):
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Fixtures for tests in the impress core application"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
USER = "user"
|
||||
TEAM = "team"
|
||||
VIA = [USER, TEAM]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_teams():
|
||||
"""Mock for the "teams" property on the User model."""
|
||||
with mock.patch(
|
||||
"core.models.User.teams", new_callable=mock.PropertyMock
|
||||
) as mock_teams:
|
||||
yield mock_teams
|
||||
@@ -1,792 +0,0 @@
|
||||
"""
|
||||
Test document accesses API endpoints for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_document_accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list document accesses."""
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory.create_batch(2, document=document)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list document accesses for a document
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory.create_batch(3, document=document)
|
||||
|
||||
# Accesses for other documents to which the user is related should not be listed either
|
||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list document accesses for a document
|
||||
to which they are directly related, whatever their role in the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
user_access = None
|
||||
if via == USER:
|
||||
user_access = models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
user=user,
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
user_access = models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
team="lasuite",
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
|
||||
access1 = factories.TeamDocumentAccessFactory(document=document)
|
||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
# Accesses for other documents to which the user is related should not be listed either
|
||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
)
|
||||
|
||||
access2_user = serializers.UserSerializer(instance=access2.user).data
|
||||
base_user = serializers.UserSerializer(instance=user).data
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 3
|
||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": base_user if via == "user" else None,
|
||||
"team": "lasuite" if via == "team" else "",
|
||||
"role": user_access.role,
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": None,
|
||||
"team": access1.team,
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": access2_user,
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a document access.
|
||||
"""
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a document access for
|
||||
a document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Accesses related to another document should be excluded even if the user is related to it
|
||||
for access in [
|
||||
factories.UserDocumentAccessFactory(),
|
||||
factories.UserDocumentAccessFactory(user=user),
|
||||
]:
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"detail": "No DocumentAccess matches the given query."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
A user who is related to a document should be allowed to retrieve the
|
||||
associated document user accesses.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
access_user = serializers.UserSerializer(instance=access.user).data
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(access.id),
|
||||
"user": access_user,
|
||||
"team": "",
|
||||
"role": access.role,
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a document access."""
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_document_accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a document access for a document to which
|
||||
they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_authenticated_reader_or_editor(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Readers or editors of a document should not be allowed to update its accesses."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is a direct administrator in a document should be allowed to update a user
|
||||
access for this document, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
role=random.choice(["administrator", "editor", "reader"]),
|
||||
)
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(["administrator", "editor", "reader"]),
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_administrator_from_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an administrator in a document, should not be allowed to update
|
||||
the user access of an "owner" for this document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=other_user, role="owner"
|
||||
)
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an administrator in a document, should not be allowed to update
|
||||
the user access of another user to grant document ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "editor", "reader"]),
|
||||
)
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
# We are not allowed or not really updating the role
|
||||
if field == "role" or new_data["role"] == old_values["role"]:
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an owner in a document should be allowed to update
|
||||
a user access for this document whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
factories.UserFactory()
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document,
|
||||
)
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
|
||||
"""
|
||||
A user who is owner of a document should be allowed to update
|
||||
their own user access provided there are other owners in the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "editor", "reader"])
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
data={
|
||||
**old_values,
|
||||
"role": new_role,
|
||||
"user_id": old_values.get("user", {}).get("id")
|
||||
if old_values.get("user") is not None
|
||||
else None,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
|
||||
|
||||
# Delete
|
||||
|
||||
|
||||
def test_api_document_accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a document access."""
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_document_accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a document access for a
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a document access for a
|
||||
document in which they are a simple reader or editor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_administrators_except_owners(
|
||||
via, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Users who are administrators in a document should be allowed to delete an access
|
||||
from the document provided it is not ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, role=random.choice(["reader", "editor", "administrator"])
|
||||
)
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_teams):
|
||||
"""
|
||||
Users who are administrators in a document should not be allowed to delete an ownership
|
||||
access from the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document, role="owner")
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_owners(via, mock_user_teams):
|
||||
"""
|
||||
Users should be able to delete the document access of another user
|
||||
for a document of which they are owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 2
|
||||
assert models.DocumentAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a document
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.DocumentAccess.objects.count() == 1
|
||||
@@ -1,229 +0,0 @@
|
||||
"""
|
||||
Test document accesses API endpoints for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.core import mail
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_document_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create document accesses."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"document": str(document.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.DocumentAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_document_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create document accesses for a document to
|
||||
which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_create_authenticated_reader_or_editor(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Readers or editors of a document should not be allowed to create document accesses."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
for new_role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": new_role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
|
||||
"""
|
||||
Administrators of a document should be able to create document accesses
|
||||
except for the "owner" role.
|
||||
An email should be sent to the accesses to notify them of the adding.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a resource can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user_id": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
|
||||
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
|
||||
other_user = serializers.UserSerializer(instance=other_user).data
|
||||
assert response.json() == {
|
||||
"abilities": new_document_access.get_abilities(user),
|
||||
"id": str(new_document_access.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"user": other_user,
|
||||
}
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [other_user["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
"""
|
||||
Owners of a document should be able to create document accesses whatever the role.
|
||||
An email should be sent to the accesses to notify them of the adding.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user_id": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
|
||||
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
|
||||
other_user = serializers.UserSerializer(instance=other_user).data
|
||||
assert response.json() == {
|
||||
"id": str(new_document_access.id),
|
||||
"user": other_user,
|
||||
"team": "",
|
||||
"role": role,
|
||||
"abilities": new_document_access.get_abilities(user),
|
||||
}
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [other_user["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
@@ -1,664 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Invitation model
|
||||
"""
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
from django.core import mail
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_document_invitations__create__anonymous():
|
||||
"""Anonymous users should not be able to create invitations."""
|
||||
document = factories.DocumentFactory()
|
||||
invitation_values = {
|
||||
"email": "guest@example.com",
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_invitations__create__authenticated_outsider():
|
||||
"""Users outside of document should not be permitted to invite to document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
invitation_values = {
|
||||
"email": "guest@example.com",
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"inviting,invited,is_allowed",
|
||||
(
|
||||
["reader", "reader", False],
|
||||
["reader", "editor", False],
|
||||
["reader", "administrator", False],
|
||||
["reader", "owner", False],
|
||||
["editor", "reader", False],
|
||||
["editor", "editor", False],
|
||||
["editor", "administrator", False],
|
||||
["editor", "owner", False],
|
||||
["administrator", "reader", True],
|
||||
["administrator", "editor", True],
|
||||
["administrator", "administrator", True],
|
||||
["administrator", "owner", False],
|
||||
["owner", "reader", True],
|
||||
["owner", "editor", True],
|
||||
["owner", "administrator", True],
|
||||
["owner", "owner", True],
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_invitations__create__privileged_members(
|
||||
via, inviting, invited, is_allowed, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Only owners and administrators should be able to invite new users.
|
||||
Only owners can invite owners.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=inviting
|
||||
)
|
||||
|
||||
invitation_values = {
|
||||
"email": "guest@example.com",
|
||||
"role": invited,
|
||||
}
|
||||
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
if is_allowed:
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert models.Invitation.objects.count() == 1
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
assert (
|
||||
f"{user.email} shared a document with you: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
else:
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert models.Invitation.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_document_invitations__create__email_from_content_language():
|
||||
"""
|
||||
The email generated is from the language set in the Content-Language header
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
invitation_values = {
|
||||
"email": "guest@example.com",
|
||||
"role": "reader",
|
||||
}
|
||||
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
headers={"Content-Language": "fr-fr"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["email"] == "guest@example.com"
|
||||
assert models.Invitation.objects.count() == 1
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert (
|
||||
f"{user.email} a partagé un document avec vous: {document.title}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations__create__email_from_content_language_not_supported():
|
||||
"""
|
||||
If the language from the Content-Language is not supported
|
||||
it will display the default language, English.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
invitation_values = {
|
||||
"email": "guest@example.com",
|
||||
"role": "reader",
|
||||
}
|
||||
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
headers={"Content-Language": "not-supported"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["email"] == "guest@example.com"
|
||||
assert models.Invitation.objects.count() == 1
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.email} shared a document with you: {document.title}" in email_content
|
||||
|
||||
|
||||
def test_api_document_invitations__create__issuer_and_document_override():
|
||||
"""It should not be possible to set the "document" and "issuer" fields."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
other_document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
invitation_values = {
|
||||
"document": str(other_document.id),
|
||||
"issuer": str(factories.UserFactory().id),
|
||||
"email": "guest@example.com",
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
# document and issuer automatically set
|
||||
assert response.json()["document"] == str(document.id)
|
||||
assert response.json()["issuer"] == str(user.id)
|
||||
|
||||
|
||||
def test_api_document_invitations__create__cannot_duplicate_invitation():
|
||||
"""An email should not be invited multiple times to the same document."""
|
||||
existing_invitation = factories.InvitationFactory()
|
||||
document = existing_invitation.document
|
||||
|
||||
# Grant privileged role on the Document to the user
|
||||
user = factories.UserFactory()
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document, user=user, role="administrator"
|
||||
)
|
||||
|
||||
# Create a new invitation to the same document with the exact same email address
|
||||
invitation_values = {
|
||||
"email": existing_invitation.email,
|
||||
"role": random.choice(["administrator", "editor", "reader"]),
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == [
|
||||
"Document invitation with this Email address and Document already exists."
|
||||
]
|
||||
|
||||
|
||||
def test_api_document_invitations__create__cannot_invite_existing_users():
|
||||
"""
|
||||
It should not be possible to invite already existing users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
existing_user = factories.UserFactory()
|
||||
|
||||
# Build an invitation to the email of an exising identity in the db
|
||||
invitation_values = {
|
||||
"email": existing_user.email,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == ["This email is already associated to a registered user."]
|
||||
|
||||
|
||||
def test_api_document_invitations__list__anonymous_user():
|
||||
"""Anonymous users should not be able to list invitations."""
|
||||
document = factories.DocumentFactory()
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id}/invitations/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_invitations__list__authenticated(
|
||||
via, mock_user_teams, django_assert_num_queries
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list invitations for documents to which they are
|
||||
related, whatever the role and including invitations issued by other users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
role = random.choice(models.RoleChoices.choices)[0]
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
invitation = factories.InvitationFactory(
|
||||
document=document, role="administrator", issuer=user
|
||||
)
|
||||
other_invitations = factories.InvitationFactory.create_batch(
|
||||
2, document=document, role="reader", issuer=other_user
|
||||
)
|
||||
|
||||
# invitations from other documents should not be listed
|
||||
other_document = factories.DocumentFactory()
|
||||
factories.InvitationFactory.create_batch(2, document=other_document, role="reader")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(i.id),
|
||||
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"email": str(i.email),
|
||||
"document": str(document.id),
|
||||
"role": i.role,
|
||||
"issuer": str(i.issuer.id),
|
||||
"is_expired": False,
|
||||
"abilities": {
|
||||
"destroy": role in ["administrator", "owner"],
|
||||
"update": role in ["administrator", "owner"],
|
||||
"partial_update": role in ["administrator", "owner"],
|
||||
"retrieve": True,
|
||||
},
|
||||
}
|
||||
for i in [invitation, *other_invitations]
|
||||
],
|
||||
key=lambda x: x["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations__list__expired_invitations_still_listed(settings):
|
||||
"""
|
||||
Expired invitations are still listed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
users=[(user, "administrator"), (other_user, "owner")]
|
||||
)
|
||||
|
||||
# override settings to accelerate validation expiration
|
||||
settings.INVITATION_VALIDITY_DURATION = 1 # second
|
||||
expired_invitation = factories.InvitationFactory(
|
||||
document=document,
|
||||
role="reader",
|
||||
issuer=user,
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 1
|
||||
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(expired_invitation.id),
|
||||
"created_at": expired_invitation.created_at.isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"email": str(expired_invitation.email),
|
||||
"document": str(document.id),
|
||||
"role": expired_invitation.role,
|
||||
"issuer": str(expired_invitation.issuer.id),
|
||||
"is_expired": True,
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
key=lambda x: x["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations__retrieve__anonymous_user():
|
||||
"""
|
||||
Anonymous users should not be able to retrieve invitations.
|
||||
"""
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_document_invitations__retrieve__unrelated_user():
|
||||
"""
|
||||
Authenticated unrelated users should not be able to retrieve invitations.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
invitation = factories.InvitationFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_invitations__retrieve__document_member(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users related to the document should be able to retrieve invitations
|
||||
whatever their role in the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
invitation = factories.InvitationFactory()
|
||||
role = random.choice(models.RoleChoices.choices)[0]
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=invitation.document, user=user, role=role
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=invitation.document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(invitation.id),
|
||||
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"email": invitation.email,
|
||||
"document": str(invitation.document.id),
|
||||
"role": str(invitation.role),
|
||||
"issuer": str(invitation.issuer.id),
|
||||
"is_expired": False,
|
||||
"abilities": {
|
||||
"destroy": role in ["administrator", "owner"],
|
||||
"update": role in ["administrator", "owner"],
|
||||
"partial_update": role in ["administrator", "owner"],
|
||||
"retrieve": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_invitations__put_authenticated(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated user can put invitations.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
invitation = factories.InvitationFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=invitation.document, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=invitation.document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
|
||||
response = client.patch(url, {"email": "test@test.test"}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.email == "test@test.test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_invitations__patch_authenticated(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated user can patch invitations.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
invitation = factories.InvitationFactory(role="owner")
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=invitation.document, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=invitation.document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
assert invitation.role == "owner"
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
|
||||
response = client.patch(
|
||||
url,
|
||||
{"role": "reader"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.role == "reader"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
["put", "patch"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["editor", "reader"],
|
||||
)
|
||||
def test_api_document_invitations__update__forbidden__not_authenticated(
|
||||
method, via, role, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Update of invitations is currently forbidden.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
invitation = factories.InvitationFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=invitation.document, user=user, role=role
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=invitation.document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
|
||||
|
||||
response = client.put(url)
|
||||
|
||||
if method == "patch":
|
||||
response = client.patch(url)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert (
|
||||
response.json()["detail"]
|
||||
== "You do not have permission to perform this action."
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations__delete__anonymous():
|
||||
"""Anonymous user should not be able to delete invitations."""
|
||||
invitation = factories.InvitationFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_document_invitations__delete__authenticated_outsider():
|
||||
"""Members unrelated to a document should not be allowed to cancel invitations."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
invitation = factories.InvitationFactory(document=document)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_document_invitations__delete__privileged_members(
|
||||
role, via, mock_user_teams
|
||||
):
|
||||
"""Privileged member should be able to cancel invitation."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
invitation = factories.InvitationFactory(document=document)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user_teams):
|
||||
"""Readers or editors should not be able to cancel invitation."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
invitation = factories.InvitationFactory(document=document)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert (
|
||||
response.json()["detail"]
|
||||
== "You do not have permission to perform this action."
|
||||
)
|
||||
@@ -1,583 +0,0 @@
|
||||
"""
|
||||
Test document versions API endpoints for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
def test_api_document_versions_list_anonymous(role, reach):
|
||||
"""
|
||||
Anonymous users should not be allowed to list document versions for a document
|
||||
whatever the reach and role.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_role=role, link_reach=reach)
|
||||
|
||||
# Accesses and traces for other users should not interfere
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
models.LinkTrace.objects.create(document=document, user=factories.UserFactory())
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Authentication required."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_document_versions_list_authenticated_unrelated(reach):
|
||||
"""
|
||||
Authenticated users should not be allowed to list document versions for a document
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
factories.UserDocumentAccessFactory.create_batch(3, document=document)
|
||||
|
||||
# The versions of another document to which the user is related should not be listed either
|
||||
factories.UserDocumentAccessFactory(user=user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_list_authenticated_related_success(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list document versions for a document
|
||||
to which they are directly related, whatever their role in the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
user=user,
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
team="lasuite",
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
|
||||
# Other versions of documents to which the user has access should not be listed
|
||||
factories.UserDocumentAccessFactory(user=user)
|
||||
|
||||
# A version created before the user got access should be hidden
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 0
|
||||
|
||||
# Add a new version to the document
|
||||
for i in range(3):
|
||||
document.content = f"new content {i:d}"
|
||||
document.save()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
# The current version is not listed
|
||||
assert content["count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_list_authenticated_related_pagination(
|
||||
via, mock_user_teams
|
||||
):
|
||||
"""
|
||||
The list of versions should be paginated and exclude versions that were created prior to the
|
||||
user gaining access to the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
for i in range(3):
|
||||
document.content = f"before {i:d}"
|
||||
document.save()
|
||||
|
||||
if via == USER:
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
user=user,
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document,
|
||||
team="lasuite",
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
|
||||
for i in range(4):
|
||||
document.content = f"after {i:d}"
|
||||
document.save()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
)
|
||||
|
||||
content = response.json()
|
||||
assert content["is_truncated"] is False
|
||||
# The current version is not listed
|
||||
assert content["count"] == 3
|
||||
assert content["next_version_id_marker"] == ""
|
||||
all_version_ids = [version["version_id"] for version in content["versions"]]
|
||||
|
||||
# - set page size
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2",
|
||||
)
|
||||
|
||||
content = response.json()
|
||||
assert content["count"] == 2
|
||||
assert content["is_truncated"] is True
|
||||
marker = content["next_version_id_marker"]
|
||||
assert marker == all_version_ids[1]
|
||||
assert [
|
||||
version["version_id"] for version in content["versions"]
|
||||
] == all_version_ids[:2]
|
||||
|
||||
# - get page 2
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}",
|
||||
)
|
||||
|
||||
content = response.json()
|
||||
assert content["count"] == 1
|
||||
assert content["is_truncated"] is False
|
||||
assert content["next_version_id_marker"] == ""
|
||||
assert content["versions"][0]["version_id"] == all_version_ids[2]
|
||||
|
||||
|
||||
def test_api_document_versions_list_exceeds_max_page_size():
|
||||
"""Page size should not exceed the limit set on the serializer"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
document.content = "version 2"
|
||||
document.save()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/versions/?page_size=51")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"page_size": ["Ensure this value is less than or equal to 50."]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_document_versions_retrieve_anonymous(reach):
|
||||
"""
|
||||
Anonymous users should not be allowed to find specific versions for a document with
|
||||
restricted or authenticated link reach.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_document_versions_retrieve_authenticated_unrelated(reach):
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve specific versions for a
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
A user who is related to a document should be allowed to retrieve the
|
||||
associated document versions.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 1
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
|
||||
# Versions created before the document was shared should not be seen by the user
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
# Create a new version should not make it available to the user because
|
||||
# only the current version is available to the user but it is excluded
|
||||
# from the list
|
||||
document.content = "new content 1"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 2
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
# Adding one more version should make the previous version available to the user
|
||||
document.content = "new content 2"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 3
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] == "new content 1"
|
||||
|
||||
|
||||
def test_api_document_versions_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create document versions."""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
{"foo": "bar"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert response.json() == {"detail": 'Method "POST" not allowed.'}
|
||||
|
||||
|
||||
def test_api_document_versions_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create document versions for a document to
|
||||
which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
{"foo": "bar"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_create_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users related to a document should not be allowed to create document versions
|
||||
whatever their role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
{"foo": "bar"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_api_document_versions_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a document version."""
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
document = access.document
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 1
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
{"foo": "bar"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_api_document_versions_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a document version for a document to which
|
||||
they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
document = access.document
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 1
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
|
||||
{"foo": "bar"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_update_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users with access to a document should not be able to update its versions
|
||||
whatever their role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
assert len(document.get_versions_slice()["versions"]) == 1
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id!s}/",
|
||||
{"foo": "bar"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
# Delete
|
||||
|
||||
|
||||
def test_api_document_versions_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a document version."""
|
||||
access = factories.UserDocumentAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/documents/{access.document_id!s}/versions/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_document_versions_delete_authenticated(reach):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a document version for a
|
||||
public document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
version_id = document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a document version for a
|
||||
document in which they are a simple reader or editor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
# Create a new version should make it available to the user
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 1
|
||||
|
||||
version_id = versions[0]["version_id"]
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_versions_delete_administrator_or_owner(via, mock_user_teams):
|
||||
"""
|
||||
Users who are administrator or owner of a document should be allowed to delete a version.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
role = random.choice(["administrator", "owner"])
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
# Create a new version should make it available to the user
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
document.content = "new content 1"
|
||||
document.save()
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 1
|
||||
|
||||
version_id = versions[0]["version_id"]
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
# 404 because the version was created before the user was given access to the document
|
||||
assert response.status_code == 404
|
||||
|
||||
document.content = "new content 2"
|
||||
document.save()
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 2
|
||||
|
||||
version_id = versions[0]["version_id"]
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 1
|
||||
@@ -1,255 +0,0 @@
|
||||
"""
|
||||
Test file uploads API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("authenticated", "editor"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_attachment_upload_anonymous_forbidden(reach, role):
|
||||
"""
|
||||
Anonymous users should not be able to upload attachments if the link reach
|
||||
and role don't allow it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = APIClient().post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_anonymous_success():
|
||||
"""
|
||||
Anonymous users should be able to upload attachments to a document
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = APIClient().post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_id = match.group(1)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
|
||||
"""
|
||||
Users who are not related to a document can't upload attachments if the
|
||||
link reach and role don't allow it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("authenticated", "editor"),
|
||||
("public", "editor"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_attachment_upload_authenticated_success(reach, role):
|
||||
"""
|
||||
Autenticated who are not related to a document should be able to upload a file
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_id = match.group(1)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_attachment_upload_reader(via, mock_user_teams):
|
||||
"""
|
||||
Users who are simple readers on a document should not be allowed to upload an attachment.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_role="reader")
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="reader"
|
||||
)
|
||||
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
||||
"""
|
||||
Editors, administrators and owners of a document should be able to upload an attachment.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_id = match.group(1)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_invalid(client):
|
||||
"""Attempt to upload without a file should return an explicit error."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
|
||||
response = client.post(url, {}, format="multipart")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"file": ["No file was submitted."]}
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
||||
"""The uploaded file should not exceeed the maximum size in settings."""
|
||||
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
|
||||
# Create a temporary file larger than the allowed size
|
||||
content = b"a" * (1048576 + 1)
|
||||
file = ContentFile(content, name="test.jpg")
|
||||
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_type_not_allowed(settings):
|
||||
"""The uploaded file should be of a whitelisted type."""
|
||||
settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = ["image/jpeg", "image/png"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||
|
||||
# Create a temporary file with a not allowed type (e.g., text file)
|
||||
file = ContentFile(b"a" * 1048576, name="test.txt")
|
||||
|
||||
response = client.post(url, {"file": file}, format="multipart")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"file": [
|
||||
"File type 'text/plain' is not allowed. Allowed types are: image/jpeg, image/png"
|
||||
]
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: create
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.models import Document
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create documents."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"title": "my document",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
def test_api_documents_create_authenticated_success():
|
||||
"""
|
||||
Authenticated users should be able to create documents and should automatically be declared
|
||||
as the owner of the newly created document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"title": "my document",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "my document"
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_documents_create_authenticated_title_null():
|
||||
"""It should be possible to create several documents with a null title."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory(title=None)
|
||||
|
||||
response = client.post("/api/v1.0/documents/", {}, format="json")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert Document.objects.filter(title__isnull=True).count() == 2
|
||||
|
||||
|
||||
def test_api_documents_create_force_id_success():
|
||||
"""It should be possible to force the document ID when creating a document."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
forced_id = uuid4()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"id": str(forced_id),
|
||||
"title": "my document",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
documents = Document.objects.all()
|
||||
assert len(documents) == 1
|
||||
assert documents[0].id == forced_id
|
||||
|
||||
|
||||
def test_api_documents_create_force_id_existing():
|
||||
"""
|
||||
It should not be possible to use the ID of an existing document when forcing ID on creation.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"id": str(document.id),
|
||||
"title": "my document",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"id": ["A document with this ID already exists. You cannot override it."]
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a document."""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Document.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
def test_api_documents_delete_authenticated_unrelated(reach, role):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a document to which
|
||||
they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Document.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a document for which they are
|
||||
only a reader, editor or administrator.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.Document.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to delete a document they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Document.objects.exists() is False
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Tests for link configuration of documents on API endpoint"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_documents_link_configuration_update_anonymous(reach, role):
|
||||
"""Anonymous users should not be allowed to update a link configuration."""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.LinkDocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
assert document_values == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_documents_link_configuration_update_authenticated_unrelated(reach, role):
|
||||
"""
|
||||
Authenticated users should not be allowed to update the link configuration for
|
||||
a document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.LinkDocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
assert document_values == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "reader"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_link_configuration_update_authenticated_related_forbidden(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Users who are readers or editors of a document should not be allowed to update
|
||||
the link configuration.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.LinkDocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
assert document_values == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""
|
||||
A user who is administrator or owner of a document should be allowed to update
|
||||
the link configuration.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
new_document_values = serializers.LinkDocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
assert value == new_document_values[key]
|
||||
@@ -1,274 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import operator
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_documents_list_anonymous(reach, role):
|
||||
"""
|
||||
Anonymous users should not be allowed to list documents whatever the
|
||||
link reach and the role
|
||||
"""
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_direct():
|
||||
"""
|
||||
Authenticated users should be able to list documents they are a direct
|
||||
owner/administrator/member of or documents that have a link reach other
|
||||
than restricted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
documents = [
|
||||
access.document
|
||||
for access in factories.UserDocumentAccessFactory.create_batch(2, user=user)
|
||||
]
|
||||
|
||||
# Unrelated and untraced documents
|
||||
for reach in models.LinkReachChoices:
|
||||
for role in models.LinkRoleChoices:
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
expected_ids = {str(document.id) for document in documents}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_via_team(mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list documents they are a
|
||||
owner/administrator/member of via a team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mock_user_teams.return_value = ["team1", "team2", "unknown"]
|
||||
|
||||
documents_team1 = [
|
||||
access.document
|
||||
for access in factories.TeamDocumentAccessFactory.create_batch(2, team="team1")
|
||||
]
|
||||
documents_team2 = [
|
||||
access.document
|
||||
for access in factories.TeamDocumentAccessFactory.create_batch(3, team="team2")
|
||||
]
|
||||
|
||||
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
|
||||
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_link_reach_restricted():
|
||||
"""
|
||||
An authenticated user who has link traces to a document that is restricted should not
|
||||
see it on the list view
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_traces=[user], link_reach="restricted")
|
||||
|
||||
# Link traces for other documents or other users should not interfere
|
||||
models.LinkTrace.objects.create(document=document, user=factories.UserFactory())
|
||||
other_document = factories.DocumentFactory(link_reach="public")
|
||||
models.LinkTrace.objects.create(document=other_document, user=user)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
# Only the other document is returned but not the restricted document even though the user
|
||||
# visited it earlier (probably b/c it previously had public or authenticated reach...)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == str(other_document.id)
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
|
||||
"""
|
||||
An authenticated user who has link traces to a document with public or authenticated
|
||||
link reach should see it on the list view.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
documents = [
|
||||
factories.DocumentFactory(link_traces=[user], link_reach=reach)
|
||||
for reach in models.LinkReachChoices
|
||||
if reach != "restricted"
|
||||
]
|
||||
expected_ids = {str(document.id) for document in documents}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_documents_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document_ids = [
|
||||
str(access.document_id)
|
||||
for access in factories.UserDocumentAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/documents/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
document_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/documents/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
document_ids.remove(content["results"][0]["id"])
|
||||
assert document_ids == []
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_distinct():
|
||||
"""A document with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
document = factories.DocumentFactory(users=[user, other_user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(document.id)
|
||||
|
||||
|
||||
def test_api_documents_list_ordering_default():
|
||||
"""Documents should be ordered by descending "updated_at" by default"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(5, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
# Check that results are sorted by descending "updated_at" as expected
|
||||
for i in range(4):
|
||||
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
|
||||
|
||||
|
||||
def test_api_documents_list_ordering_by_fields():
|
||||
"""It should be possible to order by several fields"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(5, users=[user])
|
||||
|
||||
for parameter in [
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
"title",
|
||||
"-title",
|
||||
]:
|
||||
is_descending = parameter.startswith("-")
|
||||
field = parameter.lstrip("-")
|
||||
querystring = f"?ordering={parameter}"
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{querystring:s}")
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
# Check that results are sorted by the field in querystring as expected
|
||||
compare = operator.ge if is_descending else operator.le
|
||||
for i in range(4):
|
||||
assert compare(results[i][field], results[i + 1][field])
|
||||
@@ -1,583 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_retrieve_anonymous_public():
|
||||
"""Anonymous users should be allowed to retrieve public documents."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
"manage_accesses": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"retrieve": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||
def test_api_documents_retrieve_anonymous_restricted_or_authenticated(reach):
|
||||
"""Anonymous users should not be able to retrieve a document that is not public."""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(reach):
|
||||
"""
|
||||
Authenticated users should be able to retrieve a public document to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"link_configuration": False,
|
||||
"destroy": False,
|
||||
"manage_accesses": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"retrieve": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_retrieve_authenticated_trace_twice(reach):
|
||||
"""
|
||||
Accessing a document several times should not raise any error even though the
|
||||
trace already exists for this document and user.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
|
||||
)
|
||||
|
||||
client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
|
||||
)
|
||||
|
||||
# A second visit should not raise any error
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_documents_retrieve_authenticated_unrelated_restricted():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a document that is restricted and
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a document to which they
|
||||
are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
||||
access1_user = serializers.UserSerializer(instance=user).data
|
||||
access2_user = serializers.UserSerializer(instance=access2.user).data
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": access1_user,
|
||||
"team": "",
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": access2_user,
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve a restricted document related to
|
||||
teams in which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
|
||||
factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["readers"],
|
||||
["unknown", "readers"],
|
||||
["editors"],
|
||||
["unknown", "editors"],
|
||||
],
|
||||
)
|
||||
def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a document to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
expected_abilities = {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
}
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": access_reader.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": access_editor.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": access_administrator.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": access_owner.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["administrators"],
|
||||
["editors", "administrators"],
|
||||
["unknown", "administrators"],
|
||||
],
|
||||
)
|
||||
def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a document to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["owners"],
|
||||
["owners", "administrators"],
|
||||
["members", "administrators", "owners"],
|
||||
["unknown", "owners"],
|
||||
],
|
||||
)
|
||||
def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a restricted document to which
|
||||
they are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
# editable only if there is another owner role than the user's team...
|
||||
"destroy": other_access.role == "owner",
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"]
|
||||
if other_access.role == "owner"
|
||||
else [],
|
||||
"update": other_access.role == "owner",
|
||||
"partial_update": other_access.role == "owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
"""
|
||||
Test file uploads API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_retrieve_auth_anonymous_public():
|
||||
"""Anonymous users should be able to retrieve attachments linked to a public document"""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
filename = f"{uuid.uuid4()!s}.jpg"
|
||||
key = f"{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
|
||||
def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach):
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve attachments linked to a document
|
||||
with link reach set to authenticated or restricted.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
filename = f"{uuid.uuid4()!s}.jpg"
|
||||
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Authorization" not in response
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach):
|
||||
"""
|
||||
Authenticated users who are not related to a document should be able to retrieve
|
||||
attachments related to a document with public or authenticated link reach.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
filename = f"{uuid.uuid4()!s}.jpg"
|
||||
key = f"{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_documents_retrieve_auth_authenticated_restricted():
|
||||
"""
|
||||
Authenticated users who are not related to a document should not be allowed to
|
||||
retrieve attachments linked to a document that is restricted.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
filename = f"{uuid.uuid4()!s}.jpg"
|
||||
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Authorization" not in response
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_retrieve_auth_related(via, mock_user_teams):
|
||||
"""
|
||||
Users who have a specific access to a document, whatever the role, should be able to
|
||||
retrieve related attachments.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||
|
||||
filename = f"{uuid.uuid4()!s}.jpg"
|
||||
key = f"{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
@@ -1,306 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: update
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("authenticated", "editor"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_update_anonymous_forbidden(reach, role):
|
||||
"""
|
||||
Anonymous users should not be allowed to update a document when link
|
||||
configuration does not allow it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
assert document_values == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach,role",
|
||||
[
|
||||
("public", "reader"),
|
||||
("authenticated", "reader"),
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
|
||||
"""
|
||||
Authenticated users should not be allowed to update a document to which
|
||||
they are not related if the link configuration does not allow it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
assert document_values == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"is_authenticated,reach,role",
|
||||
[
|
||||
(False, "public", "editor"),
|
||||
(True, "public", "editor"),
|
||||
(True, "authenticated", "editor"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_update_anonymous_or_authenticated_unrelated(
|
||||
is_authenticated, reach, role
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to update a document to which
|
||||
they are not related if the link configuration allows it.
|
||||
"""
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
else:
|
||||
user = AnonymousUser()
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
else:
|
||||
assert value == new_document_values[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_update_authenticated_reader(via, mock_user_teams):
|
||||
"""
|
||||
Users who are reader of a document but not administrators should
|
||||
not be allowed to update it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_role="reader")
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="reader"
|
||||
)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
document.refresh_from_db()
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
assert document_values == old_document_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""A user who is editor, administrator or owner of a document should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
else:
|
||||
assert value == new_document_values[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_update_authenticated_owners(via, mock_user_teams):
|
||||
"""Administrators of a document should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/", new_document_values, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
else:
|
||||
assert value == new_document_values[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
|
||||
"""
|
||||
Being administrator or owner of a document should not grant authorization to update
|
||||
another document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document,
|
||||
team="lasuite",
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
|
||||
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
|
||||
old_document_values = serializers.DocumentSerializer(instance=other_document).data
|
||||
|
||||
new_document_values = serializers.DocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{other_document.id!s}/",
|
||||
new_document_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
other_document.refresh_from_db()
|
||||
other_document_values = serializers.DocumentSerializer(instance=other_document).data
|
||||
assert other_document_values == old_document_values
|
||||
@@ -24,7 +24,7 @@ def test_openapi_client_schema():
|
||||
"--api-version",
|
||||
"v1.0",
|
||||
"--urlconf",
|
||||
"core.urls",
|
||||
"people.api_urls",
|
||||
"--format",
|
||||
"openapi-json",
|
||||
"--file",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : create
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create team accesses."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"team": str(team.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TeamAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create team accesses for a team to
|
||||
which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_member():
|
||||
"""Members of a team should not be allowed to create team accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_administrator():
|
||||
"""
|
||||
Administrators of a team should be able to create team accesses except for the "owner" role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a team can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_owner():
|
||||
"""
|
||||
Owners of a team should be able to create team accesses whatever the role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_webhook():
|
||||
"""
|
||||
When the team has a webhook, creating a team access should fire a call.
|
||||
"""
|
||||
user, other_user = factories.UserFactory.create_batch(2)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response by scim provider using "responses":
|
||||
rsp = rsps.add(
|
||||
rsps.PATCH,
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert rsp.call_count == 1
|
||||
assert rsps.calls[0].request.url == webhook.url
|
||||
|
||||
# Payload sent to scim provider
|
||||
payload = json.loads(rsps.calls[0].request.body)
|
||||
assert payload == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "members",
|
||||
"value": [
|
||||
{
|
||||
"value": str(other_user.id),
|
||||
"email": None,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : delete
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_member():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team in which they are a simple member.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_administrators():
|
||||
"""
|
||||
Users who are administrators in a team should be allowed to delete an access
|
||||
from the team provided it is not ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_except_owners():
|
||||
"""
|
||||
Users should be able to delete the team access of another user
|
||||
for a team of which they are owner provided it is not an owner access.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_for_owners():
|
||||
"""
|
||||
Users should not be allowed to delete the team access of another owner
|
||||
even for a team in which they are direct owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_last_owner():
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a team
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_webhook():
|
||||
"""
|
||||
When the team has a webhook, deleting a team access should fire a call.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
webhook = factories.TeamWebhookFactory(team=team)
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
# Ensure successful response by scim provider using "responses":
|
||||
rsp = rsps.add(
|
||||
rsps.PATCH,
|
||||
re.compile(r".*/Groups/.*"),
|
||||
body="{}",
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
assert rsp.call_count == 1
|
||||
assert rsps.calls[0].request.url == webhook.url
|
||||
|
||||
# Payload sent to scim provider
|
||||
payload = json.loads(rsps.calls[0].request.body)
|
||||
assert payload == {
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "members",
|
||||
"value": [
|
||||
{
|
||||
"value": str(access.user.id),
|
||||
"email": None,
|
||||
"type": "User",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists() is False
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list team accesses."""
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(2, team=team)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list team accesses for a team
|
||||
to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(3, team=team)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_related():
|
||||
"""
|
||||
Authenticated users should be able to list team accesses for a team
|
||||
to which they are related, with a given role.
|
||||
"""
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
owner = factories.IdentityFactory(is_main=True)
|
||||
access1 = factories.TeamAccessFactory.create(
|
||||
team=team, user=owner.user, role="owner"
|
||||
)
|
||||
|
||||
administrator = factories.IdentityFactory(is_main=True)
|
||||
access2 = factories.TeamAccessFactory.create(
|
||||
team=team, user=administrator.user, role="administrator"
|
||||
)
|
||||
|
||||
# Ensure this user's role is different from other team members to test abilities' computation
|
||||
user_access = models.TeamAccess.objects.create(team=team, user=user, role="member")
|
||||
|
||||
# Grant other team accesses to the user, they should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 3
|
||||
assert sorted(response.json()["results"], key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": str(identity.email),
|
||||
"name": str(identity.name),
|
||||
},
|
||||
"role": str(user_access.role),
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": {
|
||||
"id": str(access1.user.id),
|
||||
"email": str(owner.email),
|
||||
"name": str(owner.name),
|
||||
},
|
||||
"role": str(access1.role),
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": {
|
||||
"id": str(access2.user.id),
|
||||
"email": str(administrator.email),
|
||||
"name": str(administrator.name),
|
||||
},
|
||||
"role": str(access2.role),
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_main_identity():
|
||||
"""
|
||||
Name and email should be returned from main identity only
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory(user=user, is_main=True)
|
||||
factories.IdentityFactory(user=user) # additional non-main identity
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
|
||||
# other team members should appear, with correct identity
|
||||
other_user = factories.UserFactory()
|
||||
other_main_identity = factories.IdentityFactory(is_main=True, user=other_user)
|
||||
factories.IdentityFactory(user=other_user)
|
||||
factories.TeamAccessFactory.create(team=team, user=other_user)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 2
|
||||
users_info = [
|
||||
(access["user"]["email"], access["user"]["name"])
|
||||
for access in response.json()["results"]
|
||||
]
|
||||
# user information should be returned from main identity
|
||||
assert sorted(users_info) == sorted(
|
||||
[
|
||||
(str(identity.email), str(identity.name)),
|
||||
(str(other_main_identity.email), str(other_main_identity.name)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_constant_numqueries(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
The number of queries should not depend on the amount of fetched accesses.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory(user=user, is_main=True)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
# Only 4 queries are needed to efficiently fetch team accesses,
|
||||
# related users and identities :
|
||||
# - query retrieving logged-in user for user_role annotation
|
||||
# - count from pagination
|
||||
# - query prefetching users' main identity
|
||||
# - distinct from viewset
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
# create 20 new team members
|
||||
for _ in range(20):
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
# num queries should still be 4
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_ordering():
|
||||
"""Team accesses can be ordered by "role"."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory(user=user, is_main=True)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user)
|
||||
|
||||
# create 20 new team members
|
||||
for _ in range(20):
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=role",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [team_access["role"] for team_access in response.json()["results"]]
|
||||
assert sorted(results) == results
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-role",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [team_access["role"] for team_access in response.json()["results"]]
|
||||
assert sorted(results, reverse=True) == results
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ordering_fields", ["name", "email"])
|
||||
def test_api_team_accesses_list_authenticated_ordering_user(ordering_fields):
|
||||
"""Team accesses can be ordered by user's fields "email" or "name"."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory(user=user, is_main=True)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user)
|
||||
|
||||
# create 20 new team members
|
||||
for _ in range(20):
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering={ordering_fields}",
|
||||
)
|
||||
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_fields]
|
||||
for team_access in response.json()["results"]
|
||||
]
|
||||
assert sorted(results, key=normalize) == results
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-{ordering_fields}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [
|
||||
team_access["user"][ordering_fields]
|
||||
for team_access in response.json()["results"]
|
||||
]
|
||||
assert sorted(results, reverse=True, key=normalize) == results
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a team access.
|
||||
"""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team access for
|
||||
a team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory(team=factories.TeamFactory())
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No TeamAccess matches the given query."}
|
||||
|
||||
# Accesses related to another team should be excluded even if the user is related to it
|
||||
for other_access in [
|
||||
factories.TeamAccessFactory(),
|
||||
factories.TeamAccessFactory(user=user),
|
||||
]:
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{other_access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No TeamAccess matches the given query."}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_related():
|
||||
"""
|
||||
A user who is related to a team should be allowed to retrieve the
|
||||
associated team user accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(access.id),
|
||||
"user": {
|
||||
"id": str(access.user.id),
|
||||
"email": str(identity.email),
|
||||
"name": str(identity.name),
|
||||
},
|
||||
"role": str(access.role),
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : update
|
||||
"""
|
||||
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team access for a team to which
|
||||
they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_member():
|
||||
"""Members of a team should not be allowed to update its accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_except_owner():
|
||||
"""
|
||||
A user who is an administrator in a team should be allowed to update a user
|
||||
access for this team, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(["administrator", "member"]),
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_from_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of an "owner" for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_to_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of another user to grant team ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
# We are not allowed or not really updating the role
|
||||
if field == "role" or new_data["role"] == old_values["role"]:
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_except_owner():
|
||||
"""
|
||||
A user who is an owner in a team should be allowed to update
|
||||
a user access for this team except for existing "owner" accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_for_owners():
|
||||
"""
|
||||
A user who is "owner" of a team should not be allowed to update
|
||||
an existing owner access for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_self():
|
||||
"""
|
||||
A user who is owner of a team should be allowed to update
|
||||
their own user access provided there are other owners in the team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "member"])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_201_CREATED,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import IdentityFactory, TeamFactory
|
||||
from core.models import Team
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create teams."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert not Team.objects.exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to create teams and should automatically be declared
|
||||
as the owner of the newly created team.
|
||||
"""
|
||||
identity = IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "my team",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_slugify_name():
|
||||
"""
|
||||
Creating teams should automatically generate a slug.
|
||||
"""
|
||||
identity = IdentityFactory()
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{"name": "my team"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == "my team"
|
||||
assert team.slug == "my-team"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"param",
|
||||
[
|
||||
("my team", "my-team"),
|
||||
("my team", "my-team"),
|
||||
("MY TEAM TOO", "my-team-too"),
|
||||
("mon équipe", "mon-equipe"),
|
||||
("front devs & UX", "front-devs-ux"),
|
||||
],
|
||||
)
|
||||
def test_api_teams_create_authenticated_expected_slug(param):
|
||||
"""
|
||||
Creating teams should automatically create unaccented, no unicode, lower-case slug.
|
||||
"""
|
||||
identity = IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": param[0],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
team = Team.objects.get()
|
||||
assert team.name == param[0]
|
||||
assert team.slug == param[1]
|
||||
|
||||
|
||||
def test_api_teams_create_authenticated_unique_slugs():
|
||||
"""
|
||||
Creating teams should raise an error if already existing slug.
|
||||
"""
|
||||
TeamFactory(name="existing team")
|
||||
identity = IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/teams/",
|
||||
{
|
||||
"name": "èxisting team",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["slug"] == ["Team with this Slug already exists."]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_204_NO_CONTENT,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team."""
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.Team.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_teams_delete_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team to which they are not
|
||||
related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
assert models.Team.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_teams_delete_authenticated_member():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team for which they are
|
||||
only a member.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/",
|
||||
)
|
||||
|
||||
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() == 1
|
||||
|
||||
|
||||
def test_api_teams_delete_authenticated_administrator():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team for which they are
|
||||
administrator.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/",
|
||||
)
|
||||
|
||||
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() == 1
|
||||
|
||||
|
||||
def test_api_teams_delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be able to delete a team for which they are directly
|
||||
owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
assert models.Team.objects.exists() is False
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list teams."""
|
||||
factories.TeamFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/api/v1.0/teams/")
|
||||
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list teams
|
||||
they are an owner/administrator/member of.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
expected_ids = {
|
||||
str(access.team.id)
|
||||
for access in factories.TeamAccessFactory.create_batch(5, user=user)
|
||||
}
|
||||
factories.TeamFactory.create_batch(2) # Other teams
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_teams_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(access.team.id)
|
||||
for access in factories.TeamAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/teams/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
team_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/teams/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
team_ids.remove(content["results"][0]["id"])
|
||||
assert team_ids == []
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated_distinct():
|
||||
"""A team with several related users should only be listed once."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[user, other_user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(team.id)
|
||||
|
||||
|
||||
def test_api_teams_order():
|
||||
"""
|
||||
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
team_ids.reverse()
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from newest to oldest"
|
||||
|
||||
|
||||
def test_api_teams_order_param():
|
||||
"""
|
||||
Test that the 'created_at' field is sorted in ascending order
|
||||
when the 'ordering' query parameter is set.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from oldest to newest"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a team."""
|
||||
team = factories.TeamFactory()
|
||||
response = APIClient().get(f"/api/v1.0/teams/{team.id}/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team to which they are
|
||||
not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a team to which they
|
||||
are related whatever the role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access1 = factories.TeamAccessFactory(team=team, user=user)
|
||||
access2 = factories.TeamAccessFactory(team=team)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert sorted(response.json().pop("accesses")) == sorted(
|
||||
[
|
||||
str(access1.id),
|
||||
str(access2.id),
|
||||
]
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"slug": team.slug,
|
||||
"abilities": team.get_abilities(user),
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: update
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_teams_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team."""
|
||||
team = factories.TeamFactory()
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_members():
|
||||
"""
|
||||
Users who are members of a team but not administrators should
|
||||
not be allowed to update it.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_administrators():
|
||||
"""Administrators of a team should be allowed to update it."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
initial_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
# generate new random values
|
||||
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
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"]:
|
||||
assert value == initial_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > initial_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
assert value == new_values[key]
|
||||
|
||||
|
||||
def test_api_teams_update_authenticated_owners():
|
||||
"""Administrators of a team should be allowed to update it,
|
||||
apart from read-only fields."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(
|
||||
instance=factories.TeamFactory.build()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
for key, value in team_values.items():
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
assert value == old_team_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_team_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
assert value == new_team_values[key]
|
||||
|
||||
|
||||
def test_api_teams_update_administrator_or_owner_of_another():
|
||||
"""
|
||||
Being administrator or owner of a team should not grant authorization to update
|
||||
another team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.TeamFactory(users=[(user, random.choice(["administrator", "owner"]))])
|
||||
team = factories.TeamFactory(name="Old name")
|
||||
old_team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
new_team_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
assert team_values == old_team_values
|
||||
|
||||
|
||||
def test_api_teams_update_existing_slug_should_return_error():
|
||||
"""
|
||||
Updating a team's name to an existing slug should return a bad request,
|
||||
instead of creating a duplicate.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.TeamFactory(name="Existing team", users=[(user, "administrator")])
|
||||
my_team = factories.TeamFactory(name="New team", users=[(user, "administrator")])
|
||||
|
||||
updated_values = serializers.TeamSerializer(instance=my_team).data
|
||||
# Update my team's name for existing team. Creates a duplicate slug
|
||||
updated_values["name"] = "existing team"
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{my_team.id!s}/",
|
||||
updated_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["slug"] == ["Team with this Slug already exists."]
|
||||
# Both teams names and slugs should be unchanged
|
||||
assert my_team.name == "New team"
|
||||
assert my_team.slug == "new-team"
|
||||
@@ -1,48 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.models import Template
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create templates."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/templates/",
|
||||
{
|
||||
"title": "my template",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Template.objects.exists()
|
||||
|
||||
|
||||
def test_api_templates_create_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to create templates and should automatically be declared
|
||||
as the owner of the newly created template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/templates/",
|
||||
{
|
||||
"title": "my template",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
template = Template.objects.get()
|
||||
assert template.title == "my template"
|
||||
assert template.accesses.filter(role="owner", user=user).exists()
|
||||
@@ -1,107 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: delete
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a template."""
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_templates_delete_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template to which they are not
|
||||
related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
is_public = random.choice([True, False])
|
||||
template = factories.TemplateFactory(is_public=is_public)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403 if is_public else 404
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_delete_authenticated_member_or_administrator(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template for which they are
|
||||
only a member or administrator.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to delete a template they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Template.objects.exists() is False
|
||||
@@ -1,208 +0,0 @@
|
||||
"""
|
||||
Test users API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_generate_document_anonymous_public():
|
||||
"""Anonymous users can generate pdf document with public templates."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {
|
||||
"body": "# Test markdown body",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_anonymous_not_public():
|
||||
"""
|
||||
Anonymous users should not be allowed to generate pdf document with templates
|
||||
that are not marked as public.
|
||||
"""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
data = {
|
||||
"body": "# Test markdown body",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_generate_document_authenticated_public():
|
||||
"""Authenticated users can generate pdf document with public templates."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "# Test markdown body"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_authenticated_not_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to generate pdf document with templates
|
||||
that are not marked as public.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
data = {"body": "# Test markdown body"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_generate_document_related(via, mock_user_teams):
|
||||
"""Users related to a template can generate pdf document."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserTemplateAccessFactory(user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamTemplateAccessFactory(team="lasuite")
|
||||
|
||||
data = {"body": "# Test markdown body"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_type_html():
|
||||
"""Generate pdf document with the body type html."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "<p>Test body</p>", "body_type": "html"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_type_markdown():
|
||||
"""Generate pdf document with the body type markdown."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "# Test markdown body", "body_type": "markdown"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_type_unknown():
|
||||
"""Generate pdf document with the body type unknown."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "# Test markdown body", "body_type": "unknown"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"body_type": [
|
||||
'"unknown" is not a valid choice.',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_generate_document_export_docx():
|
||||
"""Generate pdf document with the body type html."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "<p>Test body</p>", "body_type": "html", "format": "docx"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
response.headers["content-type"]
|
||||
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
@@ -1,220 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_list_anonymous():
|
||||
"""Anonymous users should only be able to list public templates."""
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
expected_ids = {str(template.id) for template in public_templates}
|
||||
|
||||
response = APIClient().get("/api/v1.0/templates/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_direct():
|
||||
"""
|
||||
Authenticated users should be able to list templates they are a direct
|
||||
owner/administrator/member of or that are public.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
related_templates = [
|
||||
access.template
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
|
||||
expected_ids = {
|
||||
str(template.id) for template in related_templates + public_templates
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 7
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_via_team(mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list templates they are a
|
||||
owner/administrator/member of via a team or that are public.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mock_user_teams.return_value = ["team1", "team2", "unknown"]
|
||||
|
||||
templates_team1 = [
|
||||
access.template
|
||||
for access in factories.TeamTemplateAccessFactory.create_batch(2, team="team1")
|
||||
]
|
||||
templates_team2 = [
|
||||
access.template
|
||||
for access in factories.TeamTemplateAccessFactory.create_batch(3, team="team2")
|
||||
]
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
|
||||
expected_ids = {
|
||||
str(template.id)
|
||||
for template in templates_team1 + templates_team2 + public_templates
|
||||
}
|
||||
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 7
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_templates_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template_ids = [
|
||||
str(access.template_id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/templates/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
template_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/templates/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
template_ids.remove(content["results"][0]["id"])
|
||||
assert template_ids == []
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_distinct():
|
||||
"""A template with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
template = factories.TemplateFactory(users=[user, other_user], is_public=True)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(template.id)
|
||||
|
||||
|
||||
def test_api_templates_list_order_default():
|
||||
"""The templates list should be sorted by 'created_at' in descending order by default."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template_ids = [
|
||||
str(access.template.id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_template_ids = [template["id"] for template in response_data["results"]]
|
||||
|
||||
template_ids.reverse()
|
||||
assert (
|
||||
response_template_ids == template_ids
|
||||
), "created_at values are not sorted from newest to oldest"
|
||||
|
||||
|
||||
def test_api_templates_list_order_param():
|
||||
"""
|
||||
The templates list is sorted by 'created_at' in ascending order when setting
|
||||
the "ordering" query parameter.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
templates_ids = [
|
||||
str(access.template.id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_template_ids = [template["id"] for template in response_data["results"]]
|
||||
|
||||
assert (
|
||||
response_template_ids == templates_ids
|
||||
), "created_at values are not sorted from oldest to newest"
|
||||
@@ -1,522 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_retrieve_anonymous_public():
|
||||
"""Anonymous users should be allowed to retrieve public templates."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"manage_accesses": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"title": template.title,
|
||||
"is_public": True,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_anonymous_not_public():
|
||||
"""Anonymous users should not be able to retrieve a template that is not public."""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_unrelated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve a public template to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"manage_accesses": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"title": template.title,
|
||||
"is_public": True,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_unrelated_not_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a template that is not public and
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access1 = factories.UserTemplateAccessFactory(template=template, user=user)
|
||||
access2 = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": str(user.id),
|
||||
"team": "",
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["user"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": template.is_public,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve a template related to teams in
|
||||
which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(template=template, team="owners", role="owner")
|
||||
factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["readers"],
|
||||
["unknown", "readers"],
|
||||
["editors"],
|
||||
["unknown", "editors"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
expected_abilities = {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
}
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": access_reader.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": access_editor.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": access_administrator.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": access_owner.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["administrators"],
|
||||
["members", "administrators"],
|
||||
["unknown", "administrators"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_administrators(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["owners"],
|
||||
["owners", "administrators"],
|
||||
["members", "administrators", "owners"],
|
||||
["unknown", "owners"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_owners(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
# editable only if there is another owner role than the user's team...
|
||||
"destroy": other_access.role == "owner",
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"]
|
||||
if other_access.role == "owner"
|
||||
else [],
|
||||
"update": other_access.role == "owner",
|
||||
"partial_update": other_access.role == "owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: update
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a template."""
|
||||
template = factories.TemplateFactory()
|
||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
new_template_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
template.refresh_from_db()
|
||||
template_values = serializers.TemplateSerializer(instance=template).data
|
||||
assert template_values == old_template_values
|
||||
|
||||
|
||||
def test_api_templates_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a template to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
new_template_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
template.refresh_from_db()
|
||||
template_values = serializers.TemplateSerializer(instance=template).data
|
||||
assert template_values == old_template_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
|
||||
"""
|
||||
Users who are readers of a template should not be allowed to update it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="reader"
|
||||
)
|
||||
|
||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
new_template_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
template.refresh_from_db()
|
||||
template_values = serializers.TemplateSerializer(instance=template).data
|
||||
assert template_values == old_template_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Administrator or owner of a template should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
new_template_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
template.refresh_from_db()
|
||||
template_values = serializers.TemplateSerializer(instance=template).data
|
||||
for key, value in template_values.items():
|
||||
if key in ["id", "accesses"]:
|
||||
assert value == old_template_values[key]
|
||||
else:
|
||||
assert value == new_template_values[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
|
||||
"""Administrators of a template should be allowed to update it."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
template.refresh_from_db()
|
||||
template_values = serializers.TemplateSerializer(instance=template).data
|
||||
for key, value in template_values.items():
|
||||
if key in ["id", "accesses"]:
|
||||
assert value == old_template_values[key]
|
||||
else:
|
||||
assert value == new_template_values[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
|
||||
"""
|
||||
Being administrator or owner of a template should not grant authorization to update
|
||||
another template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template,
|
||||
team="lasuite",
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
|
||||
is_public = random.choice([True, False])
|
||||
template = factories.TemplateFactory(title="Old title", is_public=is_public)
|
||||
old_template_values = serializers.TemplateSerializer(instance=template).data
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
new_template_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403 if is_public else 404
|
||||
|
||||
template.refresh_from_db()
|
||||
template_values = serializers.TemplateSerializer(instance=template).data
|
||||
assert template_values == old_template_values
|
||||
@@ -0,0 +1,678 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
CONTACT_DATA = {
|
||||
"emails": [
|
||||
{"type": "Work", "value": "john.doe@work.com"},
|
||||
{"type": "Home", "value": "john.doe@home.com"},
|
||||
],
|
||||
"phones": [
|
||||
{"type": "Work", "value": "(123) 456-7890"},
|
||||
{"type": "Other", "value": "(987) 654-3210"},
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"type": "Home",
|
||||
"street": "123 Main St",
|
||||
"city": "Cityville",
|
||||
"state": "CA",
|
||||
"zip": "12345",
|
||||
"country": "USA",
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{"type": "Blog", "value": "http://personalwebsite.com"},
|
||||
{"type": "Website", "value": "http://workwebsite.com"},
|
||||
],
|
||||
"customFields": {"custom_field_1": "value1", "custom_field_2": "value2"},
|
||||
"organizations": [
|
||||
{
|
||||
"name": "ACME Corporation",
|
||||
"department": "IT",
|
||||
"jobTitle": "Software Engineer",
|
||||
},
|
||||
{
|
||||
"name": "XYZ Ltd",
|
||||
"department": "Marketing",
|
||||
"jobTitle": "Marketing Specialist",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_no_query():
|
||||
"""
|
||||
Authenticated users should be able to list contacts without applying a query.
|
||||
Profile and base contacts should be excluded.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
# Let's have 5 contacts in database:
|
||||
assert user.profile_contact is not None # Excluded because profile contact
|
||||
base_contact = factories.BaseContactFactory() # Excluded because overriden
|
||||
factories.ContactFactory(
|
||||
base=base_contact
|
||||
) # Excluded because belongs to other user
|
||||
contact2 = factories.ContactFactory(
|
||||
base=base_contact, owner=user, full_name="Bernard"
|
||||
) # Included
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"id": str(contact2.id),
|
||||
"base": str(base_contact.id),
|
||||
"owner": str(contact2.owner.id),
|
||||
"data": contact2.data,
|
||||
"full_name": contact2.full_name,
|
||||
"short_name": contact2.short_name,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_by_full_name():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case insensitive and
|
||||
partial query on the full name.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="David Bowman")
|
||||
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
|
||||
frank = factories.BaseContactFactory(full_name="Frank Poole")
|
||||
factories.BaseContactFactory(full_name="Heywood Floyd")
|
||||
|
||||
# Full query should work
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/contacts/?q=ank")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(frank.id)]
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/contacts/?q=ole")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert contact_ids == [str(nicole.id), str(frank.id)]
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=ool")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(nicole.id), str(frank.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
|
||||
|
||||
# Unaccented full name
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=EEE")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=AAA")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_content():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=eee")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=aaa")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented query should find unaccented content."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unaccented full name
|
||||
response = client.get("/api/v1.0/contacts/?q=ééé")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
# Unaccented short name
|
||||
response = client.get("/api/v1.0/contacts/?q=ààà")
|
||||
|
||||
assert response.status_code == 200
|
||||
contact_ids = [contact["id"] for contact in response.json()]
|
||||
assert contact_ids == [str(dave.id)]
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_anonymous():
|
||||
"""Anonymous users should not be allowed to retrieve a user."""
|
||||
client = APIClient()
|
||||
contact = factories.ContactFactory()
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_owned():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a contact they own.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"base": str(contact.base.id),
|
||||
"owner": str(contact.owner.id),
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve public contacts.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"base": None,
|
||||
"owner": None,
|
||||
"data": contact.data,
|
||||
"full_name": contact.full_name,
|
||||
"short_name": contact.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_retrieve_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve another user's contacts.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_create_anonymous_forbidden():
|
||||
"""Anonymous users should not be able to create contacts via the API."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David",
|
||||
"short_name": "Bowman",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert not models.Contact.objects.exists()
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_missing_base():
|
||||
"""Anonymous users should be able to create users."""
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert models.Contact.objects.exists() is False
|
||||
|
||||
assert response.json() == {"base": ["This field is required."]}
|
||||
|
||||
|
||||
def test_api_contacts_create_authenticated_successful():
|
||||
"""Authenticated users should be able to create contacts."""
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
base_contact = factories.BaseContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Existing override for another user should not interfere
|
||||
factories.ContactFactory(base=base_contact)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"base": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Contact.objects.count() == 3
|
||||
|
||||
contact = models.Contact.objects.get(owner=user)
|
||||
assert response.json() == {
|
||||
"id": str(contact.id),
|
||||
"base": str(base_contact.id),
|
||||
"data": CONTACT_DATA,
|
||||
"full_name": "David Bowman",
|
||||
"owner": str(user.id),
|
||||
"short_name": "Dave",
|
||||
}
|
||||
|
||||
assert contact.full_name == "David Bowman"
|
||||
assert contact.short_name == "Dave"
|
||||
assert contact.data == CONTACT_DATA
|
||||
assert contact.base == base_contact
|
||||
assert contact.owner == user
|
||||
|
||||
|
||||
@override_settings(ALLOW_API_USER_CREATE=True)
|
||||
def test_api_contacts_create_authenticated_existing_override():
|
||||
"""
|
||||
Trying to create a contact for base contact that is already overriden by the user
|
||||
should receive a 400 error.
|
||||
"""
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
|
||||
base_contact = factories.BaseContactFactory()
|
||||
factories.ContactFactory(base=base_contact, owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/contacts/",
|
||||
{
|
||||
"base": str(base_contact.id),
|
||||
"full_name": "David Bowman",
|
||||
"short_name": "Dave",
|
||||
"data": CONTACT_DATA,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
assert response.json() == {
|
||||
"__all__": ["Contact with this Owner and Base already exists."]
|
||||
}
|
||||
|
||||
|
||||
def test_api_contacts_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_owned():
|
||||
"""
|
||||
Authenticated users should be allowed to update their own contacts.
|
||||
"""
|
||||
identity = factories.IdentityFactory(user__profile_contact=None)
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["base", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to update their profile contact.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
for key, value in contact_values.items():
|
||||
if key in ["base", "owner", "id"]:
|
||||
assert value == old_contact_values[key]
|
||||
else:
|
||||
assert value == new_contact_values[key]
|
||||
|
||||
|
||||
def test_api_contacts_update_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to update contacts owned by other users.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.ContactFactory() # owned by another user
|
||||
old_contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
|
||||
new_contact_values = serializers.ContactSerializer(
|
||||
instance=factories.ContactFactory()
|
||||
).data
|
||||
new_contact_values["base"] = str(factories.ContactFactory().id)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
new_contact_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
contact.refresh_from_db()
|
||||
contact_values = serializers.ContactSerializer(instance=contact).data
|
||||
assert contact_values == old_contact_values
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a list of contacts."""
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = APIClient().delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 4
|
||||
|
||||
|
||||
def test_api_contacts_delete_list_authenticated():
|
||||
"""Authenticated users should not be allowed to delete a list of contacts."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.ContactFactory.create_batch(2)
|
||||
|
||||
response = client.delete("/api/v1.0/contacts/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert models.Contact.objects.count() == 4
|
||||
|
||||
|
||||
def test_api_contacts_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to delete a contact."""
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete(f"/api/v1.0/contacts/{contact.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Contact.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a public contact.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
contact = factories.BaseContactFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be allowed to delete a contact they own.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Contact.objects.count() == 1
|
||||
assert models.Contact.objects.filter(id=contact.id).exists() is False
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_profile():
|
||||
"""
|
||||
Authenticated users should be allowed to delete their profile contact.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
contact = factories.ContactFactory(owner=user, base=None)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.Contact.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_contacts_delete_authenticated_other():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a contact they don't own.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
contact = factories.ContactFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/contacts/{contact.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Contact.objects.count() == 2
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Unit tests for the Invitation model
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_invitations__create__anonymous():
|
||||
"""Anonymous users should not be able to create invitations."""
|
||||
team = factories.TeamFactory()
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_invitations__create__authenticated_outsider():
|
||||
"""Users outside of team should not be permitted to invite to team."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory()
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_team_invitations__create__privileged_members(role):
|
||||
"""Owners and administrators should be able to invite new members."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, role)])
|
||||
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
|
||||
def test_api_team_invitations__create__members():
|
||||
"""
|
||||
Members should not be able to invite new members.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, "member")])
|
||||
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage invitation for this team."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_invitations__create__issuer_and_team_automatically_added():
|
||||
"""Team and issuer fields should auto-complete."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, "owner")])
|
||||
|
||||
# Generate a random invitation
|
||||
invitation = factories.InvitationFactory.build()
|
||||
invitation_data = {"email": invitation.email, "role": invitation.role}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_data,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
# team and issuer automatically set
|
||||
assert response.json()["team"] == str(team.id)
|
||||
assert response.json()["issuer"] == str(identity.user.id)
|
||||
|
||||
|
||||
def test_api_team_invitations__create__cannot_duplicate_invitation():
|
||||
"""An email should not be invited multiple times to the same team."""
|
||||
existing_invitation = factories.InvitationFactory()
|
||||
team = existing_invitation.team
|
||||
|
||||
# Grant privileged role on the Team to the user
|
||||
identity = factories.IdentityFactory()
|
||||
factories.TeamAccessFactory(team=team, user=identity.user, role="administrator")
|
||||
|
||||
# Create a new invitation to the same team with the exact same email address
|
||||
duplicated_invitation = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build(email=existing_invitation.email)
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
duplicated_invitation,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["__all__"] == [
|
||||
"Team invitation with this Email address and Team already exists."
|
||||
]
|
||||
|
||||
|
||||
def test_api_team_invitations__create__cannot_invite_existing_users():
|
||||
"""
|
||||
Should not be able to invite already existing users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
|
||||
existing_user = factories.IdentityFactory(is_main=True)
|
||||
|
||||
# Build an invitation to the email of an exising identity in the db
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build(email=existing_user.email, team=team)
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["email"] == [
|
||||
"This email is already associated to a registered user."
|
||||
]
|
||||
|
||||
|
||||
def test_api_team_invitations__list__anonymous_user():
|
||||
"""Anonymous users should not be able to list invitations."""
|
||||
team = factories.TeamFactory()
|
||||
response = APIClient().get(f"/api/v1.0/teams/{team.id}/invitations/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_team_invitations__list__authenticated():
|
||||
"""
|
||||
Authenticated user should be able to list invitations
|
||||
in teams they belong to, including from other issuers.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(identity.user, "administrator"), (other_user, "owner")]
|
||||
)
|
||||
invitation = factories.InvitationFactory(
|
||||
team=team, role="administrator", issuer=identity.user
|
||||
)
|
||||
other_invitations = factories.InvitationFactory.create_batch(
|
||||
2, team=team, role="member", issuer=other_user
|
||||
)
|
||||
|
||||
# invitations from other teams should not be listed
|
||||
other_team = factories.TeamFactory()
|
||||
factories.InvitationFactory.create_batch(2, team=other_team, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(i.id),
|
||||
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"email": str(i.email),
|
||||
"team": str(team.id),
|
||||
"role": i.role,
|
||||
"issuer": str(i.issuer.id),
|
||||
"is_expired": False,
|
||||
}
|
||||
for i in [invitation, *other_invitations]
|
||||
],
|
||||
key=lambda x: x["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_invitations__list__expired_invitations_still_listed(settings):
|
||||
"""
|
||||
Expired invitations are still listed.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(identity.user, "administrator"), (other_user, "owner")]
|
||||
)
|
||||
|
||||
# override settings to accelerate validation expiration
|
||||
settings.INVITATION_VALIDITY_DURATION = 1 # second
|
||||
expired_invitation = factories.InvitationFactory(
|
||||
team=team,
|
||||
role="member",
|
||||
issuer=identity.user,
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 1
|
||||
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(expired_invitation.id),
|
||||
"created_at": expired_invitation.created_at.isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"email": str(expired_invitation.email),
|
||||
"team": str(team.id),
|
||||
"role": expired_invitation.role,
|
||||
"issuer": str(expired_invitation.issuer.id),
|
||||
"is_expired": True,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_invitations__retrieve__anonymous_user():
|
||||
"""
|
||||
Anonymous user should not be able to retrieve invitations.
|
||||
"""
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_team_invitations__retrieve__unrelated_user():
|
||||
"""
|
||||
Authenticated unrelated users should not be able to retrieve invitations.
|
||||
"""
|
||||
user = factories.IdentityFactory(user=factories.UserFactory()).user
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_api_team_invitations__retrieve__team_member():
|
||||
"""
|
||||
Authenticated team members should be able to retrieve invitations
|
||||
whatever their role in the team.
|
||||
"""
|
||||
user = factories.IdentityFactory(user=factories.UserFactory()).user
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
factories.TeamAccessFactory(team=invitation.team, user=user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(invitation.id),
|
||||
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"email": invitation.email,
|
||||
"team": str(invitation.team.id),
|
||||
"role": str(invitation.role),
|
||||
"issuer": str(invitation.issuer.id),
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
["put", "patch"],
|
||||
)
|
||||
def test_api_team_invitations__update__forbidden(method):
|
||||
"""
|
||||
Update of invitations is currently forbidden.
|
||||
"""
|
||||
user = factories.IdentityFactory(user=factories.UserFactory()).user
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
factories.TeamAccessFactory(team=invitation.team, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = None
|
||||
if method == "put":
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
if method == "patch":
|
||||
response = client.patch(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response is not None
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
|
||||
|
||||
|
||||
def test_api_team_invitations__delete__anonymous():
|
||||
"""Anonymous user should not be able to delete invitations."""
|
||||
invitation = factories.InvitationFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_team_invitations__delete__authenticated_outsider():
|
||||
"""Members outside of team should not cancel invitations."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory()
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_team_invitations__delete__privileged_members(role):
|
||||
"""Privileged member should be able to cancel invitation."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, role)])
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
|
||||
def test_api_team_invitations__delete__members():
|
||||
"""Member should not be able to cancel invitation."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, "member")])
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert (
|
||||
response.json()["detail"]
|
||||
== "You do not have permission to perform this action."
|
||||
)
|
||||
@@ -1,974 +0,0 @@
|
||||
"""
|
||||
Test template accesses API endpoints for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_template_accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list template accesses."""
|
||||
template = factories.TemplateFactory()
|
||||
factories.UserTemplateAccessFactory.create_batch(2, template=template)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list template accesses for a template
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
factories.UserTemplateAccessFactory.create_batch(3, template=template)
|
||||
|
||||
# Accesses for other templates to which the user is related should not be listed either
|
||||
other_access = factories.UserTemplateAccessFactory(user=user)
|
||||
factories.UserTemplateAccessFactory(template=other_access.template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list template accesses for a template
|
||||
to which they are directly related, whatever their role in the template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
user_access = None
|
||||
if via == USER:
|
||||
user_access = models.TemplateAccess.objects.create(
|
||||
template=template,
|
||||
user=user,
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
user_access = models.TemplateAccess.objects.create(
|
||||
template=template,
|
||||
team="lasuite",
|
||||
role=random.choice(models.RoleChoices.choices)[0],
|
||||
)
|
||||
|
||||
access1 = factories.TeamTemplateAccessFactory(template=template)
|
||||
access2 = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
# Accesses for other templates to which the user is related should not be listed either
|
||||
other_access = factories.UserTemplateAccessFactory(user=user)
|
||||
factories.UserTemplateAccessFactory(template=other_access.template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 3
|
||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": str(user.id) if via == "user" else None,
|
||||
"team": "lasuite" if via == "team" else "",
|
||||
"role": user_access.role,
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": None,
|
||||
"team": access1.team,
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_template_accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a template access.
|
||||
"""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a template access for
|
||||
a template to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Accesses related to another template should be excluded even if the user is related to it
|
||||
for access in [
|
||||
factories.UserTemplateAccessFactory(),
|
||||
factories.UserTemplateAccessFactory(user=user),
|
||||
]:
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"detail": "No TemplateAccess matches the given query."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
|
||||
"""
|
||||
A user who is related to a template should be allowed to retrieve the
|
||||
associated template user accesses.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(access.id),
|
||||
"user": str(access.user.id),
|
||||
"team": "",
|
||||
"role": access.role,
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create template accesses."""
|
||||
user = factories.UserFactory()
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"template": str(template.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TemplateAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_template_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create template accesses for a template to
|
||||
which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_editor_or_reader(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Editors or readers of a template should not be allowed to create template accesses."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
for new_role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": new_role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
|
||||
"""
|
||||
Administrators of a template should be able to create template accesses
|
||||
except for the "owner" role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a resource can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
|
||||
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_template_access.get_abilities(user),
|
||||
"id": str(new_template_access.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
"""
|
||||
Owners of a template should be able to create template accesses whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
|
||||
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"id": str(new_template_access.id),
|
||||
"user": str(other_user.id),
|
||||
"team": "",
|
||||
"role": role,
|
||||
"abilities": new_template_access.get_abilities(user),
|
||||
}
|
||||
|
||||
|
||||
def test_api_template_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a template access."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_template_accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a template access for a template to which
|
||||
they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_authenticated_editor_or_reader(
|
||||
via, role, mock_user_teams
|
||||
):
|
||||
"""Editors or readers of a template should not be allowed to update its accesses."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is a direct administrator in a template should be allowed to update a user
|
||||
access for this template, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template,
|
||||
role=random.choice(["administrator", "editor", "reader"]),
|
||||
)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(["administrator", "editor", "reader"]),
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an administrator in a template, should not be allowed to update
|
||||
the user access of an "owner" for this template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=other_user, role="owner"
|
||||
)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an administrator in a template, should not be allowed to update
|
||||
the user access of another user to grant template ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template,
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "editor", "reader"]),
|
||||
)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
# We are not allowed or not really updating the role
|
||||
if field == "role" or new_data["role"] == old_values["role"]:
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_owner(via, mock_user_teams):
|
||||
"""
|
||||
A user who is an owner in a template should be allowed to update
|
||||
a user access for this template whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
factories.UserFactory()
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template,
|
||||
)
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
|
||||
"""
|
||||
A user who is owner of a template should be allowed to update
|
||||
their own user access provided there are other owners in the template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
old_values = serializers.TemplateAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "editor", "reader"])
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.UserTemplateAccessFactory(template=template, role="owner")
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
|
||||
|
||||
# Delete
|
||||
|
||||
|
||||
def test_api_template_accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a template access."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_template_accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template access for a
|
||||
template to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "editor"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template access for a
|
||||
template in which they are a simple editor or reader.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role=role
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_administrators_except_owners(
|
||||
via, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Users who are administrators in a template should be allowed to delete an access
|
||||
from the template provided it is not ownership.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, role=random.choice(["reader", "editor", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
|
||||
"""
|
||||
Users who are administrators in a template should not be allowed to delete an ownership
|
||||
access from the template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="administrator"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template, role="owner")
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_owners(via, mock_user_teams):
|
||||
"""
|
||||
Users should be able to delete the template access of another user
|
||||
for a template of which they are owner.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
if via == USER:
|
||||
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
access = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 2
|
||||
assert models.TemplateAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a template
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
template=template, user=user, role="owner"
|
||||
)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TemplateAccess.objects.count() == 1
|
||||
@@ -1,12 +1,21 @@
|
||||
"""
|
||||
Test users API endpoints in the impress core app.
|
||||
Test users API endpoints in the People core app.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
from core.api.viewsets import Pagination
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -16,81 +25,529 @@ def test_api_users_list_anonymous():
|
||||
factories.UserFactory()
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/users/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert "Authentication credentials were not provided." in response.content.decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list users.
|
||||
Authenticated users should be able to list all users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 3
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(response.json()["results"]) == 3
|
||||
|
||||
|
||||
def test_api_users_list_query_email():
|
||||
def test_api_users_authenticated_list_by_email():
|
||||
"""
|
||||
Authenticated users should be able to list users
|
||||
and filter by email.
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory(email="david.bowman@work.com")
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com")
|
||||
frank = factories.UserFactory(email="frank_poole@work.com")
|
||||
factories.UserFactory(email="heywood_floyd@work.com")
|
||||
dave = factories.IdentityFactory(
|
||||
email="david.bowman@work.com", name=None, is_main=True
|
||||
)
|
||||
nicole = factories.IdentityFactory(
|
||||
email="nicole_foole@work.com", name=None, is_main=True
|
||||
)
|
||||
frank = factories.IdentityFactory(
|
||||
email="frank_poole@work.com", name=None, is_main=True
|
||||
)
|
||||
factories.IdentityFactory(email="heywood_floyd@work.com", name=None, is_main=True)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id)]
|
||||
assert user_ids[0] == str(dave.user.id)
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=oole")
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/users/?q=fran")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
assert user_ids[0] == str(frank.user.id)
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected emails
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.user.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.user.is_device,
|
||||
"is_staff": nicole.user.is_staff,
|
||||
"language": nicole.user.language,
|
||||
"timezone": str(nicole.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.user.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.user.is_device,
|
||||
"is_staff": frank.user.is_staff,
|
||||
"language": frank.user.language,
|
||||
"timezone": str(frank.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_list_query_email_exclude_doc_user():
|
||||
def test_api_users_authenticated_list_by_name():
|
||||
"""
|
||||
Authenticated users should be able to list users
|
||||
and filter by email and exclude users who have access to a document.
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
nicole = factories.UserFactory(email="nicole_foole@work.com")
|
||||
frank = factories.UserFactory(email="frank_poole@work.com")
|
||||
factories.UserFactory(email="heywood_floyd@work.com")
|
||||
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
|
||||
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
|
||||
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
|
||||
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=document, user=frank)
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=oole&document_id=" + str(document.id))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(nicole.id)]
|
||||
assert user_ids[0] == str(dave.user.id)
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/users/?q=fran")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.user.id)
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected user
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.user.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.user.is_device,
|
||||
"is_staff": nicole.user.is_staff,
|
||||
"language": nicole.user.language,
|
||||
"timezone": str(nicole.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.user.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.user.is_device,
|
||||
"is_staff": frank.user.is_staff,
|
||||
"language": frank.user.language,
|
||||
"timezone": str(frank.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_name_and_email():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name and email.
|
||||
"""
|
||||
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
nicole = factories.IdentityFactory(
|
||||
email="nicole_foole@work.com", name="nicole foole", is_main=True
|
||||
)
|
||||
frank = factories.IdentityFactory(
|
||||
email="oleg_poole@work.com", name=None, is_main=True
|
||||
)
|
||||
david = factories.IdentityFactory(email=None, name="david role", is_main=True)
|
||||
|
||||
# Result that matches a trigram in name and email ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
|
||||
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
|
||||
# "Oleg poole" matches twice on "ole" in her email
|
||||
# "David role" matches once on "ole" in his name
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id), str(david.user.id)]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_exclude_users_already_in_team(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to search users
|
||||
but the result should exclude all users already in the given team.
|
||||
"""
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="john doe")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
|
||||
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
|
||||
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
|
||||
mary = factories.IdentityFactory(name="mary poole", email=None, is_main=True)
|
||||
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
|
||||
factories.IdentityFactory(name="Andrei Smyslov", email=None, is_main=True)
|
||||
factories.TeamFactory.create_batch(10)
|
||||
|
||||
# Add Dave and Frank in the same team
|
||||
team = factories.TeamFactory(
|
||||
users=[
|
||||
(dave.user, models.RoleChoices.MEMBER),
|
||||
(frank.user, models.RoleChoices.MEMBER),
|
||||
]
|
||||
)
|
||||
factories.TeamFactory(users=[(nicole.user, models.RoleChoices.MEMBER)])
|
||||
|
||||
# Search user to add him/her to a team, we give a team id to the request
|
||||
# to exclude all users already in the team
|
||||
|
||||
# We can't find David Bowman because he is already a member of the given team
|
||||
# 2 queries are needed here:
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?q=bowman&team_id={team.id}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == []
|
||||
|
||||
# We can only find Nicole and Mary because Frank is already a member of the given team
|
||||
# 4 queries are needed here:
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
# - User
|
||||
# - Identity
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?q=ool&team_id={team.id}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = sorted([user["id"] for user in response.json()["results"]])
|
||||
assert user_ids == sorted([str(mary.user.id), str(nicole.user.id)])
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_multiple_identities_single_user():
|
||||
"""
|
||||
User with multiple identities should appear only once in results.
|
||||
"""
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
factories.IdentityFactory(
|
||||
user=dave, email="dave.bowman@work.com", name="dave bowman"
|
||||
)
|
||||
factories.IdentityFactory(user=dave, email="dave.bowman@fun.fr", name="dave bowman")
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
# A single user is returned, despite similarity matching both emails
|
||||
assert response.json()["count"] == 1
|
||||
assert response.json()["results"][0]["id"] == str(dave.id)
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_multiple_identities_multiple_users():
|
||||
"""
|
||||
User with multiple identities should be ranked
|
||||
on their best matching identity.
|
||||
"""
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
dave_identity = factories.IdentityFactory(
|
||||
user=dave, email="dave.bowman@work.com", is_main=True, name="dave bowman"
|
||||
)
|
||||
factories.IdentityFactory(user=dave, email="babibou@ehehe.com", name="babihou")
|
||||
davina_identity = factories.IdentityFactory(
|
||||
user=factories.UserFactory(), email="davina.bowan@work.com", name="davina"
|
||||
)
|
||||
prue_identity = factories.IdentityFactory(
|
||||
user=factories.UserFactory(),
|
||||
email="prudence.crandall@work.com",
|
||||
name="prudence",
|
||||
)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(dave.id),
|
||||
"email": dave_identity.email,
|
||||
"name": dave_identity.name,
|
||||
"is_device": dave_identity.user.is_device,
|
||||
"is_staff": dave_identity.user.is_staff,
|
||||
"language": dave_identity.user.language,
|
||||
"timezone": str(dave_identity.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(davina_identity.user.id),
|
||||
"email": davina_identity.email,
|
||||
"name": davina_identity.name,
|
||||
"is_device": davina_identity.user.is_device,
|
||||
"is_staff": davina_identity.user.is_staff,
|
||||
"language": davina_identity.user.language,
|
||||
"timezone": str(davina_identity.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(prue_identity.user.id),
|
||||
"email": prue_identity.email,
|
||||
"name": prue_identity.name,
|
||||
"is_device": prue_identity.user.is_device,
|
||||
"is_staff": prue_identity.user.is_staff,
|
||||
"language": prue_identity.user.language,
|
||||
"timezone": str(prue_identity.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(
|
||||
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
|
||||
)
|
||||
|
||||
# Unaccented full address
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@intensework.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
# Partial query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(email="david.bowman@work.com", name="david bowman")
|
||||
|
||||
# Full uppercase query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
# Partial uppercase email
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=DAVID",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.user.id)]
|
||||
|
||||
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory(admin_email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
helene = factories.IdentityFactory(
|
||||
email="helene.bowman@work.com", name="helene bowman"
|
||||
)
|
||||
|
||||
# Accented full query
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(helene.user.id)]
|
||||
|
||||
# Unaccented partial email
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=hélène",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(helene.user.id)]
|
||||
|
||||
|
||||
@mock.patch.object(Pagination, "get_page_size", return_value=3)
|
||||
def test_api_users_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(4)
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 5
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/users/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 5
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/users/"
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", [1, 10, 99, 100])
|
||||
def test_api_users_list_pagination_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should work as expected."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for i in range(page_size):
|
||||
factories.UserFactory.create(admin_email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == page_size + 1
|
||||
assert len(content["results"]) == page_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", [101, 200])
|
||||
def test_api_users_list_pagination_wrong_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should be limited to "max_page_size"."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for i in range(page_size):
|
||||
factories.UserFactory.create(admin_email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == page_size + 1
|
||||
|
||||
# Length should not exceed "max_page_size" default value
|
||||
assert len(content["results"]) == 100
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
@@ -98,7 +555,7 @@ def test_api_users_retrieve_me_anonymous():
|
||||
factories.UserFactory.create_batch(2)
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
@@ -106,22 +563,31 @@ def test_api_users_retrieve_me_anonymous():
|
||||
|
||||
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()
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Define profile contact
|
||||
contact = factories.ContactFactory(owner=user)
|
||||
user.profile_contact = contact
|
||||
user.save()
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/me/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
"name": str(identity.name),
|
||||
"email": str(identity.email),
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +597,7 @@ def test_api_users_retrieve_anonymous():
|
||||
user = factories.UserFactory()
|
||||
response = client.get(f"/api/v1.0/users/{user.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
@@ -142,7 +608,8 @@ 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()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -150,7 +617,7 @@ def test_api_users_retrieve_authenticated_self():
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "GET" not allowed.'}
|
||||
|
||||
|
||||
@@ -159,17 +626,17 @@ def test_api_users_retrieve_authenticated_other():
|
||||
Authenticated users should be able to retrieve another user's detail view with
|
||||
limited information.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json() == {"detail": 'Method "GET" not allowed.'}
|
||||
|
||||
|
||||
@@ -182,16 +649,17 @@ def test_api_users_create_anonymous():
|
||||
"password": "mypassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
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()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -204,7 +672,7 @@ def test_api_users_create_authenticated():
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 405
|
||||
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
|
||||
|
||||
@@ -222,7 +690,7 @@ def test_api_users_update_anonymous():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
@@ -238,7 +706,8 @@ 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()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -254,7 +723,7 @@ def test_api_users_update_authenticated_self():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
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():
|
||||
@@ -266,10 +735,10 @@ def test_api_users_update_authenticated_self():
|
||||
|
||||
def test_api_users_update_authenticated_other():
|
||||
"""Authenticated users should not be allowed to update other users."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
@@ -281,7 +750,7 @@ def test_api_users_update_authenticated_other():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
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():
|
||||
@@ -303,7 +772,7 @@ def test_api_users_patch_anonymous():
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
@@ -319,7 +788,8 @@ 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()
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -335,7 +805,7 @@ def test_api_users_patch_authenticated_self():
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
@@ -348,10 +818,10 @@ def test_api_users_patch_authenticated_self():
|
||||
|
||||
def test_api_users_patch_authenticated_other():
|
||||
"""Authenticated users should not be allowed to patch other users."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
user = factories.UserFactory()
|
||||
old_user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
@@ -365,7 +835,7 @@ def test_api_users_patch_authenticated_other():
|
||||
{key: new_value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
user.refresh_from_db()
|
||||
user_values = dict(serializers.UserSerializer(instance=user).data)
|
||||
@@ -380,23 +850,23 @@ def test_api_users_delete_list_anonymous():
|
||||
client = APIClient()
|
||||
response = client.delete("/api/v1.0/users/")
|
||||
|
||||
assert response.status_code == 401
|
||||
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."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.delete(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 3
|
||||
|
||||
|
||||
@@ -406,7 +876,7 @@ def test_api_users_delete_anonymous():
|
||||
|
||||
response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -414,31 +884,28 @@ def test_api_users_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a user other than themselves.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
identity = factories.IdentityFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
|
||||
assert response.status_code == 405
|
||||
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()
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
client.force_login(identity.user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
f"/api/v1.0/users/{identity.user.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user