Compare commits
429 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a99913d0e | |||
| 980a18aff2 | |||
| 6f655a20ac | |||
| 39d93e330e | |||
| cc2d034abf | |||
| 312a92a8e0 | |||
| 7a6c0f0eee | |||
| 97fa5b8532 | |||
| a092c2915b | |||
| 9b44e021fd | |||
| 2c3eef4dd9 | |||
| dec1a1a870 | |||
| 1e432cfdc2 | |||
| f5c4106547 | |||
| 494638d306 | |||
| 41260de1c3 | |||
| 140a630a6e | |||
| b716881d50 | |||
| fa8466d44d | |||
| 4ba34f6c80 | |||
| e4712831f2 | |||
| 37db31a8d5 | |||
| 4321511631 | |||
| 459cb5e2e2 | |||
| 2a7e3116bd | |||
| b9046a2d9b | |||
| d249ed0c71 | |||
| 48d3738ec2 | |||
| 92102e4a36 | |||
| dd1b271b71 | |||
| 7cfc1d8036 | |||
| 86fdbeacaa | |||
| 520d511f59 | |||
| 9c512fae69 | |||
| 1139c0abea | |||
| 9e1979f637 | |||
| ddd93ab0c5 | |||
| 85044fd665 | |||
| b83875fc97 | |||
| 7a8caf5475 | |||
| e927f2c004 | |||
| 7f25b05474 | |||
| 296b5dbf59 | |||
| accbda44e2 | |||
| f2a78ada47 | |||
| 4cb0423511 | |||
| 766aee6a92 | |||
| 3d19893091 | |||
| 00b223f648 | |||
| 38b32c1227 | |||
| 1ff3d9c54e | |||
| 6eff21f51e | |||
| 3eb8f88b5c | |||
| 3a3483b776 | |||
| 67a20f249e | |||
| c9f1356d3e | |||
| f12708acee | |||
| 58eaea000c | |||
| 7d97a037f6 | |||
| c830b4dae6 | |||
| a0fe98e156 | |||
| fa105e5b54 | |||
| ced850aecf | |||
| 3a420c0416 | |||
| b5a67df88b | |||
| 6683821eaf | |||
| 9c67c9e4d4 | |||
| 543770ae63 | |||
| f0e2a2b710 | |||
| 67625dff7a | |||
| 4ed964240f | |||
| a970a83229 | |||
| 7babc46261 | |||
| 2af88c5a4d | |||
| 07d9e290fa | |||
| 29c5199b72 | |||
| 98b3fc4a1b | |||
| 754c5d06f4 | |||
| fe00d65500 | |||
| 5ef0f825e0 | |||
| 3e5dae4ff1 | |||
| affe3be937 | |||
| 0512af273c | |||
| a925b0bedf | |||
| 143f1a02eb | |||
| 1abbf0539f | |||
| 2f8c5637f4 | |||
| 2391098aba | |||
| 6fdf912d5d | |||
| c62a0afdf6 | |||
| d73e42b20b | |||
| 5d98986402 | |||
| c7f7b0f7ad | |||
| 41a6ef9dfc | |||
| ac58341984 | |||
| 990de437ba | |||
| b4d3f94ada | |||
| c724ae0253 | |||
| e9796404cf | |||
| 6481ce311d | |||
| af7e480d52 | |||
| c1566d98fe | |||
| 4280f0779e | |||
| ffaccad014 | |||
| c077ed8414 | |||
| 6a13c5d4e1 | |||
| c42fd9be9c | |||
| 2b824c0862 | |||
| fb494c8c71 | |||
| 8d5648005f | |||
| 713d9e48c8 | |||
| bef541f956 | |||
| 4b61ffce01 | |||
| a9383212a3 | |||
| 1ed20c3896 | |||
| b5443676c1 | |||
| 424c100eeb | |||
| 91be4f5a21 | |||
| 6eb0ac99e4 | |||
| e79a74083a | |||
| 905000550d | |||
| 76d623a89b | |||
| 61cf8aae74 | |||
| ccadd9567a | |||
| 2c9758eb30 | |||
| 0f71a3fcfa | |||
| 839b78a6d0 | |||
| 69f2641159 | |||
| e5de5a4345 | |||
| c2d6e60ae8 | |||
| ff832239d3 | |||
| a60399883b | |||
| 12d32fe933 | |||
| 038d868d29 | |||
| bfde526361 | |||
| 132da0837f | |||
| 164ed885ca | |||
| f5a35c66bb | |||
| 8100422776 | |||
| f48e385d3d | |||
| b5895bf297 | |||
| 0fb0c71cfa | |||
| a0fea64630 | |||
| de922e1c04 | |||
| 8007c45a35 | |||
| 3d370e5714 | |||
| 03953f0fe7 | |||
| 67f4ddeef7 | |||
| 6f4b4bb7d3 | |||
| c27e6c1b06 | |||
| d2888d0b9d | |||
| 6b8af1f9ec | |||
| 35852dff0b | |||
| be93598b2d | |||
| 7d3fd25c61 | |||
| e3fe647e5b | |||
| 8f5c413482 | |||
| a66231d72e | |||
| 2e0b6b2a2a | |||
| edffbbaf99 | |||
| a393cd4df0 | |||
| e26b806965 | |||
| aef19cfddd | |||
| b7bd7b7772 | |||
| 43ba36af61 | |||
| b4e4ba3516 | |||
| 9c19b22a66 | |||
| d87c278cce | |||
| 287a5b3bff | |||
| cfb979a411 | |||
| d588ae847f | |||
| 08a39c7ebd | |||
| aef5dd51fc | |||
| bf10fbd25f | |||
| f5948959f2 | |||
| a0644efb45 | |||
| b4e8db9050 | |||
| 96bfbfabec | |||
| c2aa5be8ff | |||
| 3b77e1254e | |||
| b436a418ff | |||
| 9fdf87b100 | |||
| 6943f2158a | |||
| 872cc8c110 | |||
| c03e1ffe8b | |||
| 5454da5c70 | |||
| 5b3c91f6dd | |||
| f1ac0954ed | |||
| fc80c2ec2a | |||
| 8ba64322be | |||
| ae9555284d | |||
| 14a91a5698 | |||
| 9f6dac53d4 | |||
| 5ba35dbc1d | |||
| 62098ec753 | |||
| 9e318f88be | |||
| 63b565e720 | |||
| e7fe153a81 | |||
| 8e0850b13e | |||
| 8fb18ed186 | |||
| bec6ff3d22 | |||
| 0571aaabce | |||
| e4bed84343 | |||
| 37f02893ed | |||
| 4e4e2e23e3 | |||
| b68015852c | |||
| 2f79d03acc | |||
| 8762a7bc92 | |||
| 589b0eab32 | |||
| 44f4a37814 | |||
| 6c76f7faab | |||
| 201d776d1b | |||
| e7f2317b41 | |||
| 206c7f11ae | |||
| 615f92ded3 | |||
| da3ad91283 | |||
| 197b16c5d0 | |||
| cce0331b68 | |||
| 380ac0cbcf | |||
| 1779b7bab4 | |||
| 7b5698a370 | |||
| f039b2aa3b | |||
| 8531e0dd08 | |||
| e2586b1d8e | |||
| 327a5f2ed4 | |||
| 1e62320803 | |||
| ff7341e544 | |||
| 038c3d6207 | |||
| 2b67d0bd26 | |||
| d30ac8ee4e | |||
| c05b98ce0d | |||
| 3d594a99d5 | |||
| b47e60d6ec | |||
| f40f0d35cb | |||
| f1227ed037 | |||
| dbd4477e9a | |||
| b4b308bda9 | |||
| 2b456c231f | |||
| 72bb079f10 | |||
| 06af320d61 | |||
| 926fe37e85 | |||
| 51325df7d9 | |||
| 0727fe0273 | |||
| d0d0b44f57 | |||
| 6e46b686ab | |||
| 38eabe2fc8 | |||
| effa77dc10 | |||
| d5d374c311 | |||
| dd33c99532 | |||
| f4146fbe4e | |||
| dce040f4c3 | |||
| 3a68c75feb | |||
| 29577c0419 | |||
| 2813b2ca27 | |||
| ef2f4d3250 | |||
| 996cea49b4 | |||
| eb2936f48b | |||
| 10c53d2a4e | |||
| 5c9e4ab3e6 | |||
| 7aeea18202 | |||
| 64d508c260 | |||
| d75649d18a | |||
| a851720441 | |||
| 29f13f5f4b | |||
| 515b686795 | |||
| 125284456f | |||
| b7b3c6c058 | |||
| 644920e732 | |||
| dc319578b6 | |||
| abf08fe3b3 | |||
| 76e50ac02f | |||
| 5f7e3e620a | |||
| 004a4edfe7 | |||
| d221ebe4f9 | |||
| 9903b43b72 | |||
| d23426679a | |||
| e2e34a011d | |||
| c1dd34512b | |||
| 88d6d8977c | |||
| 4ffcf164d8 | |||
| c25814b060 | |||
| f88d625622 | |||
| a26ee3b868 | |||
| c0d919ae52 | |||
| 944385c83f | |||
| ccb6d4cb91 | |||
| 5853adb8eb | |||
| 70994de914 | |||
| df0ff61faf | |||
| b94f08d275 | |||
| a764e5989c | |||
| 130e7a8c99 | |||
| 8e262da8f5 | |||
| 4956beb040 | |||
| 397b9efbce | |||
| 7c6e48975d | |||
| f0a4de361e | |||
| f0b3090a73 | |||
| 88109623ba | |||
| d8e7f29072 | |||
| 0c2d097d8d | |||
| 5fbb5106a9 | |||
| c7a5b5f3fb | |||
| d967896874 | |||
| b98cc01592 | |||
| d9fa75b764 | |||
| 33b39ce315 | |||
| f5ddce6b47 | |||
| 8d48ded46b | |||
| f9bec19c7d | |||
| 89aa048fc1 | |||
| b2cc789665 | |||
| 9489264555 | |||
| e1b94e3443 | |||
| 90c8338a59 | |||
| 895aaff2d3 | |||
| bbafce64e7 | |||
| e901103c8b | |||
| f3f5f190a5 | |||
| 2dc2341052 | |||
| c2fb55506d | |||
| 428cd74366 | |||
| 0575c0f086 | |||
| 35d500c08b | |||
| bf83bf99b3 | |||
| 964efe4c2f | |||
| 75801bf95f | |||
| 4de79c2388 | |||
| f61047dd6a | |||
| d3b4a576c8 | |||
| 7107caf50b | |||
| 82463be8ec | |||
| 64cc2a9f2b | |||
| b687ad6974 | |||
| 5d6ae870fa | |||
| ef2ebf596a | |||
| 7463735a38 | |||
| d9cdeb5c68 | |||
| 5d25aed765 | |||
| a8248f10d2 | |||
| faeb8d137b | |||
| 5074b271ad | |||
| e13ecdd38d | |||
| 3aaa3e179d | |||
| 7922c702cd | |||
| 8d2a78cf8d | |||
| 7fb8a62e63 | |||
| 45bf99178b | |||
| 8305cfcf99 | |||
| 1df7c43dd3 | |||
| f9705c6ce9 | |||
| db9c9ecfe3 | |||
| 3e0739cd0a | |||
| 0024cc5814 | |||
| 5587cf69dd | |||
| 68c130bd84 | |||
| 22e79e7583 | |||
| 056bad08a5 | |||
| 2f4dd4ee7a | |||
| 51babc07af | |||
| dbb592bc9a | |||
| 0dc1be4cc7 | |||
| a772d8fc29 | |||
| c6c0c1355d | |||
| cc64b8c8b5 | |||
| 2665b707db | |||
| e28a4294fa | |||
| 9ca63baeeb | |||
| a5a8a64350 | |||
| 272b18ae02 | |||
| 37367f2d22 | |||
| 2070a159e1 | |||
| a4e8bbd26e | |||
| 75bb4b36d4 | |||
| f4e4cbaa56 | |||
| 6b567a0f69 | |||
| 007a9ae4f4 | |||
| ca0571cb21 | |||
| 560dbf1c2e | |||
| 17729e231b | |||
| 409691d466 | |||
| 603ece466d | |||
| 79831154be | |||
| da273d6b2b | |||
| 5ce77c2138 | |||
| b7663cca2d | |||
| 80335ffd15 | |||
| 6de828c049 | |||
| 352d4f6d27 | |||
| 3e6f20aedd | |||
| bf19c50146 | |||
| 1d3f1f793e | |||
| cad206cccf | |||
| 3cd1852b93 | |||
| d19c7f7fd8 | |||
| 8afc003004 | |||
| 23797f2953 | |||
| 78bccafb93 | |||
| 42bea9940e | |||
| 55e2a2073d | |||
| 1f804ad1f9 | |||
| 3ba388bc49 | |||
| b517b0bc39 | |||
| 1cb138ecb3 | |||
| 1dd33706c0 | |||
| 6fe5f3ef72 | |||
| 1bf391f26f | |||
| dfafd335f4 | |||
| 3098b9f4fc | |||
| 8fda0bc9b9 | |||
| c64b66c797 | |||
| c3e354d727 | |||
| ab7d9933e0 | |||
| f581eb8abd | |||
| a23118bee4 | |||
| 47aeab76a5 | |||
| 551468470f | |||
| 36d6735798 | |||
| b1892ded17 | |||
| b9eee3e643 | |||
| 23e92d12fb | |||
| 0f9327a1de | |||
| 0a3e26486e | |||
| ae2ba59c82 | |||
| 0f0b0f0de1 | |||
| 44ff62c08f | |||
| 312a680b66 | |||
| 7eab2b61a5 | |||
| 62df0524ac |
@@ -0,0 +1,36 @@
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Docker
|
||||
docker compose.*
|
||||
env.d
|
||||
|
||||
# Docs
|
||||
docs
|
||||
*.md
|
||||
*.log
|
||||
|
||||
# Development/test cache & configurations
|
||||
data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.vscode
|
||||
.iml
|
||||
.idea
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
@@ -0,0 +1,6 @@
|
||||
<!---
|
||||
Thanks for filing an issue 😄 ! Before you submit, please read the following:
|
||||
|
||||
Check the other issue templates if you are trying to submit a bug report, feature request, or question
|
||||
Search open/closed issues before submitting since someone might have asked the same thing before!
|
||||
-->
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: If something is not working as expected 🤔.
|
||||
|
||||
---
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Problematic behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
**Expected behavior/code**
|
||||
A clear and concise description of what you expected to happen (or code).
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Do this...
|
||||
2. Then this...
|
||||
3. And then the bug happens!
|
||||
|
||||
**Environment**
|
||||
- Impress version:
|
||||
- Platform:
|
||||
|
||||
**Possible Solution**
|
||||
<!--- Only if you have suggestions on a fix for the bug -->
|
||||
|
||||
**Additional context/Screenshots**
|
||||
Add any other context about the problem here. If applicable, add screenshots to help explain.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: I have a suggestion (and may want to build it 💪)!
|
||||
|
||||
---
|
||||
|
||||
## Feature Request
|
||||
|
||||
**Is your feature request related to a problem or unsupported use case? Please describe.**
|
||||
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen. Add any considered drawbacks.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
||||
Maybe a screenshot or design?
|
||||
|
||||
**Do you want to work on it through a Pull Request?**
|
||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: 🤗 Support Question
|
||||
about: If you have a question 💬, or something was not clear from the docs!
|
||||
|
||||
---
|
||||
|
||||
<!-- ^ Click "Preview" for a nicer view! ^
|
||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/impress).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/impress/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
**Topic**
|
||||
What's the general area of your question: for example, docker setup, database schema, search functionality,...
|
||||
|
||||
**Question**
|
||||
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
|
||||
@@ -0,0 +1,11 @@
|
||||
## Purpose
|
||||
|
||||
Description...
|
||||
|
||||
|
||||
## Proposal
|
||||
|
||||
Description...
|
||||
|
||||
- [] item 1...
|
||||
- [] item 2...
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'preprod'
|
||||
- 'production'
|
||||
|
||||
|
||||
jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "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: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
|
||||
|
||||
start-test-on-preprod:
|
||||
needs:
|
||||
- notify-argocd
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.event.ref, 'refs/tags/preprod')
|
||||
steps:
|
||||
-
|
||||
name: Debug
|
||||
run: |
|
||||
echo "Start test when preprod is ready"
|
||||
@@ -0,0 +1,185 @@
|
||||
name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
|
||||
jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "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-backend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "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-frontend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
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: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
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 }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "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: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
@@ -0,0 +1,253 @@
|
||||
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
|
||||
@@ -0,0 +1,207 @@
|
||||
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
|
||||
|
||||
# - 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
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.DS_Store
|
||||
.next/
|
||||
|
||||
# Translations # Translations
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/terraform
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Mails
|
||||
src/backend/core/templates/mail/
|
||||
|
||||
# Swagger
|
||||
**/swagger.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Terraform
|
||||
.terraform
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
|
||||
# Test & lint
|
||||
.coverage
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
|
||||
# Site media
|
||||
/data/
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.devcontainer
|
||||
@@ -0,0 +1,78 @@
|
||||
# All these sections are optional, edit this file as you like.
|
||||
[general]
|
||||
# Ignore certain rules, you can reference them by their id or by their full name
|
||||
# ignore=title-trailing-punctuation, T3
|
||||
|
||||
# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
|
||||
# verbosity = 2
|
||||
|
||||
# By default gitlint will ignore merge commits. Set to 'false' to disable.
|
||||
# ignore-merge-commits=true
|
||||
|
||||
# By default gitlint will ignore fixup commits. Set to 'false' to disable.
|
||||
# ignore-fixup-commits=true
|
||||
|
||||
# By default gitlint will ignore squash commits. Set to 'false' to disable.
|
||||
# ignore-squash-commits=true
|
||||
|
||||
# Enable debug mode (prints more output). Disabled by default.
|
||||
# debug=true
|
||||
|
||||
# Set the extra-path where gitlint will search for user defined rules
|
||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||
extra-path=gitlint/
|
||||
|
||||
# [title-max-length]
|
||||
# line-length=80
|
||||
|
||||
[title-must-not-contain-word]
|
||||
# Comma-separated list of words that should not occur in the title. Matching is case
|
||||
# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
|
||||
# will not cause a violation, but "WIP: my title" will.
|
||||
words=wip
|
||||
|
||||
#[title-match-regex]
|
||||
# python like regex (https://docs.python.org/2/library/re.html) that the
|
||||
# commit-msg title must be matched to.
|
||||
# Note that the regex can contradict with other rules if not used correctly
|
||||
# (e.g. title-must-not-contain-word).
|
||||
#regex=
|
||||
|
||||
# [B1]
|
||||
# B1 = body-max-line-length
|
||||
# line-length=120
|
||||
# [body-min-length]
|
||||
# min-length=5
|
||||
|
||||
# [body-is-missing]
|
||||
# Whether to ignore this rule on merge commits (which typically only have a title)
|
||||
# default = True
|
||||
# ignore-merge-commits=false
|
||||
|
||||
# [body-changed-file-mention]
|
||||
# List of files that need to be explicitly mentioned in the body when they are changed
|
||||
# This is useful for when developers often erroneously edit certain files or git submodules.
|
||||
# By specifying this rule, developers can only change the file when they explicitly reference
|
||||
# it in the commit message.
|
||||
# files=gitlint/rules.py,README.md
|
||||
|
||||
# [author-valid-email]
|
||||
# python like regex (https://docs.python.org/2/library/re.html) that the
|
||||
# commit author email address should be matched to
|
||||
# For example, use the following regex if you only want to allow email addresses from foo.com
|
||||
# regex=[^@]+@foo.com
|
||||
|
||||
[ignore-by-title]
|
||||
# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper)
|
||||
# upgrade dependencies
|
||||
regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$
|
||||
ignore=B6,UC1
|
||||
|
||||
# [ignore-by-body]
|
||||
# Ignore certain rules for commits of which the body has a line that matches a regex
|
||||
# E.g. Match bodies that have a line that that contain "release"
|
||||
# regex=(.*)release(.*)
|
||||
#
|
||||
# Ignore certain rules, you can reference them by their id or by their full name
|
||||
# Use 'all' to ignore all rules
|
||||
# ignore=T1,body-min-length
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
creation_rules:
|
||||
- path_regex: ./*
|
||||
key_groups:
|
||||
- age:
|
||||
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
|
||||
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
|
||||
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
|
||||
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
|
||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
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]
|
||||
|
||||
## 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
|
||||
|
||||
- ♻️ 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
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛 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
|
||||
|
||||
## 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.3.0...main
|
||||
[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
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# Django impress
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
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
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
# Copy required python dependencies
|
||||
COPY ./src/backend /builder
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 as mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base as link-collector
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
|
||||
# Install libpangocairo & rdfind
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libpangocairo-1.0-0 \
|
||||
rdfind && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# 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}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base as core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gettext \
|
||||
libcairo2 \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
pandoc \
|
||||
fonts-noto-color-emoji \
|
||||
shared-mime-info && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
# Give the "root" group the same permissions as the "root" user on /etc/passwd
|
||||
# to allow a user belonging to the root group to add new users; typically the
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||
# ID.
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core as backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
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
|
||||
# dependencies
|
||||
RUN pip uninstall -y impress
|
||||
RUN pip install -e .[dev]
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Target database host (e.g. database engine following docker compose services
|
||||
# name) & port
|
||||
ENV DB_HOST=postgresql \
|
||||
DB_PORT=5432
|
||||
|
||||
# Run django development server
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
|
||||
ARG IMPRESS_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
|
||||
|
||||
# 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 impress 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"]
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Direction Interministérielle du Numérique du Gouvernement Français
|
||||
Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
|
||||
#
|
||||
# This Makefile is only meant to be used for DEVELOPMENT purpose as we are
|
||||
# changing the user id that will run in the container.
|
||||
#
|
||||
# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER...
|
||||
#
|
||||
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
|
||||
#
|
||||
# Note to developers:
|
||||
#
|
||||
# While editing this file, please respect the following statements:
|
||||
#
|
||||
# 1. Every variable should be defined in the ad hoc VARIABLES section with a
|
||||
# relevant subsection
|
||||
# 2. Every new rule should be defined in the ad hoc RULES section with a
|
||||
# relevant subsection depending on the targeted service
|
||||
# 3. Rules should be sorted alphabetically within their section
|
||||
# 4. When a rule has multiple dependencies, you should:
|
||||
# - duplicate the rule name to add the help string (if required)
|
||||
# - write one dependency per line to increase readability and diffs
|
||||
# 5. .PHONY rule statement should be written after the corresponding rule
|
||||
# ==============================================================================
|
||||
# VARIABLES
|
||||
|
||||
BOLD := \033[1m
|
||||
RESET := \033[0m
|
||||
GREEN := \033[1;32m
|
||||
|
||||
|
||||
# -- Database
|
||||
|
||||
DB_HOST = postgresql
|
||||
DB_PORT = 5432
|
||||
|
||||
# -- Docker
|
||||
# Get the current user ID to use for docker run and docker exec commands
|
||||
DOCKER_UID = $(shell id -u)
|
||||
DOCKER_GID = $(shell id -g)
|
||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
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
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
PATH_FRONT_IMPRESS = $(PATH_FRONT)/apps/impress
|
||||
|
||||
# ==============================================================================
|
||||
# RULES
|
||||
|
||||
default: help
|
||||
|
||||
data/media:
|
||||
@mkdir -p data/media
|
||||
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
# -- Project
|
||||
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project
|
||||
bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run-frontend-dev \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev --no-cache
|
||||
@$(COMPOSE) build frontend-dev --no-cache
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
|
||||
logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
.PHONY: status
|
||||
|
||||
stop: ## stop the development server using Docker
|
||||
@$(COMPOSE) stop
|
||||
.PHONY: stop
|
||||
|
||||
# -- Backend
|
||||
|
||||
demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MAKE) resetdb
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
lint-ruff-format \
|
||||
lint-ruff-check \
|
||||
lint-pylint
|
||||
.PHONY: lint
|
||||
|
||||
lint-ruff-format: ## format back-end python sources with ruff
|
||||
@echo 'lint:ruff-format started…'
|
||||
@$(COMPOSE_RUN_APP) ruff format .
|
||||
.PHONY: lint-ruff-format
|
||||
|
||||
lint-ruff-check: ## lint back-end python sources with ruff
|
||||
@echo 'lint:ruff-check started…'
|
||||
@$(COMPOSE_RUN_APP) ruff check . --fix
|
||||
.PHONY: lint-ruff-check
|
||||
|
||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||
@echo 'lint:pylint started…'
|
||||
bin/pylint --diff-only=origin/main
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest $${args:-${1}}
|
||||
.PHONY: test-back
|
||||
|
||||
test-back-parallel: ## run all back-end tests in parallel
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
makemigrations: ## run django makemigrations for the impress project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) makemigrations
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the impress project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) migrate
|
||||
.PHONY: migrate
|
||||
|
||||
superuser: ## Create an admin superuser with password "admin"
|
||||
@echo "$(BOLD)Creating a Django superuser$(RESET)"
|
||||
@$(MANAGE) createsuperuser --email admin@example.com --password admin
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@$(MANAGE) compilemessages --ignore="venv/**/*"
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
@$(MANAGE) makemessages -a --keep-pot --all
|
||||
.PHONY: back-i18n-generate
|
||||
|
||||
shell: ## connect to database shell
|
||||
@$(MANAGE) shell #_plus
|
||||
.PHONY: dbshell
|
||||
|
||||
# -- Database
|
||||
|
||||
dbshell: ## connect to database shell
|
||||
docker compose exec app-dev python manage.py dbshell
|
||||
.PHONY: dbshell
|
||||
|
||||
resetdb: FLUSH_ARGS ?=
|
||||
resetdb: ## flush database and create a superuser "admin"
|
||||
@echo "$(BOLD)Flush database$(RESET)"
|
||||
@$(MANAGE) flush $(FLUSH_ARGS)
|
||||
@${MAKE} superuser
|
||||
.PHONY: resetdb
|
||||
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
|
||||
crowdin-download: ## Download translated message from crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
.PHONY: crowdin-download
|
||||
|
||||
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
|
||||
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-upload
|
||||
|
||||
i18n-compile: ## compile all translations
|
||||
i18n-compile: \
|
||||
back-i18n-compile \
|
||||
frontend-i18n-compile
|
||||
.PHONY: i18n-compile
|
||||
|
||||
i18n-generate: ## create the .pot files and extract frontend messages
|
||||
i18n-generate: \
|
||||
back-i18n-generate \
|
||||
frontend-i18n-generate
|
||||
.PHONY: i18n-generate
|
||||
|
||||
i18n-download-and-compile: ## download all translated messages and compile them to be used by all applications
|
||||
i18n-download-and-compile: \
|
||||
crowdin-download \
|
||||
i18n-compile
|
||||
.PHONY: i18n-download-and-compile
|
||||
|
||||
i18n-generate-and-upload: ## generate source translations for all applications and upload them to Crowdin
|
||||
i18n-generate-and-upload: \
|
||||
i18n-generate \
|
||||
crowdin-upload
|
||||
.PHONY: i18n-generate-and-upload
|
||||
|
||||
|
||||
# -- Mail generator
|
||||
|
||||
mails-build: ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build
|
||||
.PHONY: mails-build
|
||||
|
||||
mails-build-html-to-plain-text: ## Convert html files to text
|
||||
@$(MAIL_YARN) build-html-to-plain-text
|
||||
.PHONY: mails-build-html-to-plain-text
|
||||
|
||||
mails-build-mjml-to-html: ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build-mjml-to-html
|
||||
.PHONY: mails-build-mjml-to-html
|
||||
|
||||
mails-install: ## install the mail generator
|
||||
@$(MAIL_YARN) install
|
||||
.PHONY: mails-install
|
||||
|
||||
# -- Misc
|
||||
clean: ## restore repository state as it was freshly cloned
|
||||
git clean -idx
|
||||
.PHONY: clean
|
||||
|
||||
help:
|
||||
@echo "$(BOLD)impress 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
|
||||
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
.PHONY: frontend-i18n-extract
|
||||
|
||||
frontend-i18n-generate: ## Generate the frontend json files used for crowdin
|
||||
frontend-i18n-generate: \
|
||||
crowdin-download-sources \
|
||||
frontend-i18n-extract
|
||||
.PHONY: frontend-i18n-generate
|
||||
|
||||
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
|
||||
cd $(PATH_FRONT) && yarn i18n:deploy
|
||||
.PHONY: frontend-i18n-compile
|
||||
|
||||
# -- K8S
|
||||
build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
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
|
||||
@@ -0,0 +1,86 @@
|
||||
# Impress
|
||||
|
||||
Impress prints your markdown to pdf from predefined templates with user and role based access rights.
|
||||
|
||||
Impress is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisite
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker
|
||||
Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```bash
|
||||
$ docker -v
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose -v
|
||||
docker compose version 1.27.4, build 40524192
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
||||
> avoided by assigning your user to the `docker` group.
|
||||
|
||||
### Project bootstrap
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```bash
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
$ make run-frontend-dev
|
||||
```
|
||||
|
||||
### Adding content
|
||||
|
||||
You can create a basic demo site by running:
|
||||
|
||||
$ make demo
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
|
||||
```bash
|
||||
$ make help
|
||||
```
|
||||
|
||||
### Django admin
|
||||
|
||||
You can access the Django admin site at
|
||||
[http://localhost:8071/admin](http://localhost:8071/admin).
|
||||
|
||||
You first need to create a superuser account:
|
||||
|
||||
```bash
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
get in touch if you have any question related to our implementation or design
|
||||
decisions.
|
||||
|
||||
## License
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](./LICENSE)).
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Upgrade
|
||||
|
||||
All instructions to upgrade this project from one release to the next will be
|
||||
documented in this file. Upgrades must be run sequentially, meaning you should
|
||||
not skip minor/major releases while upgrading (fix releases can be skipped).
|
||||
|
||||
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).
|
||||
|
||||
For most upgrades, you just need to run the django migrations with
|
||||
the following command inside your docker container:
|
||||
|
||||
`python manage.py migrate`
|
||||
|
||||
(Note : in your development environment, you can `make migrate`.)
|
||||
|
||||
## [Unreleased]
|
||||
@@ -0,0 +1,68 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('impress')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/impress-backend:latest',
|
||||
context='..',
|
||||
dockerfile='../Dockerfile',
|
||||
only=['./src/backend', './src/mail', './docker'],
|
||||
target = 'backend-production',
|
||||
live_update=[
|
||||
sync('../src/backend', '/app'),
|
||||
run(
|
||||
'pip install -r /app/requirements.txt',
|
||||
trigger=['./api/requirements.txt']
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/impress-y-provider:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend/', './docker/', './.dockerignore'],
|
||||
target = 'y-provider',
|
||||
live_update=[
|
||||
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
|
||||
]
|
||||
)
|
||||
|
||||
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 .'))
|
||||
|
||||
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
|
||||
'''
|
||||
cmd_button('Make migration',
|
||||
argv=['sh', '-c', migration],
|
||||
resource='impress-backend',
|
||||
icon_name='developer_board',
|
||||
text='Run makemigration',
|
||||
)
|
||||
|
||||
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
|
||||
'''
|
||||
cmd_button('Migrate db',
|
||||
argv=['sh', '-c', pod_migrate],
|
||||
resource='impress-backend',
|
||||
icon_name='developer_board',
|
||||
text='Run database migration',
|
||||
)
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_PROJECT="impress"
|
||||
|
||||
|
||||
# _set_user: set (or unset) default user id used to run docker commands
|
||||
#
|
||||
# usage: _set_user
|
||||
#
|
||||
# You can override default user ID (the current host user ID), by defining the
|
||||
# USER_ID environment variable.
|
||||
#
|
||||
# To avoid running docker commands with a custom user, please set the
|
||||
# $UNSET_USER environment variable to 1.
|
||||
function _set_user() {
|
||||
|
||||
if [ $UNSET_USER -eq 1 ]; then
|
||||
USER_ID=""
|
||||
return
|
||||
fi
|
||||
|
||||
# USER_ID = USER_ID or `id -u` if USER_ID is not set
|
||||
USER_ID=${USER_ID:-$(id -u)}
|
||||
|
||||
echo "🙋(user) ID: ${USER_ID}"
|
||||
}
|
||||
|
||||
# docker_compose: wrap docker compose command
|
||||
#
|
||||
# usage: docker_compose [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose command options
|
||||
# ARGS : docker compose command arguments
|
||||
function _docker_compose() {
|
||||
|
||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-p "${COMPOSE_PROJECT}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# _dc_run: wrap docker compose run command
|
||||
#
|
||||
# usage: _dc_run [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose run command options
|
||||
# ARGS : docker compose run command arguments
|
||||
function _dc_run() {
|
||||
_set_user
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose run --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_exec: wrap docker compose exec command
|
||||
#
|
||||
# usage: _dc_exec [options] [ARGS...]
|
||||
#
|
||||
# options: docker compose exec command options
|
||||
# ARGS : docker compose exec command arguments
|
||||
function _dc_exec() {
|
||||
_set_user
|
||||
|
||||
echo "🐳(compose) exec command: '\$@'"
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose exec $user_args "$@"
|
||||
}
|
||||
|
||||
# _django_manage: wrap django's manage.py command with docker compose
|
||||
#
|
||||
# usage : _django_manage [ARGS...]
|
||||
#
|
||||
# ARGS : django's manage.py command arguments
|
||||
function _django_manage() {
|
||||
_dc_run "app-dev" python manage.py "$@"
|
||||
}
|
||||
|
||||
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
|
||||
# terraform directory.
|
||||
#
|
||||
# usage: _set_openstack_project
|
||||
#
|
||||
# If necessary the script will prompt the user to choose a project from those available
|
||||
function _set_openstack_project() {
|
||||
|
||||
declare prompt
|
||||
declare -a projects
|
||||
declare -i default=1
|
||||
declare -i choice=0
|
||||
declare -i n_projects
|
||||
|
||||
# List projects by looking in the "./env.d/terraform" directory
|
||||
# and store them in an array
|
||||
read -r -a projects <<< "$(
|
||||
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
|
||||
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
|
||||
xargs
|
||||
)"
|
||||
nb_projects=${#projects[@]}
|
||||
|
||||
if [[ ${nb_projects} -le 0 ]]; then
|
||||
echo "There are no OpenStack projects defined..." >&2
|
||||
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
|
||||
"of your project and copy your \"openrc.sh\" file into it." >&2
|
||||
exit 10
|
||||
fi
|
||||
|
||||
if [[ ${nb_projects} -gt 1 ]]; then
|
||||
prompt="Select an OpenStack project to target:\\n"
|
||||
for (( i=0; i<nb_projects; i++ )); do
|
||||
prompt+="[$((i+1))] ${projects[$i]}"
|
||||
if [[ $((i+1)) -eq ${default} ]]; then
|
||||
prompt+=" (default)"
|
||||
fi
|
||||
prompt+="\\n"
|
||||
done
|
||||
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
|
||||
prompt+="Your choice: "
|
||||
read -r -p "$(echo -e "${prompt}")" choice
|
||||
|
||||
if [[ ${choice} -gt nb_projects ]]; then
|
||||
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
|
||||
exit 11
|
||||
fi
|
||||
|
||||
if [[ ${choice} -le 0 ]]; then
|
||||
choice=${default}
|
||||
fi
|
||||
fi
|
||||
|
||||
project=${projects[$((choice-1))]}
|
||||
# Check that the openrc.sh file exists for this project
|
||||
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
|
||||
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
|
||||
exit 12
|
||||
fi
|
||||
|
||||
echo "${project}"
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_docker_compose "$@"
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
cat <<'EOF' >$PRE_COMMIT_FILE
|
||||
#!/bin/bash
|
||||
|
||||
# directories containing potential secrets
|
||||
DIRS="."
|
||||
|
||||
bold=$(tput bold)
|
||||
normal=$(tput sgr0)
|
||||
|
||||
# allow to read user input, assigns stdin to keyboard
|
||||
exec </dev/tty
|
||||
|
||||
for d in $DIRS; do
|
||||
# find files containing secrets that should be encrypted
|
||||
for f in $(find "${d}" -type f -regex ".*\.enc\..*"); do
|
||||
if ! $(grep -q "unencrypted_suffix" $f); then
|
||||
printf '\xF0\x9F\x92\xA5 '
|
||||
echo "File $f has non encrypted secrets!"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
EOF
|
||||
|
||||
chmod +x $PRE_COMMIT_FILE
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_django_manage "$@"
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
declare diff_from
|
||||
declare -a paths
|
||||
declare -a args
|
||||
|
||||
# Parse options
|
||||
for arg in "$@"
|
||||
do
|
||||
case $arg in
|
||||
--diff-only=*)
|
||||
diff_from="${arg#*=}"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
args+=("$arg")
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
paths+=("$arg")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "${diff_from}" ]]; then
|
||||
# Run pylint only on modified files located in src/backend
|
||||
# (excluding deleted files and migration files)
|
||||
# shellcheck disable=SC2207
|
||||
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$'))
|
||||
fi
|
||||
|
||||
# Fix docker vs local path when project sources are mounted as a volume
|
||||
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
|
||||
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
app-dev \
|
||||
pytest "$@"
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/sh
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
echo "0. Create ca"
|
||||
# 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
|
||||
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}" \
|
||||
registry:2
|
||||
fi
|
||||
|
||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# 2. Create kind cluster with containerd registry config dir enabled
|
||||
# TODO: kind will eventually enable this by default and this patch will
|
||||
# be unnecessary.
|
||||
#
|
||||
# See:
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: kindest/node:v1.27.3
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
echo "3. Add the registry config to the nodes"
|
||||
# 3. Add the registry config to the nodes
|
||||
#
|
||||
# This is necessary because localhost resolves to loopback addresses that are
|
||||
# network-namespace local.
|
||||
# In other words: localhost in the container is not localhost on the host.
|
||||
#
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
EOF
|
||||
done
|
||||
|
||||
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
|
||||
# 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
|
||||
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-registry-hosting
|
||||
namespace: kube-public
|
||||
data:
|
||||
localRegistryHosting.v1: |
|
||||
host: "localhost:${reg_port}"
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
EOF
|
||||
|
||||
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"}]'
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
terraform-state "$@"
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
-e TF_VAR_user_name \
|
||||
terraform "$@"
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
git submodule update --init --recursive
|
||||
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
app-dev \
|
||||
python manage.py spectacular \
|
||||
--api-version 'v1.0' \
|
||||
--urlconf 'impress.api_urls' \
|
||||
--format openapi-json \
|
||||
--file /app/core/tests/swagger/swagger.json
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
||||
@@ -0,0 +1,29 @@
|
||||
#
|
||||
# Your crowdin's credentials
|
||||
#
|
||||
api_token_env: CROWDIN_API_TOKEN
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
base_path_env: CROWDIN_BASE_PATH
|
||||
|
||||
#
|
||||
# Choose file structure in crowdin
|
||||
# e.g. true or false
|
||||
#
|
||||
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,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,212 @@
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "15432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
|
||||
mailcatcher:
|
||||
image: sj26/mailcatcher:latest
|
||||
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: .
|
||||
target: backend-development
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: impress:backend-development
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "8071:8000"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./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"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- app-dev
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: backend-production
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: impress:backend-production
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
- minio
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: impress:backend-production
|
||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- 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
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
volumes:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
- env.d/development/crowdin
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
y-provider:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: y-provider
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4444:4444"
|
||||
volumes:
|
||||
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
|
||||
- /home/frontend/servers/y-provider/node_modules/
|
||||
- /home/frontend/servers/y-provider/dist/
|
||||
|
||||
frontend-dev:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: impress-dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./src/frontend/apps/impress:/home/frontend/apps/impress
|
||||
- /home/frontend/node_modules/
|
||||
depends_on:
|
||||
- y-provider
|
||||
- celery-dev
|
||||
|
||||
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
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: kc_postgresql
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: impress
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
|
||||
server {
|
||||
listen 8083;
|
||||
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;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# The container user (see USER in the Dockerfile) is an un-privileged user that
|
||||
# does not exists and is not created during the build phase (see Dockerfile).
|
||||
# Hence, we use this entrypoint to wrap commands that will be run in the
|
||||
# container to create an entry for this user in the /etc/passwd file.
|
||||
#
|
||||
# The following environment variables may be passed to the container to
|
||||
# customize running user account:
|
||||
#
|
||||
# * USER_NAME: container user name (default: default)
|
||||
# * HOME : container user home directory (default: none)
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# or define new variables in an environment file to use with docker or docker compose:
|
||||
#
|
||||
# # env.d/production
|
||||
# USER_NAME=foo
|
||||
# HOME=/home/foo
|
||||
#
|
||||
# docker run --rm --env-file env.d/production impress:latest python manage.py migrate
|
||||
#
|
||||
|
||||
echo "🐳(entrypoint) creating user running in the container..."
|
||||
if ! whoami > /dev/null 2>&1; then
|
||||
if [ -w /etc/passwd ]; then
|
||||
echo "${USER_NAME:-default}:x:$(id -u):$(id -g):${USER_NAME:-default} user:${HOME}:/sbin/nologin" >> /etc/passwd
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "🐳(entrypoint) running your command: ${*}"
|
||||
exec "$@"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Gunicorn-django settings
|
||||
bind = ["0.0.0.0:8000"]
|
||||
name = "impress"
|
||||
python_path = "/app"
|
||||
|
||||
# Run
|
||||
graceful_timeout = 90
|
||||
timeout = 90
|
||||
workers = 3
|
||||
|
||||
# Logging
|
||||
# Using '-' for the access log file makes gunicorn log accesses to stdout
|
||||
accesslog = "-"
|
||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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,41 @@
|
||||
# Django
|
||||
DJANGO_ALLOWED_HOSTS=*
|
||||
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=impress.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
|
||||
# Python
|
||||
PYTHONPATH=/app
|
||||
|
||||
# impress 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
|
||||
|
||||
# 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_RP_CLIENT_ID=impress
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO=RS256
|
||||
OIDC_RP_SCOPES="openid email"
|
||||
|
||||
LOGIN_REDIRECT_URL=http://localhost:3000
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
@@ -0,0 +1,3 @@
|
||||
# For the CI job test-e2e
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
@@ -0,0 +1,3 @@
|
||||
CROWDIN_API_TOKEN=Your-Api-Token
|
||||
CROWDIN_PROJECT_ID=Your-Project-Id
|
||||
CROWDIN_BASE_PATH=/app/src
|
||||
@@ -0,0 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=keycloak
|
||||
POSTGRES_USER=impress
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=kc_postgresql
|
||||
DB_NAME=keycloak
|
||||
DB_USER=impress
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5433
|
||||
@@ -0,0 +1,11 @@
|
||||
# Postgresql db container configuration
|
||||
POSTGRES_DB=impress
|
||||
POSTGRES_USER=dinum
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
# App database configuration
|
||||
DB_HOST=postgresql
|
||||
DB_NAME=impress
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5432
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Gitlint extra rule to validate that the message title is of the form
|
||||
"<gitmoji>(<scope>) <subject>"
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||
|
||||
|
||||
class GitmojiTitle(LineRule):
|
||||
"""
|
||||
This rule will enforce that each commit title is of the form "<gitmoji>(<scope>) <subject>"
|
||||
where gitmoji is an emoji from the list defined in https://gitmoji.carloscuesta.me and
|
||||
subject should be all lowercase
|
||||
"""
|
||||
|
||||
id = "UC1"
|
||||
name = "title-should-have-gitmoji-and-scope"
|
||||
target = CommitMessageTitle
|
||||
|
||||
def validate(self, title, _commit):
|
||||
"""
|
||||
Download the list possible gitmojis from the project's github repository and check that
|
||||
title contains one of them.
|
||||
"""
|
||||
gitmojis = requests.get(
|
||||
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||
).json()["gitmojis"]
|
||||
emojis = [item["emoji"] for item in gitmojis]
|
||||
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
||||
if not re.search(pattern, title):
|
||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||
return [RuleViolation(self.id, violation_msg, title)]
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"packageRules": [
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored python dependencies",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": []
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Submodule
+1
Submodule secrets added at 2643697e5f
@@ -0,0 +1,472 @@
|
||||
[MASTER]
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code
|
||||
extension-pkg-whitelist=
|
||||
|
||||
# Add files or directories to the blacklist. They should be base names, not
|
||||
# paths.
|
||||
ignore=migrations
|
||||
|
||||
# Add files or directories matching the regex patterns to the blacklist. The
|
||||
# regex matches against base names, not paths.
|
||||
ignore-patterns=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||
# number of processors available to use.
|
||||
jobs=0
|
||||
|
||||
# List of plugins (as comma separated values of python modules names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=pylint_django,pylint.extensions.no_self_use
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Specify a configuration file.
|
||||
#rcfile=
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
|
||||
confidence=
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once).You can also use "--disable=all" to
|
||||
# disable everything first and then reenable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||
# --disable=W"
|
||||
disable=bad-inline-option,
|
||||
deprecated-pragma,
|
||||
django-not-configured,
|
||||
file-ignored,
|
||||
locally-disabled,
|
||||
no-self-use,
|
||||
raw-checker-failed,
|
||||
suppressed-message,
|
||||
useless-suppression
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=c-extension-no-member
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a note less than 10 (10 is the highest
|
||||
# note). You have access to the variables errors warning, statement which
|
||||
# respectively contain the number of errors / warnings messages and the total
|
||||
# number of statements analyzed. This is used by the global evaluation report
|
||||
# (RP0004).
|
||||
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details
|
||||
#msg-template=
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, json
|
||||
# and msvs (visual studio).You can also give a reporter class, eg
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
output-format=text
|
||||
|
||||
# Tells whether to display a full report or only the messages
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=optparse.Values,sys.exit
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Limits count of emitted suggestions for spelling mistakes
|
||||
max-spelling-suggestions=4
|
||||
|
||||
# Spelling dictionary name. Available dictionaries: none. To make it working
|
||||
# install python-enchant package.
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to indicated private dictionary in
|
||||
# --spelling-private-dict-file option instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,
|
||||
XXX,
|
||||
TODO
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=
|
||||
|
||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||
# mixin class is detected if its name ends with "mixin" (case insensitive).
|
||||
ignore-mixin-members=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local,responses,
|
||||
Template,Contact
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis. It
|
||||
# supports qualified module names, as well as Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid to define new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,
|
||||
_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expectedly
|
||||
# not used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored. Default to name
|
||||
# with leading underscore
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Maximum number of lines in a module
|
||||
max-module-lines=1000
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Ignore comments when computing similarities.
|
||||
ignore-comments=yes
|
||||
|
||||
# Ignore docstrings when computing similarities.
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Ignore imports when computing similarities.
|
||||
ignore-imports=yes
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
# First implementations of CMS wizards have common fields we do not want to factorize for now
|
||||
min-similarity-lines=35
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names
|
||||
argument-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names
|
||||
attr-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Naming style matching correct class attribute names
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class names
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-style
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style
|
||||
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names
|
||||
function-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma
|
||||
good-names=i,
|
||||
j,
|
||||
k,
|
||||
cm,
|
||||
ex,
|
||||
Run,
|
||||
_
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names
|
||||
method-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style
|
||||
method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$
|
||||
|
||||
# Naming style matching correct module names
|
||||
module-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Naming style matching correct variable names
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma
|
||||
deprecated-modules=optparse,tkinter.tix
|
||||
|
||||
# Create a graph of external dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
ext-import-graph=
|
||||
|
||||
# Create a graph of every (i.e. internal and external) dependencies in the
|
||||
# given file (report RP0402 must not be disabled)
|
||||
import-graph=
|
||||
|
||||
# Create a graph of internal dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,
|
||||
__new__,
|
||||
setUp
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,
|
||||
_fields,
|
||||
_replace,
|
||||
_source,
|
||||
_make
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# Maximum number of arguments for function / method
|
||||
max-args=5
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Maximum number of boolean expressions in a if statement
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
# Maximum number of return / yield for function / method body
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of statements in function / method body
|
||||
max-statements=50
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=0
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "Exception"
|
||||
overgeneral-exceptions=builtins.Exception
|
||||
@@ -0,0 +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
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class TemplateAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for template accesses."""
|
||||
|
||||
model = models.TemplateAccess
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"admin_email",
|
||||
"password",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
),
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (TemplateAccessInline,)
|
||||
list_display = (
|
||||
"id",
|
||||
"sub",
|
||||
"admin_email",
|
||||
"email",
|
||||
"is_active",
|
||||
"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")
|
||||
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
|
||||
search_fields = ("id", "sub", "admin_email", "email")
|
||||
|
||||
|
||||
@admin.register(models.Template)
|
||||
class TemplateAdmin(admin.ModelAdmin):
|
||||
"""Template 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,)
|
||||
list_display = (
|
||||
"id",
|
||||
"title",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(models.Invitation)
|
||||
class InvitationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface to handle invitations."""
|
||||
|
||||
fields = (
|
||||
"email",
|
||||
"document",
|
||||
"role",
|
||||
"created_at",
|
||||
"issuer",
|
||||
)
|
||||
readonly_fields = (
|
||||
"created_at",
|
||||
"is_expired",
|
||||
"issuer",
|
||||
)
|
||||
list_display = (
|
||||
"email",
|
||||
"document",
|
||||
"created_at",
|
||||
"is_expired",
|
||||
)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.issuer = request.user
|
||||
obj.save()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Impress core API endpoints"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from rest_framework import exceptions as drf_exceptions
|
||||
from rest_framework import views as drf_views
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
"""Handle Django ValidationError as an accepted exception.
|
||||
|
||||
For the parameters, see ``exception_handler``
|
||||
This code comes from twidi's gist:
|
||||
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
|
||||
"""
|
||||
if isinstance(exc, ValidationError):
|
||||
detail = exc.message_dict
|
||||
|
||||
if hasattr(exc, "message"):
|
||||
detail = exc.message
|
||||
elif hasattr(exc, "messages"):
|
||||
detail = exc.messages
|
||||
|
||||
exc = drf_exceptions.ValidationError(detail=detail)
|
||||
|
||||
return drf_views.exception_handler(exc, context)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@api_view(["GET"])
|
||||
def get_frontend_configuration(request):
|
||||
"""Returns the frontend configuration dict as configured in settings."""
|
||||
frontend_configuration = {
|
||||
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Permission handlers for the impress 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):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
of the auth token to avoid hitting the database.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
of the auth token to avoid hitting the database.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Write permissions are only allowed to the user itself."""
|
||||
return obj == request.user
|
||||
|
||||
|
||||
class IsOwnedOrPublic(IsAuthenticated):
|
||||
"""
|
||||
Allows access to authenticated users only for objects that are owned or not related
|
||||
to any user via the "owner" field.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Unsafe permissions are only allowed for the owner of the object."""
|
||||
if obj.owner == request.user:
|
||||
return True
|
||||
|
||||
if request.method in permissions.SAFE_METHODS and obj.owner is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
return obj.user == request.user
|
||||
except exceptions.ObjectDoesNotExist:
|
||||
return False
|
||||
|
||||
|
||||
class AccessPermission(permissions.BasePermission):
|
||||
"""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)
|
||||
@@ -0,0 +1,352 @@
|
||||
"""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 _
|
||||
|
||||
from rest_framework import exceptions, serializers
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "sub", "email"]
|
||||
read_only_fields = ["id", "sub", "email"]
|
||||
|
||||
|
||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize template accesses."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "user" field is readonly but only on update."""
|
||||
validated_data.pop("user", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def get_abilities(self, access) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return access.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Check access rights specific to writing (create/update)
|
||||
"""
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
role = attrs.get("role")
|
||||
|
||||
# Update
|
||||
if self.instance:
|
||||
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
|
||||
|
||||
if role and role not in can_set_role_to:
|
||||
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."
|
||||
)
|
||||
raise exceptions.PermissionDenied(message)
|
||||
|
||||
# Create
|
||||
else:
|
||||
try:
|
||||
resource_id = self.context["resource_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a resource ID in kwargs to create a new access."
|
||||
) from exc
|
||||
|
||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage accesses for this resource."
|
||||
)
|
||||
|
||||
if (
|
||||
role == models.RoleChoices.OWNER
|
||||
and not self.Meta.model.objects.filter( # pylint: disable=no-member
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
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."
|
||||
)
|
||||
|
||||
# pylint: disable=no-member
|
||||
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
|
||||
return attrs
|
||||
|
||||
|
||||
class DocumentAccessSerializer(BaseAccessSerializer):
|
||||
"""Serialize document accesses."""
|
||||
|
||||
user_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=models.User.objects.all(),
|
||||
write_only=True,
|
||||
source="user",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
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
|
||||
fields = [
|
||||
"id",
|
||||
"content",
|
||||
"title",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"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()
|
||||
|
||||
request = self.context.get("request")
|
||||
if request and request.method == "POST":
|
||||
fields["id"].read_only = False
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
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 {}
|
||||
|
||||
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"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a document ID in kwargs to create a new document 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,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage invitations for this document."
|
||||
)
|
||||
|
||||
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["issuer"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class DocumentVersionSerializer(serializers.Serializer):
|
||||
"""Serialize Versions."""
|
||||
|
||||
etag = serializers.CharField()
|
||||
is_latest = serializers.BooleanField()
|
||||
last_modified = serializers.DateTimeField()
|
||||
version_id = serializers.CharField()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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
|
||||
@@ -0,0 +1,772 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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 (
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
)
|
||||
from django.http import Http404
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
filters,
|
||||
mixins,
|
||||
pagination,
|
||||
status,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
|
||||
from core import models
|
||||
from core.utils import email_invitation
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
||||
|
||||
It allows to define all url kwargs and lookup fields to perform the lookup.
|
||||
"""
|
||||
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, item):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if item in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, item + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of items for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# The last lookup field is removed to perform the nested lookup as it corresponds
|
||||
# to the object pk, it is used within get_object method.
|
||||
lookup_url_kwargs = (
|
||||
self.lookup_url_kwargs[:-1]
|
||||
if self.lookup_url_kwargs
|
||||
else self.lookup_fields[:-1]
|
||||
)
|
||||
|
||||
filter_kwargs = {}
|
||||
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
|
||||
if lookup_url_kwarg not in self.kwargs:
|
||||
raise KeyError(
|
||||
f"Expected view {self.__class__.__name__} to be called with a URL "
|
||||
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
|
||||
"set the `.lookup_fields` attribute on the view correctly."
|
||||
)
|
||||
|
||||
filter_kwargs.update(
|
||||
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
|
||||
)
|
||||
|
||||
return queryset.filter(**filter_kwargs)
|
||||
|
||||
|
||||
class SerializerPerActionMixin:
|
||||
"""
|
||||
A mixin to allow to define serializer classes for each action.
|
||||
|
||||
This mixin is useful to avoid to define a serializer class for each action in the
|
||||
`get_serializer_class` method.
|
||||
"""
|
||||
|
||||
serializer_classes: dict[str, type] = {}
|
||||
default_serializer_class: type = None
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
Return the serializer class to use depending on the action.
|
||||
"""
|
||||
return self.serializer_classes.get(self.action, self.default_serializer_class)
|
||||
|
||||
|
||||
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 UserViewSet(
|
||||
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all()
|
||||
serializer_class = serializers.UserSerializer
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
# Filter users by email similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
queryset = queryset.filter(email__trigram_word_similar=query)
|
||||
|
||||
return queryset
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class ResourceViewsetMixin:
|
||||
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
|
||||
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
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")
|
||||
)
|
||||
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(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
**{self.resource_field_name: obj},
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewsetMixin:
|
||||
"""Mixin with methods common to all access viewsets."""
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list resource accesses"""
|
||||
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"]
|
||||
return context
|
||||
|
||||
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"]}
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
# 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 = (
|
||||
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"]},
|
||||
)
|
||||
.annotate(user_roles=Subquery(user_roles_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Forbid deleting the last owner access"""
|
||||
instance = self.get_object()
|
||||
resource = getattr(instance, self.resource_field_name)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Check that we don't change the role if it leads to losing the last owner."""
|
||||
instance = serializer.instance
|
||||
|
||||
# Check if the role is being updated and the new role is not "owner"
|
||||
if (
|
||||
"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
|
||||
if (
|
||||
instance.role == models.RoleChoices.OWNER
|
||||
and resource.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})
|
||||
|
||||
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
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
raise exceptions.PermissionDenied("Authentication required.")
|
||||
|
||||
document = self.get_object()
|
||||
user = request.user
|
||||
from_datetime = min(
|
||||
access.created_at
|
||||
for access in document.accesses.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
)
|
||||
)
|
||||
|
||||
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
|
||||
"versions"
|
||||
]
|
||||
paginator = pagination.PageNumberPagination()
|
||||
paginated_versions = paginator.paginate_queryset(versions_data, request)
|
||||
serialized_versions = serializers.DocumentVersionSerializer(
|
||||
paginated_versions, many=True
|
||||
)
|
||||
|
||||
return paginator.get_paginated_response(serialized_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
|
||||
from_datetime = min(
|
||||
access.created_at
|
||||
for access in document.accesses.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
)
|
||||
)
|
||||
if response["LastModified"] < from_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")
|
||||
email_invitation(language, access.user.email, access.document.id)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
POST /api/v1.0/documents/<document_id>/invitations/ with expected data:
|
||||
- email: str
|
||||
- role: str [administrator|editor|reader]
|
||||
Return newly created invitation (issuer and document are automatically set)
|
||||
|
||||
PATCH /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/ with expected data:
|
||||
- role: str [owner|admin|editor|reader]
|
||||
Return partially updated document invitation
|
||||
|
||||
DELETE /api/v1.0/documents/<document_id>/invitations/<invitation_id>/
|
||||
Delete targeted invitation
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
|
||||
queryset = (
|
||||
models.Invitation.objects.all()
|
||||
.select_related("document")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
serializer_class = serializers.InvitationSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["resource_id"] = self.kwargs["resource_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"])
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
queryset = (
|
||||
# The logged-in user should be part of a document to see its accesses
|
||||
queryset.filter(
|
||||
Q(document__accesses__user=user)
|
||||
| Q(document__accesses__team__in=teams),
|
||||
)
|
||||
# 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))
|
||||
.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")
|
||||
email_invitation(language, invitation.email, invitation.document.id)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Impress 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."""
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("impress core application")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Authentication Backends for the Impress core app."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
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. 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)
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
else:
|
||||
user = None
|
||||
|
||||
return user
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
password="!", # noqa: S106
|
||||
)
|
||||
|
||||
return user
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
# the choice of languages which should not be limited to the few languages active in
|
||||
# the app.
|
||||
# pylint: disable=no-member
|
||||
ALL_LANGUAGES = getattr(
|
||||
settings,
|
||||
"ALL_LANGUAGES",
|
||||
[(language, _(name)) for language, name in global_settings.LANGUAGES],
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
# ruff: noqa: S311
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
import factory.fuzzy
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to random users for testing purposes."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
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 Meta:
|
||||
model = models.Document
|
||||
django_get_or_create = ("title",)
|
||||
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]
|
||||
)
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document user accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.DocumentAccess
|
||||
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
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 Meta:
|
||||
model = models.DocumentAccess
|
||||
|
||||
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])
|
||||
|
||||
|
||||
class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create invitations for a user"""
|
||||
|
||||
class Meta:
|
||||
model = models.Invitation
|
||||
|
||||
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,166 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-28 20:29
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
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')),
|
||||
],
|
||||
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',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('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')),
|
||||
('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)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'impress_user',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentAccess',
|
||||
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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user relation',
|
||||
'verbose_name_plural': 'Document/user relations',
|
||||
'db_table': 'impress_document_access',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
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')),
|
||||
('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')),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document invitation',
|
||||
'verbose_name_plural': 'Document invitations',
|
||||
'db_table': 'impress_invitation',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TemplateAccess',
|
||||
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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template/user relation',
|
||||
'verbose_name_plural': 'Template/user relations',
|
||||
'db_table': 'impress_template_access',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
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.'),
|
||||
),
|
||||
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.'),
|
||||
),
|
||||
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.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_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.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
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;",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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.')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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
@@ -0,0 +1,18 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,873 @@
|
||||
"""
|
||||
Declare and configure the models for the impress core application
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import textwrap
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.core import exceptions, mail, validators
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import models
|
||||
from django.http import FileResponse
|
||||
from django.template.base import Template as DjangoTemplate
|
||||
from django.template.context import Context
|
||||
from django.utils import html, timezone
|
||||
from django.utils.functional import cached_property, lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
import pypandoc
|
||||
import weasyprint
|
||||
from botocore.exceptions import ClientError
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def get_resource_roles(resource, user):
|
||||
"""Compute the roles a user has on a resource."""
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
|
||||
try:
|
||||
roles = resource.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = resource.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||
).values_list("role", flat=True)
|
||||
except (models.ObjectDoesNotExist, IndexError):
|
||||
roles = []
|
||||
return roles
|
||||
|
||||
|
||||
class LinkRoleChoices(models.TextChoices):
|
||||
"""Defines the possible roles a link can offer on a document."""
|
||||
|
||||
READER = "reader", _("Reader") # Can read
|
||||
EDITOR = "editor", _("Editor") # Can read and edit
|
||||
|
||||
|
||||
class RoleChoices(models.TextChoices):
|
||||
"""Defines the possible roles a user can have in a resource."""
|
||||
|
||||
READER = "reader", _("Reader") # Can read
|
||||
EDITOR = "editor", _("Editor") # Can read and edit
|
||||
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
|
||||
class LinkReachChoices(models.TextChoices):
|
||||
"""Defines types of access for links"""
|
||||
|
||||
RESTRICTED = (
|
||||
"restricted",
|
||||
_("Restricted"),
|
||||
) # Only users with a specific access can read/edit the document
|
||||
AUTHENTICATED = (
|
||||
"authenticated",
|
||||
_("Authenticated"),
|
||||
) # Any authenticated user can access the document
|
||||
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
before saving as Django doesn't do it by default.
|
||||
|
||||
Includes fields common to all models: a UUID primary key and creation/update timestamps.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(
|
||||
verbose_name=_("id"),
|
||||
help_text=_("primary key for the record as UUID"),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
verbose_name=_("created on"),
|
||||
help_text=_("date and time at which a record was created"),
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
verbose_name=_("updated on"),
|
||||
help_text=_("date and time at which a record was last updated"),
|
||||
auto_now=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Call `full_clean` before saving."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_ characters."
|
||||
),
|
||||
)
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
validators=[sub_validator],
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||
|
||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||
# stores the email used by staff users to login to the admin site
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, blank=True, null=True
|
||||
)
|
||||
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
default=settings.LANGUAGE_CODE,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
)
|
||||
timezone = TimeZoneField(
|
||||
choices_display="WITH_GMT_OFFSET",
|
||||
use_pytz=False,
|
||||
default=settings.TIME_ZONE,
|
||||
help_text=_("The timezone in which the user wants to see times."),
|
||||
)
|
||||
is_device = models.BooleanField(
|
||||
_("device"),
|
||||
default=False,
|
||||
help_text=_("Whether the user is a device or a real user."),
|
||||
)
|
||||
is_staff = models.BooleanField(
|
||||
_("staff status"),
|
||||
default=False,
|
||||
help_text=_("Whether the user can log into this admin site."),
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
_("active"),
|
||||
default=True,
|
||||
help_text=_(
|
||||
"Whether this user should be treated as active. "
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_user"
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
If it's a new user, give its user access to the documents to which s.he was invited.
|
||||
"""
|
||||
is_adding = self._state.adding
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if is_adding:
|
||||
self._convert_valid_invitations()
|
||||
|
||||
def _convert_valid_invitations(self):
|
||||
"""
|
||||
Convert valid invitations to document accesses.
|
||||
Expired invitations are ignored.
|
||||
"""
|
||||
valid_invitations = Invitation.objects.filter(
|
||||
email=self.email,
|
||||
created_at__gte=(
|
||||
timezone.now()
|
||||
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
),
|
||||
).select_related("document")
|
||||
|
||||
if not valid_invitations.exists():
|
||||
return
|
||||
|
||||
DocumentAccess.objects.bulk_create(
|
||||
[
|
||||
DocumentAccess(
|
||||
user=self, document=invitation.document, role=invitation.role
|
||||
)
|
||||
for invitation in valid_invitations
|
||||
]
|
||||
)
|
||||
valid_invitations.delete()
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
raise ValueError("User has no email address.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
|
||||
@cached_property
|
||||
def teams(self):
|
||||
"""
|
||||
Get list of teams in which the user is, as a list of strings.
|
||||
Must be cached if retrieved remotely.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class BaseAccess(BaseModel):
|
||||
"""Base model for accesses to handle resources."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
team = models.CharField(max_length=100, blank=True)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def _get_abilities(self, resource, user):
|
||||
"""
|
||||
Compute and return abilities for a given user taking into account
|
||||
the current state of the object.
|
||||
"""
|
||||
roles = []
|
||||
if user.is_authenticated:
|
||||
teams = user.teams
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = resource.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=teams),
|
||||
).values_list("role", flat=True)
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
is_owner_or_admin = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
if self.role == RoleChoices.OWNER:
|
||||
can_delete = (
|
||||
RoleChoices.OWNER in roles
|
||||
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
||||
)
|
||||
set_role_to = (
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
if can_delete
|
||||
else []
|
||||
)
|
||||
else:
|
||||
can_delete = is_owner_or_admin
|
||||
set_role_to = []
|
||||
if RoleChoices.OWNER in roles:
|
||||
set_role_to.append(RoleChoices.OWNER)
|
||||
if is_owner_or_admin:
|
||||
set_role_to.extend(
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
)
|
||||
|
||||
# Remove the current role as we don't want to propose it as an option
|
||||
try:
|
||||
set_role_to.remove(self.role)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": bool(set_role_to),
|
||||
"partial_update": bool(set_role_to),
|
||||
"retrieve": bool(roles),
|
||||
"set_role_to": set_role_to,
|
||||
}
|
||||
|
||||
|
||||
class Document(BaseModel):
|
||||
"""Pad document carrying the content."""
|
||||
|
||||
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
|
||||
link_reach = models.CharField(
|
||||
max_length=20,
|
||||
choices=LinkReachChoices.choices,
|
||||
default=LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
link_role = models.CharField(
|
||||
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
|
||||
)
|
||||
|
||||
_content = None
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_document"
|
||||
ordering = ("title",)
|
||||
verbose_name = _("Document")
|
||||
verbose_name_plural = _("Documents")
|
||||
|
||||
def __str__(self):
|
||||
return str(self.title) if self.title else str(_("Untitled Document"))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Write content to object storage only if _content has changed."""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self._content:
|
||||
file_key = self.file_key
|
||||
bytes_content = self._content.encode("utf-8")
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"')
|
||||
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
|
||||
@property
|
||||
def key_base(self):
|
||||
"""Key base of the location where the document is stored in object storage."""
|
||||
if not self.pk:
|
||||
raise RuntimeError(
|
||||
"The document instance must be saved before requesting a storage key."
|
||||
)
|
||||
return str(self.pk)
|
||||
|
||||
@property
|
||||
def file_key(self):
|
||||
"""Key of the object storage file to which the document content is stored"""
|
||||
return f"{self.key_base}/file"
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
"""Return the json content from object storage if available"""
|
||||
if self._content is None and self.id:
|
||||
try:
|
||||
response = self.get_content_response()
|
||||
except (FileNotFoundError, ClientError):
|
||||
pass
|
||||
else:
|
||||
self._content = response["Body"].read().decode("utf-8")
|
||||
return self._content
|
||||
|
||||
@content.setter
|
||||
def content(self, content):
|
||||
"""Cache the content, don't write to object storage yet"""
|
||||
if not isinstance(content, str):
|
||||
raise ValueError("content should be a string.")
|
||||
|
||||
self._content = content
|
||||
|
||||
def get_content_response(self, version_id=""):
|
||||
"""Get the content in a specific version of the document"""
|
||||
return default_storage.connection.meta.client.get_object(
|
||||
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
|
||||
)
|
||||
|
||||
def get_versions_slice(
|
||||
self, from_version_id="", from_datetime=None, page_size=None
|
||||
):
|
||||
"""Get document versions from object storage with pagination and starting conditions"""
|
||||
# /!\ Trick here /!\
|
||||
# The "KeyMarker" and "VersionIdMarker" fields must either be both set or both not set.
|
||||
# The error we get otherwise is not helpful at all.
|
||||
token = {}
|
||||
if from_version_id:
|
||||
token.update(
|
||||
{"KeyMarker": self.file_key, "VersionIdMarker": from_version_id}
|
||||
)
|
||||
|
||||
if from_datetime:
|
||||
response = default_storage.connection.meta.client.list_object_versions(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Prefix=self.file_key,
|
||||
MaxKeys=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
|
||||
**token,
|
||||
)
|
||||
|
||||
# Find the first version after the given datetime
|
||||
version = None
|
||||
for version in response.get("Versions", []):
|
||||
if version["LastModified"] >= from_datetime:
|
||||
token = {
|
||||
"KeyMarker": self.file_key,
|
||||
"VersionIdMarker": version["VersionId"],
|
||||
}
|
||||
break
|
||||
else:
|
||||
if version is None or version["LastModified"] < from_datetime:
|
||||
if response["NextVersionIdMarker"]:
|
||||
return self.get_versions_slice(
|
||||
from_version_id=response["NextVersionIdMarker"],
|
||||
page_size=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
|
||||
from_datetime=from_datetime,
|
||||
)
|
||||
return {
|
||||
"next_version_id_marker": "",
|
||||
"is_truncated": False,
|
||||
"versions": [],
|
||||
}
|
||||
|
||||
response = default_storage.connection.meta.client.list_object_versions(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Prefix=self.file_key,
|
||||
MaxKeys=min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
|
||||
if page_size
|
||||
else settings.DOCUMENT_VERSIONS_PAGE_SIZE,
|
||||
**token,
|
||||
)
|
||||
return {
|
||||
"next_version_id_marker": response["NextVersionIdMarker"],
|
||||
"is_truncated": response["IsTruncated"],
|
||||
"versions": [
|
||||
{
|
||||
key_snake: version[key_camel]
|
||||
for key_camel, key_snake in [
|
||||
("ETag", "etag"),
|
||||
("IsLatest", "is_latest"),
|
||||
("LastModified", "last_modified"),
|
||||
("VersionId", "version_id"),
|
||||
]
|
||||
}
|
||||
for version in response.get("Versions", [])
|
||||
],
|
||||
}
|
||||
|
||||
def delete_version(self, version_id):
|
||||
"""Delete a version from object storage given its version id"""
|
||||
return default_storage.connection.meta.client.delete_object(
|
||||
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
|
||||
)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the document.
|
||||
"""
|
||||
roles = set(get_resource_roles(self, user))
|
||||
|
||||
# Compute version roles before adding link roles because we don't
|
||||
# want anonymous users to access versions (we wouldn't know from
|
||||
# which date to allow them anyway)
|
||||
can_get_versions = bool(roles)
|
||||
|
||||
# Add role provided by the document link
|
||||
if self.link_reach == LinkReachChoices.PUBLIC or (
|
||||
self.link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
|
||||
):
|
||||
roles.add(self.link_role)
|
||||
|
||||
is_owner_or_admin = bool(
|
||||
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
is_editor = bool(RoleChoices.EDITOR in roles)
|
||||
can_get = bool(roles)
|
||||
|
||||
return {
|
||||
"attachment_upload": is_owner_or_admin or is_editor,
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
"manage_accesses": is_owner_or_admin,
|
||||
"partial_update": is_owner_or_admin or is_editor,
|
||||
"retrieve": can_get,
|
||||
"update": is_owner_or_admin or is_editor,
|
||||
"versions_destroy": is_owner_or_admin,
|
||||
"versions_list": can_get_versions,
|
||||
"versions_retrieve": can_get_versions,
|
||||
}
|
||||
|
||||
|
||||
class LinkTrace(BaseModel):
|
||||
"""
|
||||
Relation model to trace accesses to a document via a link by a logged-in user.
|
||||
This is necessary to show the document in the user's list of documents even
|
||||
though the user does not have a role on the document.
|
||||
"""
|
||||
|
||||
document = models.ForeignKey(
|
||||
Document,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="link_traces",
|
||||
)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_link_trace"
|
||||
verbose_name = _("Document/user link trace")
|
||||
verbose_name_plural = _("Document/user link traces")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "document"],
|
||||
name="unique_link_trace_document_user",
|
||||
violation_error_message=_(
|
||||
"A link trace already exists for this document/user."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} trace on document {self.document!s}"
|
||||
|
||||
|
||||
class DocumentAccess(BaseAccess):
|
||||
"""Relation model to give access to a document for a user or a team with a role."""
|
||||
|
||||
document = models.ForeignKey(
|
||||
Document,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="accesses",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_document_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Document/user relation")
|
||||
verbose_name_plural = _("Document/user relations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "document"],
|
||||
condition=models.Q(user__isnull=False), # Exclude null users
|
||||
name="unique_document_user",
|
||||
violation_error_message=_("This user is already in this document."),
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "document"],
|
||||
condition=models.Q(team__gt=""), # Exclude empty string teams
|
||||
name="unique_document_team",
|
||||
violation_error_message=_("This team is already in this document."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=models.Q(user__isnull=False, team="")
|
||||
| models.Q(user__isnull=True, team__gt=""),
|
||||
name="check_document_access_either_user_or_team",
|
||||
violation_error_message=_("Either user or team must be set, not both."),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} is {self.role:s} in document {self.document!s}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the document access.
|
||||
"""
|
||||
return self._get_abilities(self.document, user)
|
||||
|
||||
|
||||
class Template(BaseModel):
|
||||
"""HTML and CSS code used for formatting the print around the MarkDown body."""
|
||||
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
code = models.TextField(_("code"), blank=True)
|
||||
css = models.TextField(_("css"), blank=True)
|
||||
is_public = models.BooleanField(
|
||||
_("public"),
|
||||
default=False,
|
||||
help_text=_("Whether this template is public for anyone to use."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_template"
|
||||
ordering = ("title",)
|
||||
verbose_name = _("Template")
|
||||
verbose_name_plural = _("Templates")
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the template.
|
||||
"""
|
||||
roles = get_resource_roles(self, user)
|
||||
is_owner_or_admin = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
is_editor = bool(RoleChoices.EDITOR in roles)
|
||||
can_get = self.is_public or bool(roles)
|
||||
|
||||
return {
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"generate_document": can_get,
|
||||
"manage_accesses": is_owner_or_admin,
|
||||
"update": is_owner_or_admin or is_editor,
|
||||
"partial_update": is_owner_or_admin or is_editor,
|
||||
"retrieve": can_get,
|
||||
}
|
||||
|
||||
def generate_pdf(self, body_html, metadata):
|
||||
"""
|
||||
Generate and return a pdf document wrapped around the current template
|
||||
"""
|
||||
document_html = weasyprint.HTML(
|
||||
string=DjangoTemplate(self.code).render(
|
||||
Context({"body": html.format_html(body_html), **metadata})
|
||||
)
|
||||
)
|
||||
css = weasyprint.CSS(
|
||||
string=self.css,
|
||||
font_config=weasyprint.text.fonts.FontConfiguration(),
|
||||
)
|
||||
|
||||
pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1)
|
||||
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
|
||||
response["Content-Disposition"] = f"attachment; filename={self.title}.pdf"
|
||||
|
||||
return response
|
||||
|
||||
def generate_word(self, body_html, metadata):
|
||||
"""
|
||||
Generate and return a docx document wrapped around the current template
|
||||
"""
|
||||
template_string = DjangoTemplate(self.code).render(
|
||||
Context({"body": html.format_html(body_html), **metadata})
|
||||
)
|
||||
|
||||
html_string = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
{self.css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{template_string}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
reference_docx = "core/static/reference.docx"
|
||||
output = BytesIO()
|
||||
|
||||
# Convert the HTML to a temporary docx file
|
||||
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
|
||||
output_path = tmp_file.name
|
||||
|
||||
pypandoc.convert_text(
|
||||
html_string,
|
||||
"docx",
|
||||
format="html",
|
||||
outputfile=output_path,
|
||||
extra_args=["--reference-doc", reference_docx],
|
||||
)
|
||||
|
||||
# Create a BytesIO object to store the output of the temporary docx file
|
||||
with open(output_path, "rb") as f:
|
||||
output = BytesIO(f.read())
|
||||
|
||||
# Ensure the pointer is at the beginning
|
||||
output.seek(0)
|
||||
|
||||
response = FileResponse(
|
||||
output,
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
response["Content-Disposition"] = f"attachment; filename={self.title}.docx"
|
||||
|
||||
return response
|
||||
|
||||
def generate_document(self, body, body_type, export_format):
|
||||
"""
|
||||
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: export_format = "pdf"
|
||||
- Docx: export_format = "docx"
|
||||
"""
|
||||
document = frontmatter.loads(body)
|
||||
metadata = document.metadata
|
||||
strip_body = document.content.strip()
|
||||
|
||||
if body_type == "html":
|
||||
body_html = strip_body
|
||||
else:
|
||||
body_html = (
|
||||
markdown.markdown(textwrap.dedent(strip_body)) if strip_body else ""
|
||||
)
|
||||
|
||||
if export_format == "pdf":
|
||||
return self.generate_pdf(body_html, metadata)
|
||||
|
||||
return self.generate_word(body_html, metadata)
|
||||
|
||||
|
||||
class TemplateAccess(BaseAccess):
|
||||
"""Relation model to give access to a template for a user or a team with a role."""
|
||||
|
||||
template = models.ForeignKey(
|
||||
Template,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="accesses",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_template_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Template/user relation")
|
||||
verbose_name_plural = _("Template/user relations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "template"],
|
||||
condition=models.Q(user__isnull=False), # Exclude null users
|
||||
name="unique_template_user",
|
||||
violation_error_message=_("This user is already in this template."),
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "template"],
|
||||
condition=models.Q(team__gt=""), # Exclude empty string teams
|
||||
name="unique_template_team",
|
||||
violation_error_message=_("This team is already in this template."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=models.Q(user__isnull=False, team="")
|
||||
| models.Q(user__isnull=True, team__gt=""),
|
||||
name="check_template_access_either_user_or_team",
|
||||
violation_error_message=_("Either user or team must be set, not both."),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the template access.
|
||||
"""
|
||||
return self._get_abilities(self.template, user)
|
||||
|
||||
|
||||
class Invitation(BaseModel):
|
||||
"""User invitation to a document."""
|
||||
|
||||
email = models.EmailField(_("email address"), null=False, blank=False)
|
||||
document = models.ForeignKey(
|
||||
Document,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
||||
)
|
||||
issuer = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_invitation"
|
||||
verbose_name = _("Document invitation")
|
||||
verbose_name_plural = _("Document invitations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["email", "document"], name="email_and_document_unique_together"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} invited to {self.document}"
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
# Check if an identity already exists for the provided email
|
||||
if User.objects.filter(email=self.email).exists():
|
||||
raise exceptions.ValidationError(
|
||||
{"email": _("This email is already associated to a registered user.")}
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Calculate if invitation is still valid or has expired."""
|
||||
if not self.created_at:
|
||||
return None
|
||||
|
||||
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
return timezone.now() > (self.created_at + validity_duration)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user."""
|
||||
can_delete = False
|
||||
can_update = False
|
||||
roles = []
|
||||
|
||||
if user.is_authenticated:
|
||||
teams = user.teams
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = self.document.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=teams),
|
||||
).values_list("role", flat=True)
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
can_delete = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
|
||||
can_update = bool(
|
||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": can_update,
|
||||
"partial_update": can_update,
|
||||
"retrieve": bool(roles),
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Custom template tags for the core application of People."""
|
||||
|
||||
import base64
|
||||
|
||||
from django import template
|
||||
from django.contrib.staticfiles import finders
|
||||
|
||||
from PIL import ImageFile as PillowImageFile
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def image_to_base64(file_or_path, close=False):
|
||||
"""
|
||||
Return the src string of the base64 encoding of an image represented by its path
|
||||
or file opened or not.
|
||||
|
||||
Inspired by Django's "get_image_dimensions"
|
||||
"""
|
||||
pil_parser = PillowImageFile.Parser()
|
||||
if hasattr(file_or_path, "read"):
|
||||
file = file_or_path
|
||||
if file.closed and hasattr(file, "open"):
|
||||
file_or_path.open()
|
||||
file_pos = file.tell()
|
||||
file.seek(0)
|
||||
else:
|
||||
try:
|
||||
# pylint: disable=consider-using-with
|
||||
file = open(file_or_path, "rb")
|
||||
except OSError:
|
||||
return ""
|
||||
close = True
|
||||
|
||||
try:
|
||||
image_data = file.read()
|
||||
if not image_data:
|
||||
return ""
|
||||
pil_parser.feed(image_data)
|
||||
if pil_parser.image:
|
||||
mime_type = pil_parser.image.get_format_mimetype()
|
||||
encoded_string = base64.b64encode(image_data)
|
||||
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
|
||||
return ""
|
||||
finally:
|
||||
if close:
|
||||
file.close()
|
||||
else:
|
||||
file.seek(file_pos)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def base64_static(path):
|
||||
"""Return a static file into a base64."""
|
||||
full_path = finders.find(path)
|
||||
if full_path:
|
||||
return image_to_base64(full_path, True)
|
||||
return ""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user matches the user's info sub, the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's info doesn't contain an email, created user's email should be empty.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's email and name should be set on the identity.
|
||||
The "email" field on the User model should not be set as it is reserved for staff users.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "impress@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
|
||||
"""The user's info doesn't contain a sub."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"test": "123",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
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()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
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()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
@@ -0,0 +1,18 @@
|
||||
"""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
|
||||
@@ -0,0 +1,792 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
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 "Invitation to join Docs!" 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 "Invitation to join Docs!" in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
@@ -0,0 +1,658 @@
|
||||
"""
|
||||
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 "Invitation to join Docs!" 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 "Invitation à rejoindre Docs !" 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 "Invitation to join Docs!" 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."
|
||||
)
|
||||
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
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(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 len(content["results"]) == 0
|
||||
assert content["count"] == 0
|
||||
|
||||
# Add a new version to the document
|
||||
document.content = "new content"
|
||||
document.save()
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["count"] == 1
|
||||
|
||||
|
||||
@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)
|
||||
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)
|
||||
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 user accesses.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
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")
|
||||
|
||||
# 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 make it available to the user
|
||||
time.sleep(1) # minio stores datetimes with the precision of a second
|
||||
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 == 200
|
||||
assert response.json()["content"] == "new content"
|
||||
|
||||
|
||||
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()
|
||||
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
|
||||
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/documents/{access.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()
|
||||
version_id = access.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()
|
||||
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")
|
||||
|
||||
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)
|
||||
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) == 2
|
||||
|
||||
version_id = versions[1]["version_id"]
|
||||
response = client.delete(
|
||||
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
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) == 2
|
||||
|
||||
|
||||
@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"
|
||||
document.save()
|
||||
|
||||
versions = document.get_versions_slice()["versions"]
|
||||
assert len(versions) == 2
|
||||
|
||||
version_id = versions[1]["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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
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."]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,152 @@
|
||||
"""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]
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
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])
|
||||
@@ -0,0 +1,583 @@
|
||||
"""
|
||||
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"),
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
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"
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Test suite for generated openapi schema.
|
||||
"""
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import Client
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_openapi_client_schema():
|
||||
"""
|
||||
Generated and served OpenAPI client schema should be correct.
|
||||
"""
|
||||
# Start by generating the swagger.json file
|
||||
output = StringIO()
|
||||
call_command(
|
||||
"spectacular",
|
||||
"--api-version",
|
||||
"v1.0",
|
||||
"--urlconf",
|
||||
"core.urls",
|
||||
"--format",
|
||||
"openapi-json",
|
||||
"--file",
|
||||
"core/tests/swagger/swagger.json",
|
||||
stdout=output,
|
||||
)
|
||||
assert output.getvalue() == ""
|
||||
|
||||
response = Client().get("/v1.0/swagger.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
with open(
|
||||
"core/tests/swagger/swagger.json", "r", encoding="utf-8"
|
||||
) as expected_schema:
|
||||
assert response.json() == json.load(expected_schema)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user