Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64150da81a | |||
| 6c0acd0596 | |||
| 625e98515b | |||
| 297007162a | |||
| 8fa947f24d | |||
| ee580ad258 | |||
| 155043e0eb | |||
| df03534fb1 | |||
| 714d8188f5 | |||
| 65b36c6116 | |||
| 52d8969602 |
@@ -31,6 +31,3 @@ db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
|
||||
# Frontend dependencies
|
||||
node_modules
|
||||
|
||||
@@ -11,11 +11,8 @@ on:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
|
||||
jobs:
|
||||
build-and-push-backend:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
@@ -26,7 +23,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/people-backend
|
||||
images: lasuite/people
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
@@ -38,67 +35,20 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
name: Build and push backend
|
||||
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 }}
|
||||
target: production
|
||||
push: true
|
||||
tags: app-${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/people-frontend
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: .github/workflows/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
name: Build and push frontend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
target: frontend
|
||||
push: true
|
||||
tags: frontend-${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: .github/workflows/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
|
||||
|
||||
@@ -58,9 +58,11 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install-front:
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/frontend/
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -69,85 +71,42 @@ jobs:
|
||||
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') }}
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: src/frontend/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Build CI App
|
||||
run: cd src/frontend/ && yarn ci:build
|
||||
|
||||
- name: Cache build frontend
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Test App
|
||||
run: cd src/frontend/ && yarn app:test
|
||||
run: yarn app:test
|
||||
|
||||
- name: Test Translations
|
||||
run: yarn i18n:test
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/frontend/
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
node-version: '18.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: src/frontend/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
run: yarn lint
|
||||
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-mails, build-front]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -156,28 +115,7 @@ jobs:
|
||||
- name: Set services env variables
|
||||
run: |
|
||||
make create-env-files
|
||||
cat env.d/development/common.e2e.dist >> env.d/development/common
|
||||
|
||||
- name: Download mails' templates
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: 'src/frontend/**/node_modules'
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Restore the build cache
|
||||
uses: actions/cache@v4
|
||||
id: cache-build
|
||||
with:
|
||||
path: src/frontend/apps/desk/out/
|
||||
key: build-front-${{ github.run_id }}
|
||||
|
||||
- name: Build and Start Docker Servers
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
@@ -186,17 +124,26 @@ jobs:
|
||||
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
|
||||
make run
|
||||
|
||||
- name: Apply DRF migrations
|
||||
- name: Apply DRH migrations
|
||||
run: |
|
||||
make migrate
|
||||
|
||||
- name: Add dummy data
|
||||
run: |
|
||||
make demo FLUSH_ARGS='--no-input'
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: src/frontend/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install
|
||||
|
||||
- name: Build CI App
|
||||
run: cd src/frontend/ && yarn ci:build
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test
|
||||
|
||||
@@ -225,11 +172,6 @@ jobs:
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build mails
|
||||
run: yarn build
|
||||
- name: Persist mails' templates
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -254,7 +196,6 @@ jobs:
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
@@ -275,7 +216,7 @@ jobs:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
DJANGO_SETTINGS_MODULE: people.settings
|
||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||
DJANGO_JWT_PRIVATE_SIGNING_KEY: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DB_HOST: localhost
|
||||
DB_NAME: people
|
||||
DB_USER: dinum
|
||||
@@ -289,11 +230,6 @@ jobs:
|
||||
run: |
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
- name: Download mails' templates
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mails-templates
|
||||
path: src/backend/core/templates/mail
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
SOPS_PRIVATE=ENC[AES256_GCM,data:FAgeMw3bbPVNZEuyuJ4rMeknOH4UZ1vC7k3S0qe6aaeGG3HHfEIpWHckCjHLDTz6hNDUxWSr/WPytntbGjqcvzKwYZBeehGx9hY=,iv:O64EPSvyYSxjHOIIrdjMQYIJR9HW2CxE+6Ay0e0dvoc=,tag:64G0A4kIJ8AzNx3+CluW5Q==,type:str]
|
||||
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:IlmqbPlM6s9paCAxn40v4LNEGbzYlm9SVplXfP8PI8Ei/K5oGtLPquoDokK33ySEfEh5b5xLAZjlmDyICdv89ETdd7RLB/63f2LduMn8n0A=,iv:gdcCrv58wkLC6wufFvHGrHB/A6aLXAApJ036BYjlN8g=,tag:wReGIj4izqzBHY7X0rqOAg==,type:str]
|
||||
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:bepqdi4eaMM=,iv:nF7FSUiuRu4kTYeEQB3cN/Fwrpt0fB0dC/dm4poec7w=,tag:kbxhwe1SQYkaddKyY1UQDg==,type:str]
|
||||
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:z8ZkbbR9,iv:Uc2aGQ9dAkiUcTpBqcIqD2fXt4+/ObJJz/3D8BhN9/A=,tag:ZDqDpsD7IN7iyFvKmxoI2w==,type:str]
|
||||
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:3E/4G2zOBNZqkGc=,iv:zbQzrgLYgNp99zRUZ3ICG47OkLPV1HnhcNoIu3cSmpc=,tag:CEvKG2HVd2ijjGfzkM005Q==,type:str]
|
||||
DOCKER_HUB_USER=ENC[AES256_GCM,data:Qc/HR/O8nw==,iv:nvXUmLHgNFQwm6aIpBcPvOdITOkw1YT7hIyk4ptuOOw=,tag:qoqSA9NlGCbor+7OyGArfw==,type:str]
|
||||
ARGOCD_WEBHOOK_URL=ENC[AES256_GCM,data:WhW6TQQV/IQteW/jQukjlTaTLeNUzEzadp+HJEC7bqrWR88ZBUlk8sfF5KV7PAAAZtvMhe4mi/yl,iv:CInpRuJpKFpxozdzHQkfDxbhQYyy+VByzrDr2ZSmJ7E=,tag:RNmIoYtA5DSi1W7TDsJHxw==,type:str]
|
||||
ARGOCD_WEBHOOK_SECRET=ENC[AES256_GCM,data:KTT5ubYdm5oVzXL0sM9HdNObVKhSD1spB3KYgM6CskEpQutb3R4Lu5mImft9C9tRKIkZetxV9YyLawCPaZm5P1U=,iv:Xp+xE43a6/GODhhujnSrTfcQJ2P9u4YrRakmxLwEtEc=,tag:jYAk+4X7x44edb0vcbnKWA==,type:str]
|
||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHaHpGT0lLR21qRi8wZCt2\nc0t5NGlDR29lK2JscE4rc3VZOU5ic3FBdkhnCnJYdDdvMlBROWhPc2pjbDJTbmo0\ncENxV0hDYzZKSFh5RTNnUTRyaUdGLzgKLS0tIENIeDQyQ1NzMkF6cTZVRmU1VC9D\nNy9BTElMQlBEUWhCQWxpdVI1enJNblEKbGEkN7JPlj+OqBHYhZhZwfjpdxGlkjti\nBPsDHIklHYufk9+TjnxBYL1Oe6zLn4zlG0r/w8AHrTo6oke5dUjvKg==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
SOPS_PRIVATE=ENC[AES256_GCM,data:uMsnbpi1FFxO430iptp2axlH/souCPCJ/afCqh/kIDWs8xWHu0xJ2o6PlNOgb4l1gHF8sy4eSHFOW5HPKVbZ9gafRIL0JYHJr3A=,iv:IgaHDad7IuW2wFoxGALLDCs+UiSd3rEYwGNSX38wUfI=,tag:zs4wn4hDm5fghfI/7iH6CQ==,type:str]
|
||||
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:tZRGFs792GjKYGit+oNubCwPbjWarhlgcyQ7oStO8K3nao0lEzLrm3+ARaOEXkzEkKSal2N1c+vlYIjRyzV1X7WZ7nZQWBiihEJgAfF3NGM=,iv:hohak/1niu5kV/W4XA0KEE3UB0BuNCDxbRxb0O+Aocs=,tag:Ssi0HyEhCKb9+WFyp3/yBQ==,type:str]
|
||||
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:m44KrW5xJDE=,iv:bSyKrKQ0Z1yzrNaejAUyD+n1Z9z+ci92GoyS5KiiFKI=,tag:dDtpbn+6LkPwsg+qgMNWvA==,type:str]
|
||||
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:Gonh6ps7,iv:yUqPty+R060m6CYrDf7na2f6h0aRpIhRCXZoJW4ThJg=,tag:ewupoenajsc9o0Aj0r/niw==,type:str]
|
||||
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:CBjUvS/UQcqdEv8=,iv:zJKGyyRnq+zxhKkYS5h3zw/J1320zrAqpiObcwiHWsA=,tag:OoQNe+4K63KXTWNsvjRZNQ==,type:str]
|
||||
DOCKER_HUB_USER=ENC[AES256_GCM,data:ivs7oeBBdA==,iv:cKAQJ071XhcRuFyeHhZkscvvz7sHrKIPJg5pt76hCXg=,tag:2VRmp1ULWvVH8NHcBifBGA==,type:str]
|
||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIbkIyRi9jTGEzL2RZcGFS\nVkhuN2xMN1FBcm04eGJQUnpvQVRpdmFmaFF3CjdoNVhSM0lrb0lTSXAyUytSKzhG\nNERYaC80OThmUUxCSmt2U2Q0Mno3b0kKLS0tIFRjQzYyOEcwTnRScGhzaGxwaGFL\namxEUXNaeVhWUjYwbzR2Z1Uzb3JsTlEK6tyhbRmcUP4Aql49DPkrYb5tbwvK2EdA\ntvyPQo4pPit+pzgqsVgW+O8Wo4/rYLlITfuVRrOfHEaH3wmf6hziSw==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_0__map_recipient=age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
|
||||
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArOG1XL1NFc29xVWFFdW9Q\nM0FrY3BEazJRRXNHMjVpS2hCVVBNSllUU1dJCkRIT00xSVAzeEozZndwTzlmY1Bn\nc3VJYmQrVzRXZTF1TjR4QXQrYXpIaG8KLS0tIEtXNTkvb2tRcjR0YWdneTI2dlBu\nY2tkYVU4UGtZdEIxQWMxQ1h6aHQ1RjQKpPQRQZtMDMkmSUhm7PQXpBHYK45Fm7Gx\nNL4gpLq1bJ6tNlnvP2zj2OOJ7NsiSeH8Vey6NON6E5wDKckBkpZUdg==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIL2NwVjVtVmd1ZE15WmFC\nVGl5cnU1OHVUcXAxWmZCQXBJbXBlcXFUN0RzClpOVXBTek9wbVB6M2s5TWlFbUo3\nelZrT3dsK3p6cEJGTGJoSTRvaEh4NW8KLS0tIGlsL29oSEthU28xV0RERFIxTnZR\nSSs3YjdUT0NyTEtlbjlmZXVOaTd3SzgK5jFJGREfJ/HVytWsCKWFsqM5JoaFSnhv\n538KzzldzcbtWfnY+bQ6A2EBjETwOzCTuQB8axAMj0URXPI+qelKIQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_1__map_recipient=age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
|
||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUUEM5ZzNST2RHTzRKUUxH\nbGxsOVoxTkVQWnFBa3lweExjSG5tZW02ZTBVCmhmLzRkbEVUUFNOZXdZRG5QUUJL\nL0lvMDArRHBGbmsrR1Y0NE9DcmtwbWcKLS0tIDljdmdGaTVVdnBxdjlYM0IrWTZ1\nYU03M1dHZm5nMDY0ZE9TSzl2Mk9YcG8KZ6vkG1KUIpS3BnIWcluA0nc5YX5AB3RH\nPJqFTdNwUfeB8omOyifF4jxU3Jqm53d3kVmiSPXTlaOYwqrhbT1oAw==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzOWtwSytMNGRmSHNQN0Zz\nV1RTWktXT1c5VjYvNTg2V0x1U3JSTFA1dmtvCnc4RHRodjVxSzcrZXJycCtJMGNy\nZ3ZwbTFDTUc4TTMxbm8rNXQ1ME90Q1UKLS0tIERPUmg1dSt5OWQwaXV0Y2FiYWdt\nVzlhcFNvUS9Nd3A5ZTYwT2lLUU5WVU0KGHSm1BQ3voKs98WiXNLO6hlqoiQmi1/F\n2RCBkE4/gOVyAmJAjOFizaF0Dhd7Ba4KS5l5QylFHloL8XtyixEhog==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_2__map_recipient=age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
|
||||
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0c2x0YVB4cW1jVFRZVlBP\nNFJiSHIrWk9HWGgreVRSZWhkd3cveDBDZ1RZCkQzYnFxMEhUb2s2U3drZzQwTW5D\nbU9iL0RoN2Ztb0M2UEluOUQ5M3RtMEEKLS0tIEhJRnZGekxWR3dRejV1SkNDQkda\nQkY5MGZyZW1QNzdveXFTbGd0WjRZaTAKbv+2BPThEjNn7yjK4aX4C7kvnXQgf1Qb\n6J72BKD8AtHOJVdp/d1+I41ejwXdfBbSKN5O6rnPozH60GDWFlQRwA==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmYlB6cEZKK3NLbm5Ob0hi\nS3M0OFoyTHhVYVA1WmcvSHc2MUErQ2JyaUh3Cnp1NlYvU1BPUnVON29UZWUvWmEz\nOW85N013VUJ3Q1ZZUmMvTWdYclBZTUkKLS0tIG1KODdkazhTUHRPMXZXQnJFNXJY\nOFlSNjZCbkpxcm9tN1dFTkZ4K1lETFUKhiPwKEG71OlTcK/Ul1GKGayLy025vAmo\nNgQUhbqXf7KmqDAmrQNzXTsLsOpRi33l66jFSGkTsFtiXNlmjFljKg==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_3__map_recipient=age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
|
||||
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4bzBsVUdlV1dzNVZoWGs2\nR3k0ZGh4cTlNTWdqNXlzc29KeTNTcmd6MHlZCi9wa29CNzcwWmtyT2U2bVArQXlU\nOGJXY3VaT2J5SEd1clY2WFVLa0s2a0EKLS0tIGlYZzRFT29ld2t6SU5tUUhIeUpG\nRnQwcTN0OFZJNGJicUNmeVE0eVcrU3MK96xiB4AkQSXLnP5XvVlQhvjQEQKhTZuO\nYbO1uP0EGSQ+f6uP3ENw4BFAgdB3y0YFcVm5VtTxN4Tpw3ZUVWtqew==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJUzI2eDB0MU41YWoxcjAv\nOU8yMlowb3IybCtzcFBBTEVlUzdBczZkQlFNCk5tbnhLR2kweGNFZmx1OFgwaWZU\nTzRKZTBZamJjRFNYOUJlYmxTUVg4S2MKLS0tIDNGdlVVc0hLK1BPTUJVc0tPb1lK\ndVVWVi9GRkxwYXR1cXMrdnlsejJBeGsKKmVWvIMrBYH+UrDMkZPxN8KWnCgA6WK2\nbexMYr2AIF2QMbhck7XW2NuvAwvwjbJMfcd+cp9boe+EcC4YjdJZlw==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_4__map_recipient=age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
|
||||
sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoWkEzQlRLNVd3U3hrU2dh\nY0l1OGVlYjJNZEIzK1R5NWFCK1lnK2pMVnlBCksrR1JKZTFmdzJCekZkQStvejBL\nd05wejByZjdnN2xweVhBemNiSDUwUEUKLS0tIEJIVGYzbWNBN2hrNEtoY2xMSk9v\naWw1THRBbmpDbGc2eE1WV0VSVUtoNm8KyCP6BtJRNUfvlAiuzQ1xhpjm6YFUA66G\n/qk9WLm07qHfFD5r2dGQBksN61Nk+akF0BGa56qnAEJp63lP8u1xbg==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_5__map_recipient=age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
|
||||
sops_lastmodified=2024-03-26T10:02:48Z
|
||||
sops_mac=ENC[AES256_GCM,data:p7Br4MRgpr26vSS79QBwYRXQWdb20lbrSxOdcrj385EhDVaqZM9aaycf09FItuBrQDuNO3NVSoGnJvWhekwRXXJlxXDENmjP9YBnNt25WXCa2+PLQGzvGbZjo/KHky5zqwVV5XaJ++UuO7VJZgB6m5m7RPZEBCo4cwy7rvNeyEs=,iv:XaavC9VEDckO1QpsPjUwf5BpJzFcEdxycSZvuFX8D7A=,tag:S82ex6+X4MUkkR8m9UAgqA==,type:str]
|
||||
sops_lastmodified=2024-02-05T17:45:08Z
|
||||
sops_mac=ENC[AES256_GCM,data:WMsYYvGId0UlRhiw5C4TTpP/7oIIgWteGRtX7Nlo3qQwBc1LSUyN+7kblkXWD+nNG51B0xfl9E5QdXO3Ao2WskQZZEJqqkmt6wSw/ScSPcH56FoIqBhoGsPKyJOZkK0zojnCJQLniTDlISG0UiiJ4KHh8FJDWYbzAy2zFEm3Et8=,iv:zB2hyxnKiA3flsMQ7XFOT/fR9hamd6YWo3BNwce4oWM=,tag:uZ2Ia8sR5R8fmURtJ+PojA==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.8.1
|
||||
|
||||
@@ -79,4 +79,3 @@ db.sqlite3
|
||||
.vscode/
|
||||
*.iml
|
||||
.devcontainer/
|
||||
.tool-versions
|
||||
|
||||
@@ -5,11 +5,9 @@ creation_rules:
|
||||
# - Anthony Le-Courric key-id: age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv
|
||||
# - Antoine Lebaud key-id: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
|
||||
# - Marie Pupo Jeammet key-id: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
|
||||
# - argocd key-id: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
|
||||
- age:
|
||||
age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x,
|
||||
age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7,
|
||||
age1g5keveae6zhn059e7cxkjqdz4v3u47ypejv9ujld65nwh6d5pd9qfm0ecv,
|
||||
age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3,
|
||||
age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa,
|
||||
age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
|
||||
age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
|
||||
|
||||
@@ -11,44 +11,31 @@ RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
### ---- Front-end dependencies image ----
|
||||
FROM node:20 as frontend-deps
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
COPY ./src/frontend/package.json ./package.json
|
||||
COPY ./src/frontend/yarn.lock ./yarn.lock
|
||||
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
|
||||
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
|
||||
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
|
||||
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
FROM node:20 as frontend-builder
|
||||
FROM node:20 as front-builder
|
||||
|
||||
WORKDIR /builder
|
||||
# Copy frontend app sources
|
||||
COPY ./src/frontend /builder
|
||||
|
||||
COPY --from=frontend-deps /deps/node_modules ./node_modules
|
||||
COPY ./src/frontend .
|
||||
WORKDIR /builder/apps/desk
|
||||
|
||||
WORKDIR ./apps/desk
|
||||
|
||||
RUN yarn build
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY --from=frontend-builder \
|
||||
COPY --from=front-builder \
|
||||
/builder/apps/desk/out \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
|
||||
COPY ./docker/files/etc/nginx/conf.d /etc/nginx/conf.d:ro
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -147,7 +134,7 @@ WORKDIR /app
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core as backend-development
|
||||
FROM core as development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
@@ -172,10 +159,10 @@ ENV DB_HOST=postgresql \
|
||||
DB_PORT=5432
|
||||
|
||||
# Run django development server
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
CMD python manage.py runserver 0.0.0.0:8000
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
FROM core as production
|
||||
|
||||
ARG PEOPLE_STATIC_ROOT=/data/static
|
||||
|
||||
@@ -194,4 +181,4 @@ COPY --from=link-collector ${PEOPLE_STATIC_ROOT} ${PEOPLE_STATIC_ROOT}
|
||||
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
|
||||
|
||||
# The default command runs gunicorn WSGI server in people's main module
|
||||
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/people.py", "people.wsgi:application"]
|
||||
CMD gunicorn -c /usr/local/etc/gunicorn/people.py people.wsgi:application
|
||||
|
||||
@@ -92,8 +92,10 @@ bootstrap: \
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
build: ## build the dev and demo containers
|
||||
@$(COMPOSE) build app-dev
|
||||
@$(COMPOSE) build nginx
|
||||
@$(COMPOSE) build app
|
||||
.PHONY: build
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -204,10 +206,9 @@ 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)
|
||||
@$(MANAGE) flush
|
||||
@${MAKE} superuser
|
||||
.PHONY: resetdb
|
||||
|
||||
|
||||
@@ -69,22 +69,6 @@ You first need to create a superuser account:
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
### Run frontend
|
||||
|
||||
Run the front with:
|
||||
|
||||
```bash
|
||||
$ make run-front-desk
|
||||
```
|
||||
|
||||
Then access at
|
||||
[http://localhost:3000](http://localhost:3000)
|
||||
|
||||
user: people
|
||||
|
||||
password: people
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
|
||||
@@ -19,11 +19,11 @@ services:
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
target: backend-development
|
||||
target: development
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
image: people:development
|
||||
environment:
|
||||
- PYLINTHOME=/app/.pylint.d
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-development
|
||||
image: people:development
|
||||
command: ["celery", "-A", "people.celery_app", "worker", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
@@ -60,16 +60,19 @@ services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: backend-production
|
||||
target: production
|
||||
args:
|
||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
image: people:production
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
- DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:8088
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "8082:8000"
|
||||
volumes:
|
||||
- ./data/media:/data/media
|
||||
depends_on:
|
||||
@@ -78,7 +81,7 @@ services:
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: people:backend-production
|
||||
image: people:production
|
||||
command: ["celery", "-A", "people.celery_app", "worker", "-l", "INFO"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
@@ -89,8 +92,12 @@ services:
|
||||
- app
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
build:
|
||||
context: .
|
||||
target: frontend
|
||||
image: people:frontend
|
||||
ports:
|
||||
- "8088:8088"
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
@@ -164,7 +171,7 @@ services:
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: people
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
PROXY_ADDRESS_FORWARDING: true
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
|
||||
@@ -46,9 +46,6 @@
|
||||
"users": [
|
||||
{
|
||||
"username": "people",
|
||||
"email": "people@people.world",
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
@@ -59,43 +56,12 @@
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"username": "user-e2e",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-chromium"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-webkit"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "password-e2e-firefox"
|
||||
"value": "password-e2e"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
@@ -1110,7 +1076,7 @@
|
||||
},
|
||||
{
|
||||
"id": "79712bcf-b7f7-4ca3-b97c-418f48fded9b",
|
||||
"name": "first name",
|
||||
"name": "given name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
@@ -1119,7 +1085,7 @@
|
||||
"user.attribute": "firstName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "first_name",
|
||||
"claim.name": "given_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
@@ -1140,7 +1106,7 @@
|
||||
},
|
||||
{
|
||||
"id": "7f741e96-41fe-4021-bbfd-506e7eb94e69",
|
||||
"name": "last name",
|
||||
"name": "family name",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||
"consentRequired": false,
|
||||
@@ -1149,7 +1115,7 @@
|
||||
"user.attribute": "lastName",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "last_name",
|
||||
"claim.name": "family_name",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
server {
|
||||
listen 8088;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
server {
|
||||
listen 8083;
|
||||
@@ -11,3 +27,4 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# For the CI job test-e2e
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
@@ -8,4 +8,4 @@ DB_HOST=kc_postgresql
|
||||
DB_NAME=keycloak
|
||||
DB_USER=people
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5433
|
||||
DB_PORT=5433
|
||||
@@ -8,4 +8,4 @@ DB_HOST=postgresql
|
||||
DB_NAME=people
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5432
|
||||
DB_PORT=5432
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"packageRules": [
|
||||
{
|
||||
"enabled": false,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Admin classes and registrations for People's core app."""
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
@@ -83,15 +82,6 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
),
|
||||
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (IdentityInline, TeamAccessInline)
|
||||
list_display = (
|
||||
"email",
|
||||
@@ -120,45 +110,3 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(models.Invitation)
|
||||
class InvitationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface to handle invitations."""
|
||||
|
||||
fields = (
|
||||
"email",
|
||||
"team",
|
||||
"role",
|
||||
"created_at",
|
||||
"issuer",
|
||||
)
|
||||
readonly_fields = (
|
||||
"created_at",
|
||||
"is_expired",
|
||||
"issuer",
|
||||
)
|
||||
list_display = (
|
||||
"email",
|
||||
"team",
|
||||
"created_at",
|
||||
"is_expired",
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj:
|
||||
# all fields read only = disable update
|
||||
return self.fields
|
||||
return self.readonly_fields
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Custom edit form. Remove 'save' buttons."""
|
||||
extra_context = extra_context or {}
|
||||
extra_context["show_save_and_continue"] = False
|
||||
extra_context["show_save"] = False
|
||||
extra_context["show_save_and_add_another"] = False
|
||||
return super().change_view(request, object_id, extra_context=extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.issuer = request.user
|
||||
obj.save()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""People core API endpoints"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Permission handlers for the People core app."""
|
||||
|
||||
from django.core import exceptions
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Client serializers for the People core app."""
|
||||
|
||||
from rest_framework import exceptions, serializers
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
@@ -27,66 +26,28 @@ class ContactSerializer(serializers.ModelSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
A ModelSerializer that takes an additional `fields` argument that
|
||||
controls which fields should be displayed.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Don't pass the 'fields' arg up to the superclass
|
||||
fields = kwargs.pop("fields", None)
|
||||
|
||||
# Instantiate the superclass normally
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if fields is not None:
|
||||
# Drop any fields that are not specified in the `fields` argument.
|
||||
allowed = set(fields)
|
||||
existing = set(self.fields)
|
||||
for field_name in existing - allowed:
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class UserSerializer(DynamicFieldsModelSerializer):
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
data = serializers.SerializerMethodField(read_only=True)
|
||||
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
|
||||
name = serializers.SerializerMethodField(read_only=True)
|
||||
email = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"data",
|
||||
"language",
|
||||
"timezone",
|
||||
"is_device",
|
||||
"is_staff",
|
||||
]
|
||||
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
|
||||
read_only_fields = ["id", "email", "data", "is_device", "is_staff"]
|
||||
|
||||
def _get_main_identity_attr(self, obj, attribute_name):
|
||||
"""Return the specified attribute of the main identity."""
|
||||
try:
|
||||
return getattr(obj.main_identity[0], attribute_name)
|
||||
except TypeError:
|
||||
return getattr(obj.main_identity, attribute_name)
|
||||
except IndexError:
|
||||
main_identity = obj.identities.filter(is_main=True).first()
|
||||
return getattr(obj.main_identity, attribute_name) if main_identity else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
def get_name(self, obj):
|
||||
"""Return main identity's name."""
|
||||
return self._get_main_identity_attr(obj, "name")
|
||||
|
||||
def get_email(self, obj):
|
||||
"""Return main identity's email."""
|
||||
return self._get_main_identity_attr(obj, "email")
|
||||
def get_data(self, user) -> dict:
|
||||
"""Return contact data for the user."""
|
||||
return user.profile_contact.data if user.profile_contact else {}
|
||||
|
||||
|
||||
class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
@@ -165,52 +126,17 @@ class TeamAccessSerializer(serializers.ModelSerializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class TeamAccessReadOnlySerializer(TeamAccessSerializer):
|
||||
"""Serialize team accesses for list and retrieve actions."""
|
||||
|
||||
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
|
||||
|
||||
class Meta:
|
||||
model = models.TeamAccess
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"user",
|
||||
"role",
|
||||
"abilities",
|
||||
]
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
"""Serialize teams."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TeamAccessSerializer(many=True, read_only=True)
|
||||
slug = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Team
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"slug",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
fields = ["id", "name", "accesses", "abilities", "slug"]
|
||||
read_only_fields = ["id", "accesses", "abilities", "slug"]
|
||||
|
||||
def get_abilities(self, team) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
@@ -222,38 +148,3 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
def get_slug(self, instance):
|
||||
"""Return slug from the team's name."""
|
||||
return instance.get_slug()
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
|
||||
class Meta:
|
||||
model = models.Invitation
|
||||
fields = ["id", "created_at", "email", "team", "role", "issuer", "is_expired"]
|
||||
read_only_fields = ["id", "created_at", "team", "issuer", "is_expired"]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate and restrict invitation to new user based on email."""
|
||||
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
|
||||
try:
|
||||
team_id = self.context["team_id"]
|
||||
except KeyError as exc:
|
||||
raise exceptions.ValidationError(
|
||||
"You must set a team ID in kwargs to create a new team invitation."
|
||||
) from exc
|
||||
|
||||
if not models.TeamAccess.objects.filter(
|
||||
team=team_id,
|
||||
user=user,
|
||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied(
|
||||
"You are not allowed to manage invitation for this team."
|
||||
)
|
||||
|
||||
attrs["team_id"] = team_id
|
||||
attrs["issuer"] = user
|
||||
return attrs
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Utils that can be useful throughout the People core app
|
||||
"""
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
|
||||
def get_tokens_for_user(user):
|
||||
"""Get JWT tokens for user authentication."""
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return {
|
||||
"refresh": str(refresh),
|
||||
"access": str(refresh.access_token),
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
"""API endpoints"""
|
||||
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Func, Max, OuterRef, Q, Subquery, Value
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
filters,
|
||||
mixins,
|
||||
pagination,
|
||||
response,
|
||||
throttling,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
|
||||
from core import models
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
SIMILARITY_THRESHOLD = 0.04
|
||||
EMAIL_SIMILARITY_THRESHOLD = 0.01
|
||||
# TrigramSimilarity threshold is lower for searching email than for names,
|
||||
# to improve matching results
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
@@ -97,11 +96,12 @@ class SerializerPerActionMixin:
|
||||
class Pagination(pagination.PageNumberPagination):
|
||||
"""Pagination to display no more than 100 objects per page sorted by creation date."""
|
||||
|
||||
ordering = "-created_on"
|
||||
max_page_size = 100
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
|
||||
class BurstRateThrottle(throttling.UserRateThrottle):
|
||||
class BurstRateThrottle(UserRateThrottle):
|
||||
"""
|
||||
Throttle rate for minutes. See DRF section in settings for default value.
|
||||
"""
|
||||
@@ -109,7 +109,7 @@ class BurstRateThrottle(throttling.UserRateThrottle):
|
||||
scope = "burst"
|
||||
|
||||
|
||||
class SustainedRateThrottle(throttling.UserRateThrottle):
|
||||
class SustainedRateThrottle(UserRateThrottle):
|
||||
"""
|
||||
Throttle rate for hours. See DRF section in settings for default value.
|
||||
"""
|
||||
@@ -185,7 +185,7 @@ class UserViewSet(
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all().order_by("-created_at")
|
||||
queryset = models.User.objects.all().select_related("profile_contact")
|
||||
serializer_class = serializers.UserSerializer
|
||||
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
|
||||
pagination_class = Pagination
|
||||
@@ -198,37 +198,19 @@ class UserViewSet(
|
||||
# Exclude inactive contacts
|
||||
queryset = queryset.filter(
|
||||
is_active=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
"identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="main_identity",
|
||||
)
|
||||
)
|
||||
|
||||
# Exclude all users already in the given team
|
||||
if team_id := self.request.GET.get("team_id", ""):
|
||||
queryset = queryset.exclude(teams__id=team_id)
|
||||
|
||||
# Search by case-insensitive and accent-insensitive trigram similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
similarity = Max(
|
||||
TrigramSimilarity(
|
||||
Coalesce(
|
||||
Func("identities__email", function="unaccent"), Value("")
|
||||
),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
+ TrigramSimilarity(
|
||||
Coalesce(
|
||||
Func("identities__name", function="unaccent"), Value("")
|
||||
),
|
||||
Func("identities__email", function="unaccent"),
|
||||
Func(Value(query), function="unaccent"),
|
||||
)
|
||||
)
|
||||
queryset = (
|
||||
queryset.annotate(similarity=similarity)
|
||||
.filter(similarity__gte=SIMILARITY_THRESHOLD)
|
||||
.filter(similarity__gte=EMAIL_SIMILARITY_THRESHOLD)
|
||||
.order_by("-similarity")
|
||||
)
|
||||
|
||||
@@ -244,12 +226,9 @@ class UserViewSet(
|
||||
"""
|
||||
Return information on currently logged user
|
||||
"""
|
||||
user = request.user
|
||||
user.main_identity = models.Identity.objects.filter(
|
||||
user=user, is_main=True
|
||||
).first()
|
||||
context = {"request": request}
|
||||
return response.Response(
|
||||
self.serializer_class(user, context={"request": request}).data
|
||||
self.serializer_class(request.user, context=context).data
|
||||
)
|
||||
|
||||
|
||||
@@ -265,9 +244,6 @@ class TeamViewSet(
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
serializer_class = serializers.TeamSerializer
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["-created_at"]
|
||||
queryset = models.Team.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -324,15 +300,8 @@ class TeamAccessViewSet(
|
||||
lookup_field = "pk"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = (
|
||||
models.TeamAccess.objects.all().select_related("user").order_by("-created_at")
|
||||
)
|
||||
list_serializer_class = serializers.TeamAccessReadOnlySerializer
|
||||
detail_serializer_class = serializers.TeamAccessSerializer
|
||||
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering = ["role"]
|
||||
ordering_fields = ["role", "email", "name"]
|
||||
queryset = models.TeamAccess.objects.all().select_related("user")
|
||||
serializer_class = serializers.TeamAccessSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list team accesses"""
|
||||
@@ -349,45 +318,23 @@ class TeamAccessViewSet(
|
||||
context["team_id"] = self.kwargs["team_id"]
|
||||
return context
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in {"list", "retrieve"}:
|
||||
return self.list_serializer_class
|
||||
return self.detail_serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(team=self.kwargs["team_id"])
|
||||
|
||||
if self.action in {"list", "retrieve"}:
|
||||
# Determine which role the logged-in user has in the team
|
||||
if self.action == "list":
|
||||
# Limit to team access instances related to a team THAT also has a team access
|
||||
# instance for the logged-in user (we don't want to list only the team access
|
||||
# instances pointing to the logged-in user)
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=self.kwargs["team_id"]
|
||||
team__accesses__user=self.request.user
|
||||
).values("role")[:1]
|
||||
|
||||
user_main_identity_query = models.Identity.objects.filter(
|
||||
user=OuterRef("user_id"), is_main=True
|
||||
)
|
||||
|
||||
queryset = (
|
||||
# The logged-in user should be part of a team to see its accesses
|
||||
queryset.filter(
|
||||
team__accesses__user=self.request.user,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"user__identities",
|
||||
queryset=models.Identity.objects.filter(is_main=True),
|
||||
to_attr="main_identity",
|
||||
)
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each team access
|
||||
.annotate(
|
||||
user_role=Subquery(user_role_query),
|
||||
email=Subquery(user_main_identity_query.values("email")[:1]),
|
||||
name=Subquery(user_main_identity_query.values("name")[:1]),
|
||||
)
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
@@ -425,77 +372,3 @@ class TeamAccessViewSet(
|
||||
raise exceptions.ValidationError({"role": message})
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
class InvitationViewset(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to team.
|
||||
|
||||
GET /api/v1.0/teams/<team_id>/invitations/:<invitation_id>/
|
||||
Return list of invitations related to that team or or one
|
||||
team access if an id is provided.
|
||||
|
||||
POST /api/v1.0/teams/<team_id>/invitations/ with expected data:
|
||||
- email: str
|
||||
- role: str [owner|admin|member]
|
||||
- issuer : User, automatically added from user making query, if allowed
|
||||
- team : Team, automatically added from requested URI
|
||||
Return newly created invitation
|
||||
|
||||
PUT / PATCH : Not permitted. Instead of updating your invitation,
|
||||
delete and create a new one.
|
||||
|
||||
DELETE /api/v1.0/teams/<team_id>/invitations/<invitation_id>/
|
||||
Delete targeted invitation
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = (
|
||||
models.Invitation.objects.all().select_related("team").order_by("-created_at")
|
||||
)
|
||||
serializer_class = serializers.InvitationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list invitations"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
return super().get_permissions()
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extra context provided to the serializer class."""
|
||||
context = super().get_serializer_context()
|
||||
context["team_id"] = self.kwargs["team_id"]
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(team=self.kwargs["team_id"])
|
||||
|
||||
if self.action == "list":
|
||||
# Determine which role the logged-in user has in the team
|
||||
user_role_query = models.TeamAccess.objects.filter(
|
||||
user=self.request.user, team=self.kwargs["team_id"]
|
||||
).values("role")[:1]
|
||||
|
||||
queryset = (
|
||||
# The logged-in user should be part of a team to see its accesses
|
||||
queryset.filter(
|
||||
team__accesses__user=self.request.user,
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each team access
|
||||
.annotate(user_role=Subquery(user_role_query))
|
||||
.distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Authentication for the People core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -9,15 +9,18 @@ import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken
|
||||
|
||||
from .models import Identity
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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.
|
||||
in the User and Identity models, as well as the userinfo endpoint behavior in Agent Connect.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
@@ -31,9 +34,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
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.
|
||||
Note: Userinfo endpoint returns a signed JWT, and not 'application/json'. This
|
||||
constraint comes from Agent Connect. 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.
|
||||
@@ -67,39 +70,24 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
|
||||
# Compute user name from OIDC name fields as defined in settings
|
||||
names_list = [
|
||||
user_info[field]
|
||||
for field in settings.USER_OIDC_FIELDS_TO_NAME
|
||||
if user_info.get(field)
|
||||
]
|
||||
user_info["name"] = " ".join(names_list) or None
|
||||
|
||||
email = user_info.get("email")
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
raise InvalidToken(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = (
|
||||
self.UserModel.objects.filter(identities__sub=sub)
|
||||
.annotate(
|
||||
identity_email=models.F("identities__email"),
|
||||
identity_name=models.F("identities__name"),
|
||||
)
|
||||
.annotate(identity_email=models.F("identities__email"))
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
|
||||
if user:
|
||||
email = user_info.get("email")
|
||||
name = user_info.get("name")
|
||||
if (
|
||||
email
|
||||
and email != user.identity_email
|
||||
or name
|
||||
and name != user.identity_name
|
||||
):
|
||||
Identity.objects.filter(sub=sub).update(email=email, name=name)
|
||||
if email and email != user.identity_email:
|
||||
Identity.objects.filter(sub=sub).update(email=email)
|
||||
|
||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
@@ -108,15 +96,16 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
def create_user(self, claims):
|
||||
"""Return a newly created User instance."""
|
||||
|
||||
email = claims.get("email")
|
||||
sub = claims.get("sub")
|
||||
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
raise InvalidToken(
|
||||
_("Claims contained no recognizable user identification")
|
||||
)
|
||||
|
||||
user = self.UserModel.objects.create(password="!") # noqa: S106
|
||||
Identity.objects.create(
|
||||
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
|
||||
)
|
||||
user = self.UserModel.objects.create(password="!", email=email) # noqa: S106
|
||||
Identity.objects.create(user=user, sub=sub, email=email)
|
||||
|
||||
return user
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
@@ -124,7 +123,6 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
django_get_or_create = ("email",)
|
||||
|
||||
email = factory.Faker("email")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
@@ -141,7 +139,6 @@ class IdentityFactory(factory.django.DjangoModelFactory):
|
||||
user = factory.SubFactory(UserFactory)
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
name = factory.Faker("name")
|
||||
|
||||
|
||||
class TeamFactory(factory.django.DjangoModelFactory):
|
||||
@@ -150,20 +147,18 @@ class TeamFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Team
|
||||
django_get_or_create = ("name",)
|
||||
skip_postgeneration_save = True
|
||||
|
||||
name = factory.Sequence(lambda n: f"team{n}")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to team from a given list of users with or without roles."""
|
||||
if not create or not extracted:
|
||||
return
|
||||
for user_entry in extracted:
|
||||
if isinstance(user_entry, models.User):
|
||||
TeamAccessFactory(team=self, user=user_entry)
|
||||
else:
|
||||
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
if isinstance(item, models.User):
|
||||
TeamAccessFactory(team=self, user=item)
|
||||
else:
|
||||
TeamAccessFactory(team=self, user=item[0], role=item[1])
|
||||
|
||||
|
||||
class TeamAccessFactory(factory.django.DjangoModelFactory):
|
||||
@@ -175,15 +170,3 @@ class TeamAccessFactory(factory.django.DjangoModelFactory):
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
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
|
||||
|
||||
team = factory.SubFactory(TeamFactory)
|
||||
email = factory.Faker("email")
|
||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||
issuer = factory.SubFactory(UserFactory)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-05 17:09
|
||||
# Generated by Django 5.0.1 on 2024-02-06 15:08
|
||||
|
||||
import django.contrib.auth.models
|
||||
import core.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Team',
|
||||
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
|
||||
'db_table': 'people_user',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
('objects', core.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -95,7 +95,6 @@ class Migration(migrations.Migration):
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
|
||||
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
|
||||
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
@@ -106,23 +105,6 @@ class Migration(migrations.Migration):
|
||||
'ordering': ('-is_main', 'email'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invitation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='email address')),
|
||||
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Team invitation',
|
||||
'verbose_name_plural': 'Team invitations',
|
||||
'db_table': 'people_invitation',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamAccess',
|
||||
fields=[
|
||||
@@ -160,10 +142,6 @@ class Migration(migrations.Migration):
|
||||
model_name='identity',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
constraint=models.UniqueConstraint(fields=('email', 'team'), name='email_and_team_unique_together'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='teamaccess',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'team'), name='unique_team_user', violation_error_message='This user is already in this team.'),
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
"""
|
||||
Declare and configure the models for the People core application
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import smtplib
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
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.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import override
|
||||
|
||||
import jsonschema
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
contact_schema_path = os.path.join(current_dir, "jsonschema", "contact_data.json")
|
||||
with open(contact_schema_path, "r", encoding="utf-8") as contact_schema_file:
|
||||
@@ -154,6 +145,16 @@ class Contact(BaseModel):
|
||||
raise exceptions.ValidationError({"data": [error_message]}) from e
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""
|
||||
Override user manager to get the related contact in the same query by default (Contact model)
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Always select the related contact when doing a query on users."""
|
||||
return super().get_queryset().select_related("profile_contact")
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
@@ -197,7 +198,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
@@ -261,7 +262,6 @@ class Identity(BaseModel):
|
||||
validators=[sub_validator],
|
||||
)
|
||||
email = models.EmailField(_("email address"), null=True, blank=True)
|
||||
name = models.CharField(_("name"), max_length=100, null=True, blank=True)
|
||||
is_main = models.BooleanField(
|
||||
_("main"),
|
||||
default=False,
|
||||
@@ -286,49 +286,14 @@ class Identity(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
main_str = "[main]" if self.is_main else ""
|
||||
id_str = self.email or self.sub
|
||||
return f"{id_str:s}{main_str:s}"
|
||||
return f"{self.email:s}{main_str:s}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Saves identity, ensuring users always have exactly one main identity.
|
||||
If it's a new identity, give its user access to the relevant teams.
|
||||
"""
|
||||
|
||||
if self._state.adding:
|
||||
self._convert_valid_invitations()
|
||||
|
||||
"""Ensure users always have one and only one main identity."""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Ensure users always have one and only one main identity.
|
||||
if self.is_main is True:
|
||||
self.user.identities.exclude(id=self.id).update(is_main=False)
|
||||
|
||||
def _convert_valid_invitations(self):
|
||||
"""
|
||||
Convert valid invitations to team 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("team")
|
||||
|
||||
if not valid_invitations.exists():
|
||||
return
|
||||
|
||||
TeamAccess.objects.bulk_create(
|
||||
[
|
||||
TeamAccess(user=self.user, team=invitation.team, role=invitation.role)
|
||||
for invitation in valid_invitations
|
||||
]
|
||||
)
|
||||
valid_invitations.delete()
|
||||
|
||||
def clean(self):
|
||||
"""Normalize the email field and clean the 'is_main' field."""
|
||||
if self.email:
|
||||
@@ -395,7 +360,7 @@ class Team(BaseModel):
|
||||
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
|
||||
|
||||
return {
|
||||
"get": bool(role),
|
||||
"get": True,
|
||||
"patch": is_owner_or_admin,
|
||||
"put": is_owner_or_admin,
|
||||
"delete": role == RoleChoices.OWNER,
|
||||
@@ -483,108 +448,3 @@ class TeamAccess(BaseModel):
|
||||
"put": bool(set_role_to),
|
||||
"set_role_to": set_role_to,
|
||||
}
|
||||
|
||||
|
||||
class Invitation(BaseModel):
|
||||
"""User invitation to teams."""
|
||||
|
||||
email = models.EmailField(_("email address"), null=False, blank=False)
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
issuer = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "people_invitation"
|
||||
verbose_name = _("Team invitation")
|
||||
verbose_name_plural = _("Team invitations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["email", "team"], name="email_and_team_unique_together"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} invited to {self.team}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Make invitations read-only."""
|
||||
if self.created_at:
|
||||
raise exceptions.PermissionDenied()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
self.email_invitation()
|
||||
|
||||
def clean(self):
|
||||
"""Validate fields."""
|
||||
super().clean()
|
||||
|
||||
# Check if an identity already exists for the provided email
|
||||
if Identity.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
|
||||
role = None
|
||||
|
||||
if user.is_authenticated:
|
||||
try:
|
||||
role = self.user_role
|
||||
except AttributeError:
|
||||
try:
|
||||
role = self.team.accesses.filter(user=user).values("role")[0][
|
||||
"role"
|
||||
]
|
||||
except (self._meta.model.DoesNotExist, IndexError):
|
||||
role = None
|
||||
|
||||
can_delete = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
|
||||
|
||||
return {
|
||||
"delete": can_delete,
|
||||
"get": bool(role),
|
||||
"patch": False,
|
||||
"put": False,
|
||||
}
|
||||
|
||||
def email_invitation(self):
|
||||
"""Email invitation to the user."""
|
||||
try:
|
||||
with override(self.issuer.language):
|
||||
template_vars = {
|
||||
"title": _("Invitation to join Desk!"),
|
||||
}
|
||||
msg_html = render_to_string("mail/html/invitation.html", template_vars)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
|
||||
mail.send_mail(
|
||||
_("Invitation to join Desk!"),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[self.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("invitation to %s was not sent: %s", self.email, exception)
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -1,58 +0,0 @@
|
||||
"""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 ""
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Test suite for generated openapi schema.
|
||||
"""
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : create
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create team accesses."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"team": str(team.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TeamAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create team accesses for a team to
|
||||
which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_member():
|
||||
"""Members of a team should not be allowed to create team accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_administrator():
|
||||
"""
|
||||
Administrators of a team should be able to create team accesses except for the "owner" role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a team can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_owner():
|
||||
"""
|
||||
Owners of a team should be able to create team accesses whatever the role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : delete
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_member():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team in which they are a simple member.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_administrators():
|
||||
"""
|
||||
Users who are administrators in a team should be allowed to delete an access
|
||||
from the team provided it is not ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_except_owners():
|
||||
"""
|
||||
Users should be able to delete the team access of another user
|
||||
for a team of which they are owner provided it is not an owner access.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_for_owners():
|
||||
"""
|
||||
Users should not be allowed to delete the team access of another owner
|
||||
even for a team in which they are direct owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_last_owner():
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a team
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
@@ -1,285 +0,0 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list team accesses."""
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(2, team=team)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list team accesses for a team
|
||||
to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(3, team=team)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_related():
|
||||
"""
|
||||
Authenticated users should be able to list team accesses for a team
|
||||
to which they are related, with a given role.
|
||||
"""
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
|
||||
owner = factories.IdentityFactory(is_main=True)
|
||||
access1 = factories.TeamAccessFactory.create(
|
||||
team=team, user=owner.user, role="owner"
|
||||
)
|
||||
|
||||
administrator = factories.IdentityFactory(is_main=True)
|
||||
access2 = factories.TeamAccessFactory.create(
|
||||
team=team, user=administrator.user, role="administrator"
|
||||
)
|
||||
|
||||
# Ensure this user's role is different from other team members to test abilities' computation
|
||||
user_access = models.TeamAccess.objects.create(team=team, user=user, role="member")
|
||||
|
||||
# Grant other team accesses to the user, they should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 3
|
||||
assert sorted(response.json()["results"], key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": str(identity.email),
|
||||
"name": str(identity.name),
|
||||
},
|
||||
"role": str(user_access.role),
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": {
|
||||
"id": str(access1.user.id),
|
||||
"email": str(owner.email),
|
||||
"name": str(owner.name),
|
||||
},
|
||||
"role": str(access1.role),
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": {
|
||||
"id": str(access2.user.id),
|
||||
"email": str(administrator.email),
|
||||
"name": str(administrator.name),
|
||||
},
|
||||
"role": str(access2.role),
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_main_identity():
|
||||
"""
|
||||
Name and email should be returned from main identity only
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory(user=user, is_main=True)
|
||||
factories.IdentityFactory(user=user) # additional non-main identity
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
|
||||
# other team members should appear, with correct identity
|
||||
other_user = factories.UserFactory()
|
||||
other_main_identity = factories.IdentityFactory(is_main=True, user=other_user)
|
||||
factories.IdentityFactory(user=other_user)
|
||||
factories.TeamAccessFactory.create(team=team, user=other_user)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 2
|
||||
users_info = [
|
||||
(access["user"]["email"], access["user"]["name"])
|
||||
for access in response.json()["results"]
|
||||
]
|
||||
# user information should be returned from main identity
|
||||
assert sorted(users_info) == sorted(
|
||||
[
|
||||
(str(identity.email), str(identity.name)),
|
||||
(str(other_main_identity.email), str(other_main_identity.name)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_constant_numqueries(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
The number of queries should not depend on the amount of fetched accesses.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory(user=user, is_main=True)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
# Only 4 queries are needed to efficiently fetch team accesses,
|
||||
# related users and identities :
|
||||
# - query retrieving logged-in user for user_role annotation
|
||||
# - count from pagination
|
||||
# - query prefetching users' main identity
|
||||
# - distinct from viewset
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
# create 20 new team members
|
||||
for _ in range(20):
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
# num queries should still be 4
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_ordering():
|
||||
"""Team accesses can be ordered by "role"."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory(user=user, is_main=True)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user)
|
||||
|
||||
# create 20 new team members
|
||||
for _ in range(20):
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=role",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [team_access["role"] for team_access in response.json()["results"]]
|
||||
assert sorted(results) == results
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-role",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [team_access["role"] for team_access in response.json()["results"]]
|
||||
assert sorted(results, reverse=True) == results
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ordering_fields", ["name", "email"])
|
||||
def test_api_team_accesses_list_authenticated_ordering_user(ordering_fields):
|
||||
"""Team accesses can be ordered by user's fields "email" or "name"."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
factories.IdentityFactory(user=user, is_main=True)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
models.TeamAccess.objects.create(team=team, user=user)
|
||||
|
||||
# create 20 new team members
|
||||
for _ in range(20):
|
||||
extra_user = factories.IdentityFactory(is_main=True).user
|
||||
factories.TeamAccessFactory(team=team, user=extra_user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering={ordering_fields}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [
|
||||
team_access["user"][ordering_fields]
|
||||
for team_access in response.json()["results"]
|
||||
]
|
||||
assert sorted(results) == results
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-{ordering_fields}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 21
|
||||
|
||||
results = [
|
||||
team_access["user"][ordering_fields]
|
||||
for team_access in response.json()["results"]
|
||||
]
|
||||
assert sorted(results, reverse=True) == results
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a team access.
|
||||
"""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team access for
|
||||
a team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory(team=factories.TeamFactory())
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No TeamAccess matches the given query."}
|
||||
|
||||
# Accesses related to another team should be excluded even if the user is related to it
|
||||
for other_access in [
|
||||
factories.TeamAccessFactory(),
|
||||
factories.TeamAccessFactory(user=user),
|
||||
]:
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{other_access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No TeamAccess matches the given query."}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_related():
|
||||
"""
|
||||
A user who is related to a team should be allowed to retrieve the
|
||||
associated team user accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(access.id),
|
||||
"user": {
|
||||
"id": str(access.user.id),
|
||||
"email": str(identity.email),
|
||||
"name": str(identity.name),
|
||||
},
|
||||
"role": str(access.role),
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
"""
|
||||
Test for team accesses API endpoints in People's core app : update
|
||||
"""
|
||||
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team access for a team to which
|
||||
they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_member():
|
||||
"""Members of a team should not be allowed to update its accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_except_owner():
|
||||
"""
|
||||
A user who is an administrator in a team should be allowed to update a user
|
||||
access for this team, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(["administrator", "member"]),
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_from_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of an "owner" for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_to_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of another user to grant team ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
# We are not allowed or not really updating the role
|
||||
if field == "role" or new_data["role"] == old_values["role"]:
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_except_owner():
|
||||
"""
|
||||
A user who is an owner in a team should be allowed to update
|
||||
a user access for this team except for existing "owner" accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_for_owners():
|
||||
"""
|
||||
A user who is "owner" of a team should not be allowed to update
|
||||
an existing owner access for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_self():
|
||||
"""
|
||||
A user who is owner of a team should be allowed to update
|
||||
their own user access provided there are other owners in the team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "member"])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_201_CREATED,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_204_NO_CONTENT,
|
||||
@@ -45,7 +44,7 @@ def test_api_teams_delete_authenticated_unrelated():
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
assert models.Team.objects.count() == 1
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -27,10 +26,7 @@ def test_api_teams_list_anonymous():
|
||||
|
||||
|
||||
def test_api_teams_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list teams
|
||||
they are an owner/administrator/member of.
|
||||
"""
|
||||
"""Authenticated users should be able to list teams they are an owner/administrator/member of."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
@@ -123,61 +119,3 @@ def test_api_teams_list_authenticated_distinct():
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(team.id)
|
||||
|
||||
|
||||
def test_api_teams_order():
|
||||
"""
|
||||
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
team_ids.reverse()
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from newest to oldest"
|
||||
|
||||
|
||||
def test_api_teams_order_param():
|
||||
"""
|
||||
Test that the 'created_at' field is sorted in ascending order
|
||||
when the 'ordering' query parameter is set.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team_ids = [
|
||||
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/teams/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_team_ids = [team["id"] for team in response_data["results"]]
|
||||
|
||||
assert (
|
||||
response_team_ids == team_ids
|
||||
), "created_at values are not sorted from oldest to newest"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
|
||||
from rest_framework.test import APIClient
|
||||
@@ -38,7 +37,7 @@ def test_api_teams_retrieve_authenticated_unrelated():
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_api_teams_retrieve_authenticated_related():
|
||||
@@ -59,19 +58,28 @@ def test_api_teams_retrieve_authenticated_related():
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert sorted(response.json().pop("accesses")) == sorted(
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
|
||||
[
|
||||
str(access1.id),
|
||||
str(access2.id),
|
||||
]
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": str(user.id),
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["user"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"slug": team.slug,
|
||||
"abilities": team.get_abilities(user),
|
||||
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Tests for Teams API endpoint in People's core app: update
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
@@ -61,7 +60,7 @@ def test_api_teams_update_authenticated_unrelated():
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
@@ -122,10 +121,8 @@ def test_api_teams_update_authenticated_administrators():
|
||||
team.refresh_from_db()
|
||||
final_values = serializers.TeamSerializer(instance=team).data
|
||||
for key, value in final_values.items():
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
if key in ["id", "accesses"]: # pylint: disable=R1733
|
||||
assert value == initial_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > initial_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
assert value == new_values[key]
|
||||
@@ -156,10 +153,8 @@ def test_api_teams_update_authenticated_owners():
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
for key, value in team_values.items():
|
||||
if key in ["id", "accesses", "created_at"]:
|
||||
if key in ["id", "accesses"]:
|
||||
assert value == old_team_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_team_values[key]
|
||||
else:
|
||||
# name, slug and abilities successfully modified
|
||||
assert value == new_team_values[key]
|
||||
@@ -188,7 +183,7 @@ def test_api_teams_update_administrator_or_owner_of_another():
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json() == {"detail": "No Team matches the given query."}
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
team.refresh_from_db()
|
||||
team_values = serializers.TeamSerializer(instance=team).data
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Test contacts API endpoints in People's core app.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
"""
|
||||
Test team accesses API endpoints for users in People'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
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_accesses_list_anonymous():
|
||||
"""Anonymous users should not be allowed to list team accesses."""
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(2, team=team)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to list team accesses for a team
|
||||
to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
team = factories.TeamFactory()
|
||||
factories.TeamAccessFactory.create_batch(3, team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_list_authenticated_related():
|
||||
"""
|
||||
Authenticated users should be able to list team accesses for a team
|
||||
to which they are related, whatever their role in the team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
user_access = models.TeamAccess.objects.create(team=team, user=user) # random role
|
||||
access1, access2 = factories.TeamAccessFactory.create_batch(2, team=team)
|
||||
|
||||
# Accesses for other teams to which the user is related should not be listed either
|
||||
other_access = factories.TeamAccessFactory(user=user)
|
||||
factories.TeamAccessFactory(team=other_access.team)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 3
|
||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": str(user.id),
|
||||
"role": user_access.role,
|
||||
"abilities": user_access.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": str(access1.user.id),
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_anonymous():
|
||||
"""
|
||||
Anonymous users should not be allowed to retrieve a team access.
|
||||
"""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a team access for
|
||||
a team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
response = client.get(f"/api/v1.0/teams/{team.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 team should be excluded even if the user is related to it
|
||||
for access in [
|
||||
factories.TeamAccessFactory(),
|
||||
factories.TeamAccessFactory(user=user),
|
||||
]:
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_api_team_accesses_retrieve_authenticated_related():
|
||||
"""
|
||||
A user who is related to a team should be allowed to retrieve the
|
||||
associated team user accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[user])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(access.id),
|
||||
"user": str(access.user.id),
|
||||
"role": access.role,
|
||||
"abilities": access.get_abilities(user),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create team accesses."""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(user.id),
|
||||
"team": str(team.id),
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
assert models.TeamAccess.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to create team accesses for a team to
|
||||
which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
team = factories.TeamFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_member():
|
||||
"""Members of a team should not be allowed to create team accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
for role in [role[0] for role in models.RoleChoices.choices]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage accesses for this team."
|
||||
}
|
||||
|
||||
assert not models.TeamAccess.objects.filter(user=other_user).exists()
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_administrator():
|
||||
"""
|
||||
Administrators of a team should be able to create team accesses except for the "owner" role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": "owner",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Only owners of a team can assign other users as owners."
|
||||
}
|
||||
|
||||
# It should be allowed to create a lower access
|
||||
role = random.choice(
|
||||
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_create_authenticated_owner():
|
||||
"""
|
||||
Owners of a team should be able to create team accesses whatever the role.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/",
|
||||
{
|
||||
"user": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
|
||||
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
|
||||
assert response.json() == {
|
||||
"abilities": new_team_access.get_abilities(user),
|
||||
"id": str(new_team_access.id),
|
||||
"role": role,
|
||||
"user": str(other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_accesses_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
api_client = APIClient()
|
||||
for field, value in new_values.items():
|
||||
response = api_client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a team access for a team to which
|
||||
they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_authenticated_member():
|
||||
"""Members of a team should not be allowed to update its accesses."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
{**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_except_owner():
|
||||
"""
|
||||
A user who is an administrator in a team should be allowed to update a user
|
||||
access for this team, as long as they don't try to set the role to owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(["administrator", "member"]),
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_from_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of an "owner" for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_administrator_to_owner():
|
||||
"""
|
||||
A user who is an administrator in a team, should not be allowed to update
|
||||
the user access of another user to grant team ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
other_user = factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
user=other_user,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
# We are not allowed or not really updating the role
|
||||
if field == "role" or new_data["role"] == old_values["role"]:
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_except_owner():
|
||||
"""
|
||||
A user who is an owner in a team should be allowed to update
|
||||
a user access for this team except for existing "owner" accesses.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
factories.UserFactory()
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team,
|
||||
role=random.choice(["administrator", "member"]),
|
||||
)
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
new_data = {**old_values, field: value}
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data=new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
if (
|
||||
new_data["role"] == old_values["role"]
|
||||
): # we are not really updating the role
|
||||
assert response.status_code == 403
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
if field == "role":
|
||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
||||
else:
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_for_owners():
|
||||
"""
|
||||
A user who is "owner" of a team should not be allowed to update
|
||||
an existing owner access for this team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
|
||||
new_values = {
|
||||
"id": uuid4(),
|
||||
"user_id": factories.UserFactory().id,
|
||||
"role": random.choice(models.RoleChoices.choices)[0],
|
||||
}
|
||||
|
||||
for field, value in new_values.items():
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, field: value},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
updated_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
assert updated_values == old_values
|
||||
|
||||
|
||||
def test_api_team_accesses_update_owner_self():
|
||||
"""
|
||||
A user who is owner of a team should be allowed to update
|
||||
their own user access provided there are other owners in the team.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
old_values = serializers.TeamAccessSerializer(instance=access).data
|
||||
new_role = random.choice(["administrator", "member"])
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
access.refresh_from_db()
|
||||
assert access.role == "owner"
|
||||
|
||||
# Add another owner and it should now work
|
||||
factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
data={**old_values, "role": new_role},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access.refresh_from_db()
|
||||
assert access.role == new_role
|
||||
|
||||
|
||||
# Delete
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a team access."""
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team to which they are not related.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
access = factories.TeamAccessFactory()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_member():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a team access for a
|
||||
team in which they are a simple member.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "member")])
|
||||
access = factories.TeamAccessFactory(team=team)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_administrators():
|
||||
"""
|
||||
Users who are administrators in a team should be allowed to delete an access
|
||||
from the team provided it is not ownership.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_except_owners():
|
||||
"""
|
||||
Users should be able to delete the team access of another user
|
||||
for a team of which they are owner provided it is not an owner access.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(
|
||||
team=team, role=random.choice(["member", "administrator"])
|
||||
)
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_for_owners():
|
||||
"""
|
||||
Users should not be allowed to delete the team access of another owner
|
||||
even for a team in which they are direct owner.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory(users=[(user, "owner")])
|
||||
access = factories.TeamAccessFactory(team=team, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
assert models.TeamAccess.objects.filter(user=access.user).exists()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 2
|
||||
|
||||
|
||||
def test_api_team_accesses_delete_owners_last_owner():
|
||||
"""
|
||||
It should not be possible to delete the last owner access from a team
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
team = factories.TeamFactory()
|
||||
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
|
||||
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.TeamAccess.objects.count() == 1
|
||||
@@ -1,421 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Invitation model
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_team_invitations__create__anonymous():
|
||||
"""Anonymous users should not be able to create invitations."""
|
||||
team = factories.TeamFactory()
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_invitations__create__authenticated_outsider():
|
||||
"""Users outside of team should not be permitted to invite to team."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory()
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_team_invitations__create__privileged_members(role):
|
||||
"""Owners and administrators should be able to invite new members."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, role)])
|
||||
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
|
||||
def test_api_team_invitations__create__members():
|
||||
"""
|
||||
Members should not be able to invite new members.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, "member")])
|
||||
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build()
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {
|
||||
"detail": "You are not allowed to manage invitation for this team."
|
||||
}
|
||||
|
||||
|
||||
def test_api_team_invitations__create__issuer_and_team_automatically_added():
|
||||
"""Team and issuer fields should auto-complete."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, "owner")])
|
||||
|
||||
# Generate a random invitation
|
||||
invitation = factories.InvitationFactory.build()
|
||||
invitation_data = {"email": invitation.email, "role": invitation.role}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_data,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
# team and issuer automatically set
|
||||
assert response.json()["team"] == str(team.id)
|
||||
assert response.json()["issuer"] == str(identity.user.id)
|
||||
|
||||
|
||||
def test_api_team_invitations__create__cannot_duplicate_invitation():
|
||||
"""An email should not be invited multiple times to the same team."""
|
||||
existing_invitation = factories.InvitationFactory()
|
||||
team = existing_invitation.team
|
||||
|
||||
# Grant privileged role on the Team to the user
|
||||
identity = factories.IdentityFactory()
|
||||
factories.TeamAccessFactory(team=team, user=identity.user, role="administrator")
|
||||
|
||||
# Create a new invitation to the same team with the exact same email address
|
||||
duplicated_invitation = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build(email=existing_invitation.email)
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
duplicated_invitation,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["__all__"] == [
|
||||
"Team invitation with this Email address and Team already exists."
|
||||
]
|
||||
|
||||
|
||||
def test_api_team_invitations__create__cannot_invite_existing_users():
|
||||
"""
|
||||
Should not be able to invite already existing users.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
team = factories.TeamFactory(users=[(user, "administrator")])
|
||||
|
||||
existing_user = factories.IdentityFactory(is_main=True)
|
||||
|
||||
# Build an invitation to the email of an exising identity in the db
|
||||
invitation_values = serializers.InvitationSerializer(
|
||||
factories.InvitationFactory.build(email=existing_user.email, team=team)
|
||||
).data
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["email"] == [
|
||||
"This email is already associated to a registered user."
|
||||
]
|
||||
|
||||
|
||||
def test_api_team_invitations__list__anonymous_user():
|
||||
"""Anonymous users should not be able to list invitations."""
|
||||
team = factories.TeamFactory()
|
||||
response = APIClient().get(f"/api/v1.0/teams/{team.id}/invitations/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_team_invitations__list__authenticated():
|
||||
"""
|
||||
Authenticated user should be able to list invitations
|
||||
in teams they belong to, including from other issuers.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(identity.user, "administrator"), (other_user, "owner")]
|
||||
)
|
||||
invitation = factories.InvitationFactory(
|
||||
team=team, role="administrator", issuer=identity.user
|
||||
)
|
||||
other_invitations = factories.InvitationFactory.create_batch(
|
||||
2, team=team, role="member", issuer=other_user
|
||||
)
|
||||
|
||||
# invitations from other teams should not be listed
|
||||
other_team = factories.TeamFactory()
|
||||
factories.InvitationFactory.create_batch(2, team=other_team, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(i.id),
|
||||
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"email": str(i.email),
|
||||
"team": str(team.id),
|
||||
"role": i.role,
|
||||
"issuer": str(i.issuer.id),
|
||||
"is_expired": False,
|
||||
}
|
||||
for i in [invitation, *other_invitations]
|
||||
],
|
||||
key=lambda x: x["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_invitations__list__expired_invitations_still_listed(settings):
|
||||
"""
|
||||
Expired invitations are still listed.
|
||||
"""
|
||||
identity = factories.IdentityFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
team = factories.TeamFactory(
|
||||
users=[(identity.user, "administrator"), (other_user, "owner")]
|
||||
)
|
||||
|
||||
# override settings to accelerate validation expiration
|
||||
settings.INVITATION_VALIDITY_DURATION = 1 # second
|
||||
expired_invitation = factories.InvitationFactory(
|
||||
team=team,
|
||||
role="member",
|
||||
issuer=identity.user,
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["count"] == 1
|
||||
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(expired_invitation.id),
|
||||
"created_at": expired_invitation.created_at.isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"email": str(expired_invitation.email),
|
||||
"team": str(team.id),
|
||||
"role": expired_invitation.role,
|
||||
"issuer": str(expired_invitation.issuer.id),
|
||||
"is_expired": True,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def test_api_team_invitations__retrieve__anonymous_user():
|
||||
"""
|
||||
Anonymous user should not be able to retrieve invitations.
|
||||
"""
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_team_invitations__retrieve__unrelated_user():
|
||||
"""
|
||||
Authenticated unrelated users should not be able to retrieve invitations.
|
||||
"""
|
||||
user = factories.IdentityFactory(user=factories.UserFactory()).user
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_api_team_invitations__retrieve__team_member():
|
||||
"""
|
||||
Authenticated team members should be able to retrieve invitations
|
||||
whatever their role in the team.
|
||||
"""
|
||||
user = factories.IdentityFactory(user=factories.UserFactory()).user
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
factories.TeamAccessFactory(team=invitation.team, user=user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(invitation.id),
|
||||
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"email": invitation.email,
|
||||
"team": str(invitation.team.id),
|
||||
"role": str(invitation.role),
|
||||
"issuer": str(invitation.issuer.id),
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
["put", "patch"],
|
||||
)
|
||||
def test_api_team_invitations__update__forbidden(method):
|
||||
"""
|
||||
Update of invitations is currently forbidden.
|
||||
"""
|
||||
user = factories.IdentityFactory(user=factories.UserFactory()).user
|
||||
|
||||
invitation = factories.InvitationFactory()
|
||||
factories.TeamAccessFactory(team=invitation.team, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
if method == "put":
|
||||
response = client.put(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
if method == "patch":
|
||||
response = client.patch(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
|
||||
|
||||
|
||||
def test_api_team_invitations__delete__anonymous():
|
||||
"""Anonymous user should not be able to delete invitations."""
|
||||
invitation = factories.InvitationFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_team_invitations__delete__authenticated_outsider():
|
||||
"""Members outside of team should not cancel invitations."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory()
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||
def test_api_team_invitations__delete__privileged_members(role):
|
||||
"""Privileged member should be able to cancel invitation."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, role)])
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
|
||||
def test_api_team_invitations__delete__members():
|
||||
"""Member should not be able to cancel invitation."""
|
||||
identity = factories.IdentityFactory()
|
||||
|
||||
team = factories.TeamFactory(users=[(identity.user, "member")])
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(identity.user)
|
||||
response = client.delete(
|
||||
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert (
|
||||
response.json()["detail"]
|
||||
== "You do not have permission to perform this action."
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Test users API endpoints in the People core app.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -54,21 +53,15 @@ def test_api_users_authenticated_list_by_email():
|
||||
partial query on the email.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="john doe")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(
|
||||
email="david.bowman@work.com", name=None, is_main=True
|
||||
)
|
||||
nicole = factories.IdentityFactory(
|
||||
email="nicole_foole@work.com", name=None, is_main=True
|
||||
)
|
||||
frank = factories.IdentityFactory(
|
||||
email="frank_poole@work.com", name=None, is_main=True
|
||||
)
|
||||
factories.IdentityFactory(email="heywood_floyd@work.com", name=None, is_main=True)
|
||||
dave = factories.IdentityFactory(email="david.bowman@work.com")
|
||||
nicole = factories.IdentityFactory(email="nicole_foole@work.com")
|
||||
frank = factories.IdentityFactory(email="frank_poole@work.com")
|
||||
factories.IdentityFactory(email="heywood_floyd@work.com")
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
@@ -97,200 +90,24 @@ def test_api_users_authenticated_list_by_email():
|
||||
# Even with a low similarity threshold, query should match expected emails
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.user.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.user.is_device,
|
||||
"is_staff": nicole.user.is_staff,
|
||||
"language": nicole.user.language,
|
||||
"timezone": str(nicole.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.user.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.user.is_device,
|
||||
"is_staff": frank.user.is_staff,
|
||||
"language": frank.user.language,
|
||||
"timezone": str(frank.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_name():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
|
||||
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
|
||||
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
|
||||
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(dave.user.id)
|
||||
|
||||
# Partial query should work
|
||||
response = client.get("/api/v1.0/users/?q=fran")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids[0] == str(frank.user.id)
|
||||
|
||||
# Result that matches a trigram twice ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
# "Nicole Foole" matches twice on "ole"
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
|
||||
|
||||
# Even with a low similarity threshold, query should match expected user
|
||||
response = client.get("/api/v1.0/users/?q=ool")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(nicole.user.id),
|
||||
"email": nicole.email,
|
||||
"name": nicole.name,
|
||||
"is_device": nicole.user.is_device,
|
||||
"is_staff": nicole.user.is_staff,
|
||||
"language": nicole.user.language,
|
||||
"timezone": str(nicole.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(frank.user.id),
|
||||
"email": frank.email,
|
||||
"name": frank.name,
|
||||
"is_device": frank.user.is_device,
|
||||
"is_staff": frank.user.is_staff,
|
||||
"language": frank.user.language,
|
||||
"timezone": str(frank.user.timezone),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_by_name_and_email():
|
||||
"""
|
||||
Authenticated users should be able to search users with a case-insensitive and
|
||||
partial query on the name and email.
|
||||
"""
|
||||
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="john doe")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
nicole = factories.IdentityFactory(
|
||||
email="nicole_foole@work.com", name="nicole foole", is_main=True
|
||||
)
|
||||
frank = factories.IdentityFactory(
|
||||
email="oleg_poole@work.com", name=None, is_main=True
|
||||
)
|
||||
david = factories.IdentityFactory(email=None, name="david role", is_main=True)
|
||||
|
||||
# Result that matches a trigram in name and email ranks better than result that matches once
|
||||
response = client.get("/api/v1.0/users/?q=ole")
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
|
||||
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
|
||||
# "Oleg poole" matches twice on "ole" in her email
|
||||
# "David role" matches once on "ole" in his name
|
||||
assert user_ids == [str(nicole.user.id), str(frank.user.id), str(david.user.id)]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_exclude_users_already_in_team(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to search users
|
||||
but the result should exclude all users already in the given team.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="john doe")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
|
||||
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
|
||||
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
|
||||
mary = factories.IdentityFactory(name="mary poole", email=None, is_main=True)
|
||||
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
|
||||
factories.IdentityFactory(name="Andrei Smyslov", email=None, is_main=True)
|
||||
factories.TeamFactory.create_batch(10)
|
||||
|
||||
# Add Dave and Frank in the same team
|
||||
team = factories.TeamFactory(
|
||||
users=[
|
||||
(dave.user, models.RoleChoices.MEMBER),
|
||||
(frank.user, models.RoleChoices.MEMBER),
|
||||
]
|
||||
)
|
||||
factories.TeamFactory(users=[(nicole.user, models.RoleChoices.MEMBER)])
|
||||
|
||||
# Search user to add him/her to a team, we give a team id to the request
|
||||
# to exclude all users already in the team
|
||||
|
||||
# We can't find David Bowman because he is already a member of the given team
|
||||
# 2 queries are needed here:
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
with django_assert_num_queries(2):
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?q=bowman&team_id={team.id}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["results"] == []
|
||||
|
||||
# We can only find Nicole and Mary because Frank is already a member of the given team
|
||||
# 4 queries are needed here:
|
||||
# - user authenticated
|
||||
# - search user query
|
||||
# - User
|
||||
# - Identity
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?q=ool&team_id={team.id}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
user_ids = sorted([user["id"] for user in response.json()["results"]])
|
||||
assert user_ids == sorted([str(mary.user.id), str(nicole.user.id)])
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_multiple_identities_single_user():
|
||||
"""
|
||||
User with multiple identities should appear only once in results.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
factories.IdentityFactory(
|
||||
user=dave, email="dave.bowman@work.com", name="dave bowman"
|
||||
)
|
||||
factories.IdentityFactory(user=dave, email="dave.bowman@fun.fr", name="dave bowman")
|
||||
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
|
||||
factories.IdentityFactory(user=dave, email="david.bowman@fun.fr")
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
@@ -309,24 +126,18 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
|
||||
on their best matching identity.
|
||||
"""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="john doe")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory()
|
||||
dave_identity = factories.IdentityFactory(
|
||||
user=dave, email="dave.bowman@work.com", is_main=True, name="dave bowman"
|
||||
)
|
||||
factories.IdentityFactory(user=dave, email="babibou@ehehe.com", name="babihou")
|
||||
davina_identity = factories.IdentityFactory(
|
||||
user=factories.UserFactory(), email="davina.bowan@work.com", name="davina"
|
||||
)
|
||||
prue_identity = factories.IdentityFactory(
|
||||
user=factories.UserFactory(),
|
||||
email="prudence.crandall@work.com",
|
||||
name="prudence",
|
||||
)
|
||||
davina = factories.UserFactory()
|
||||
prudence = factories.UserFactory()
|
||||
factories.IdentityFactory(user=dave, email="david.bowman@work.com")
|
||||
factories.IdentityFactory(user=dave, email="babibou@ehehe.com")
|
||||
factories.IdentityFactory(user=davina, email="davina.bowan@work.com")
|
||||
factories.IdentityFactory(user=prudence, email="prudence.crandall@work.com")
|
||||
|
||||
# Full query should work
|
||||
response = client.get(
|
||||
@@ -335,48 +146,19 @@ def test_api_users_authenticated_list_multiple_identities_multiple_users():
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 3
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": str(dave.id),
|
||||
"email": dave_identity.email,
|
||||
"name": dave_identity.name,
|
||||
"is_device": dave_identity.user.is_device,
|
||||
"is_staff": dave_identity.user.is_staff,
|
||||
"language": dave_identity.user.language,
|
||||
"timezone": str(dave_identity.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(davina_identity.user.id),
|
||||
"email": davina_identity.email,
|
||||
"name": davina_identity.name,
|
||||
"is_device": davina_identity.user.is_device,
|
||||
"is_staff": davina_identity.user.is_staff,
|
||||
"language": davina_identity.user.language,
|
||||
"timezone": str(davina_identity.user.timezone),
|
||||
},
|
||||
{
|
||||
"id": str(prue_identity.user.id),
|
||||
"email": prue_identity.email,
|
||||
"name": prue_identity.name,
|
||||
"is_device": prue_identity.user.is_device,
|
||||
"is_staff": prue_identity.user.is_staff,
|
||||
"language": prue_identity.user.language,
|
||||
"timezone": str(prue_identity.user.timezone),
|
||||
},
|
||||
]
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(dave.id), str(davina.id), str(prudence.id)]
|
||||
|
||||
|
||||
def test_api_users_authenticated_list_uppercase_content():
|
||||
"""Upper case content should be found by lower case query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(
|
||||
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
|
||||
)
|
||||
dave = factories.IdentityFactory(email="DAVID.BOWMAN@INTENSEWORK.COM")
|
||||
|
||||
# Unaccented full address
|
||||
response = client.get(
|
||||
@@ -400,12 +182,12 @@ def test_api_users_authenticated_list_uppercase_content():
|
||||
def test_api_users_list_authenticated_capital_query():
|
||||
"""Upper case query should find lower case content."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="eva karl")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.IdentityFactory(email="david.bowman@work.com", name="david bowman")
|
||||
dave = factories.IdentityFactory(email="david.bowman@work.com")
|
||||
|
||||
# Full uppercase query
|
||||
response = client.get(
|
||||
@@ -429,14 +211,12 @@ def test_api_users_list_authenticated_capital_query():
|
||||
def test_api_contacts_list_authenticated_accented_query():
|
||||
"""Accented content should be found by unaccented query."""
|
||||
user = factories.UserFactory(email="tester@ministry.fr")
|
||||
factories.IdentityFactory(user=user, email=user.email, name="john doe")
|
||||
factories.IdentityFactory(user=user, email=user.email)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
helene = factories.IdentityFactory(
|
||||
email="helene.bowman@work.com", name="helene bowman"
|
||||
)
|
||||
helene = factories.IdentityFactory(email="helene.bowman@work.com")
|
||||
|
||||
# Accented full query
|
||||
response = client.get(
|
||||
@@ -498,58 +278,6 @@ def test_api_users_list_pagination(
|
||||
assert len(content["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", [1, 10, 99, 100])
|
||||
def test_api_users_list_pagination_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should work as expected."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for i in range(page_size):
|
||||
factories.UserFactory.create(email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == page_size + 1
|
||||
assert len(content["results"]) == page_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", [101, 200])
|
||||
def test_api_users_list_pagination_wrong_page_size(
|
||||
page_size,
|
||||
):
|
||||
"""Page's size on pagination should be limited to "max_page_size"."""
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for i in range(page_size):
|
||||
factories.UserFactory.create(email=f"user-{i}@people.com")
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/users/?page_size={page_size}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == page_size + 1
|
||||
|
||||
# Length should not exceed "max_page_size" default value
|
||||
assert len(content["results"]) == 100
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
"""Anonymous users should not be allowed to list users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
@@ -563,7 +291,7 @@ def test_api_users_retrieve_me_anonymous():
|
||||
|
||||
def test_api_users_retrieve_me_authenticated():
|
||||
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
|
||||
identity = factories.IdentityFactory(is_main=True)
|
||||
identity = factories.IdentityFactory()
|
||||
user = identity.user
|
||||
|
||||
client = APIClient()
|
||||
@@ -582,12 +310,12 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"name": str(identity.name),
|
||||
"email": str(identity.email),
|
||||
"email": str(user.email),
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_device": False,
|
||||
"is_staff": False,
|
||||
"data": user.profile_contact.data,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Unit tests for the `get_or_create_user` function."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
from core import models
|
||||
from core import factories, models
|
||||
from core.authentication import OIDCAuthenticationBackend
|
||||
from core.factories import IdentityFactory
|
||||
|
||||
@@ -21,7 +22,7 @@ def test_authentication_getter_existing_user_no_email(
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
# Create a user and its identity
|
||||
identity = IdentityFactory(name=None)
|
||||
identity = IdentityFactory()
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
@@ -34,7 +35,7 @@ def test_authentication_getter_existing_user_no_email(
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity.refresh_from_db()
|
||||
@@ -46,82 +47,45 @@ def test_authentication_getter_existing_user_with_email(
|
||||
):
|
||||
"""
|
||||
When the user's info contains an email and targets an existing user,
|
||||
it should update the email on the identity but not on the user.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
identity = IdentityFactory(name="John Doe")
|
||||
identity = IdentityFactory()
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
user_email = identity.user.email
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": identity.sub,
|
||||
"email": identity.email,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
return {"sub": identity.sub, "email": identity.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# Only 1 query because email and names have not changed
|
||||
# Only 1 query if the email has not changed
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert models.User.objects.get() == user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"first_name, last_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
("John", "Doe", "jack.duy@example.com"),
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the identity when they change.
|
||||
The email on the user should not be changed.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
|
||||
user_email = identity.user.email
|
||||
|
||||
# Create multiple identities for a user
|
||||
for _ in range(5):
|
||||
IdentityFactory(user=identity.user)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
new_email = "test@fooo.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": identity.sub,
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
return {"sub": identity.sub, "email": new_email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
# Additional update query if the email has changed
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity.refresh_from_db()
|
||||
assert identity.email == email
|
||||
assert identity.name == f"{first_name:s} {last_name:s}"
|
||||
assert identity.email == new_email
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
assert user == identity.user
|
||||
@@ -141,7 +105,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity = user.identities.get()
|
||||
@@ -156,28 +120,26 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
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.
|
||||
User's info contains an email, created user's email should be set.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "people@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
)
|
||||
|
||||
identity = user.identities.get()
|
||||
assert identity.sub == "123"
|
||||
assert identity.email == email
|
||||
assert identity.name == "John Doe"
|
||||
|
||||
assert user.email is None
|
||||
assert user.email == email
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -192,13 +154,11 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
|
||||
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",
|
||||
),
|
||||
with django_assert_num_queries(0), pytest.raises(
|
||||
InvalidToken, match="User info contained no recognizable user identification"
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
user = klass.get_or_create_user(
|
||||
access_token=AccessToken(), id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Unit tests for the Contact model
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Unit tests for the Identity model
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
@@ -71,19 +70,19 @@ def test_models_identities_is_main_switch():
|
||||
|
||||
|
||||
def test_models_identities_email_not_required():
|
||||
"""The 'email' field can be blank."""
|
||||
"""The "email" field can be blank."""
|
||||
user = factories.UserFactory()
|
||||
models.Identity.objects.create(user=user, sub="123", email=None)
|
||||
|
||||
|
||||
def test_models_identities_user_required():
|
||||
"""The 'user' field is required."""
|
||||
"""The "user" field is required."""
|
||||
with pytest.raises(models.User.DoesNotExist, match="Identity has no user."):
|
||||
models.Identity.objects.create(user=None, email="david@example.com")
|
||||
|
||||
|
||||
def test_models_identities_email_unique_same_user():
|
||||
"""The 'email' field should be unique for a given user."""
|
||||
"""The "email" field should be unique for a given user."""
|
||||
email = factories.IdentityFactory()
|
||||
|
||||
with pytest.raises(
|
||||
@@ -94,13 +93,13 @@ def test_models_identities_email_unique_same_user():
|
||||
|
||||
|
||||
def test_models_identities_email_unique_different_users():
|
||||
"""The 'email' field should not be unique among users."""
|
||||
"""The "email" field should not be unique among users."""
|
||||
email = factories.IdentityFactory()
|
||||
factories.IdentityFactory(email=email.email)
|
||||
|
||||
|
||||
def test_models_identities_email_normalization():
|
||||
"""The 'email' field should be automatically normalized upon saving."""
|
||||
"""The email field should be automatically normalized upon saving."""
|
||||
email = factories.IdentityFactory()
|
||||
email.email = "Thomas.Jefferson@Example.com"
|
||||
email.save()
|
||||
@@ -121,21 +120,21 @@ def test_models_identities_ordering():
|
||||
|
||||
|
||||
def test_models_identities_sub_null():
|
||||
"""The 'sub' field should not be null."""
|
||||
"""The "sub" field should not be null."""
|
||||
user = factories.UserFactory()
|
||||
with pytest.raises(ValidationError, match="This field cannot be null."):
|
||||
models.Identity.objects.create(user=user, sub=None)
|
||||
|
||||
|
||||
def test_models_identities_sub_blank():
|
||||
"""The 'sub' field should not be blank."""
|
||||
"""The "sub" field should not be blank."""
|
||||
user = factories.UserFactory()
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank."):
|
||||
models.Identity.objects.create(user=user, email="david@example.com", sub="")
|
||||
|
||||
|
||||
def test_models_identities_sub_unique():
|
||||
"""The 'sub' field should be unique."""
|
||||
"""The "sub" field should be unique."""
|
||||
user = factories.UserFactory()
|
||||
identity = factories.IdentityFactory()
|
||||
with pytest.raises(ValidationError, match="Identity with this Sub already exists."):
|
||||
@@ -143,7 +142,7 @@ def test_models_identities_sub_unique():
|
||||
|
||||
|
||||
def test_models_identities_sub_max_length():
|
||||
"""The 'sub' field should be 255 characters maximum."""
|
||||
"""The subfield should be 255 characters maximum."""
|
||||
factories.IdentityFactory(sub="a" * 255)
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.IdentityFactory(sub="a" * 256)
|
||||
@@ -155,13 +154,13 @@ def test_models_identities_sub_max_length():
|
||||
|
||||
|
||||
def test_models_identities_sub_special_characters():
|
||||
"""The 'sub' field should accept periods, dashes, +, @ and underscores."""
|
||||
"""The subfield should accept periods, dashes, +, @ and underscores."""
|
||||
identity = factories.IdentityFactory(sub="dave.bowman-1+2@hal_9000")
|
||||
assert identity.sub == "dave.bowman-1+2@hal_9000"
|
||||
|
||||
|
||||
def test_models_identities_sub_spaces():
|
||||
"""The 'sub' field should not accept spaces."""
|
||||
"""The subfield should not accept spaces."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
factories.IdentityFactory(sub="a b")
|
||||
|
||||
@@ -172,12 +171,12 @@ def test_models_identities_sub_spaces():
|
||||
|
||||
|
||||
def test_models_identities_sub_upper_case():
|
||||
"""The 'sub' field should accept upper case characters."""
|
||||
"""The subfield should accept upper case characters."""
|
||||
identity = factories.IdentityFactory(sub="John")
|
||||
assert identity.sub == "John"
|
||||
|
||||
|
||||
def test_models_identities_sub_ascii():
|
||||
"""The 'sub' field should accept non ASCII letters."""
|
||||
"""The subfield should accept non ASCII letters."""
|
||||
identity = factories.IdentityFactory(sub="rené")
|
||||
assert identity.sub == "rené"
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Invitation model
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
import time
|
||||
import uuid
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core import exceptions, mail
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
def test_models_invitations_readonly_after_create():
|
||||
"""Existing invitations should be readonly."""
|
||||
invitation = factories.InvitationFactory()
|
||||
with pytest.raises(exceptions.PermissionDenied):
|
||||
invitation.save()
|
||||
|
||||
|
||||
def test_models_invitations_email_no_empty_mail():
|
||||
"""The "email" field should not be empty."""
|
||||
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
|
||||
factories.InvitationFactory(email="")
|
||||
|
||||
|
||||
def test_models_invitations_email_no_null_mail():
|
||||
"""The "email" field is required."""
|
||||
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
|
||||
factories.InvitationFactory(email=None)
|
||||
|
||||
|
||||
def test_models_invitations_team_required():
|
||||
"""The "team" field is required."""
|
||||
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
|
||||
factories.InvitationFactory(team=None)
|
||||
|
||||
|
||||
def test_models_invitations_team_should_be_team_instance():
|
||||
"""The "team" field should be a team instance."""
|
||||
with pytest.raises(ValueError, match='Invitation.team" must be a "Team" instance'):
|
||||
factories.InvitationFactory(team="ee")
|
||||
|
||||
|
||||
def test_models_invitations_role_required():
|
||||
"""The "role" field is required."""
|
||||
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
|
||||
factories.InvitationFactory(role="")
|
||||
|
||||
|
||||
def test_models_invitations_role_among_choices():
|
||||
"""The "role" field should be a valid choice."""
|
||||
with pytest.raises(
|
||||
exceptions.ValidationError, match="Value 'boss' is not a valid choice"
|
||||
):
|
||||
factories.InvitationFactory(role="boss")
|
||||
|
||||
|
||||
def test_models_invitations__is_expired(settings):
|
||||
"""
|
||||
The 'is_expired' property should return False until validity duration
|
||||
is exceeded and True afterwards.
|
||||
"""
|
||||
expired_invitation = factories.InvitationFactory()
|
||||
assert expired_invitation.is_expired is False
|
||||
|
||||
settings.INVITATION_VALIDITY_DURATION = 1
|
||||
time.sleep(1)
|
||||
|
||||
assert expired_invitation.is_expired is True
|
||||
|
||||
|
||||
def test_models_invitation__new_user__convert_invitations_to_accesses():
|
||||
"""
|
||||
Upon creating a new identity, invitations linked to that email
|
||||
should be converted to accesses and then deleted.
|
||||
"""
|
||||
# Two invitations to the same mail but to different teams
|
||||
invitation_to_team1 = factories.InvitationFactory()
|
||||
invitation_to_team2 = factories.InvitationFactory(email=invitation_to_team1.email)
|
||||
|
||||
other_invitation = factories.InvitationFactory(
|
||||
team=invitation_to_team2.team
|
||||
) # another person invited to team2
|
||||
|
||||
new_identity = factories.IdentityFactory(
|
||||
is_main=True, email=invitation_to_team1.email
|
||||
)
|
||||
|
||||
# The invitation regarding
|
||||
assert models.TeamAccess.objects.filter(
|
||||
team=invitation_to_team1.team, user=new_identity.user
|
||||
).exists()
|
||||
assert models.TeamAccess.objects.filter(
|
||||
team=invitation_to_team2.team, user=new_identity.user
|
||||
).exists()
|
||||
assert not models.Invitation.objects.filter(
|
||||
team=invitation_to_team1.team, email=invitation_to_team1.email
|
||||
).exists() # invitation "consumed"
|
||||
assert not models.Invitation.objects.filter(
|
||||
team=invitation_to_team2.team, email=invitation_to_team2.email
|
||||
).exists() # invitation "consumed"
|
||||
assert models.Invitation.objects.filter(
|
||||
team=invitation_to_team2.team, email=other_invitation.email
|
||||
).exists() # the other invitation remains
|
||||
|
||||
|
||||
def test_models_invitation__new_user__filter_expired_invitations():
|
||||
"""
|
||||
Upon creating a new identity, valid invitations should be converted into accesses
|
||||
and expired invitations should remain unchanged.
|
||||
"""
|
||||
with freeze_time("2020-01-01"):
|
||||
expired_invitation = factories.InvitationFactory()
|
||||
user_email = expired_invitation.email
|
||||
valid_invitation = factories.InvitationFactory(email=user_email)
|
||||
|
||||
new_identity = factories.IdentityFactory(is_main=True, email=user_email)
|
||||
|
||||
# valid invitation should have granted access to the related team
|
||||
assert models.TeamAccess.objects.filter(
|
||||
team=valid_invitation.team, user=new_identity.user
|
||||
).exists()
|
||||
assert not models.Invitation.objects.filter(
|
||||
team=valid_invitation.team, email=user_email
|
||||
).exists()
|
||||
|
||||
# expired invitation should not have been consumed
|
||||
assert not models.TeamAccess.objects.filter(
|
||||
team=expired_invitation.team, user=new_identity.user
|
||||
).exists()
|
||||
assert models.Invitation.objects.filter(
|
||||
team=expired_invitation.team, email=user_email
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 8), (1, 11), (20, 11)])
|
||||
def test_models_invitation__new_user__user_creation_constant_num_queries(
|
||||
django_assert_num_queries, num_invitations, num_queries
|
||||
):
|
||||
"""
|
||||
The number of queries executed during user creation should not be proportional
|
||||
to the number of invitations being processed.
|
||||
"""
|
||||
user_email = fake.email()
|
||||
|
||||
if num_invitations != 0:
|
||||
for _ in range(0, num_invitations):
|
||||
factories.InvitationFactory(email=user_email, team=factories.TeamFactory())
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
# with no invitation, we skip an "if", resulting in 8 requests
|
||||
# otherwise, we should have 11 queries with any number of invitations
|
||||
with django_assert_num_queries(num_queries):
|
||||
models.Identity.objects.create(
|
||||
is_main=True,
|
||||
email=user_email,
|
||||
user=user,
|
||||
name="Prudence C.",
|
||||
sub=uuid.uuid4(),
|
||||
)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
def test_models_team_invitations_get_abilities_anonymous():
|
||||
"""Check abilities returned for an anonymous user."""
|
||||
access = factories.InvitationFactory()
|
||||
abilities = access.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": False,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_team_invitations_get_abilities_authenticated():
|
||||
"""Check abilities returned for an authenticated user."""
|
||||
access = factories.InvitationFactory()
|
||||
user = factories.UserFactory()
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": False,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
def test_models_team_invitations_get_abilities_privileged_member(role):
|
||||
"""Check abilities for a team member with a privileged role."""
|
||||
|
||||
pivileged_access = factories.TeamAccessFactory(role=role)
|
||||
team = pivileged_access.team
|
||||
|
||||
factories.TeamAccessFactory(team=team) # another one
|
||||
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
abilities = invitation.get_abilities(pivileged_access.user)
|
||||
|
||||
assert abilities == {
|
||||
"delete": True,
|
||||
"get": True,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_team_invitations_get_abilities_member():
|
||||
"""Check abilities for a team member with 'member' role."""
|
||||
|
||||
member_access = factories.TeamAccessFactory(role="member")
|
||||
team = member_access.team
|
||||
|
||||
factories.TeamAccessFactory(team=team) # another one
|
||||
|
||||
invitation = factories.InvitationFactory(team=team)
|
||||
abilities = invitation.get_abilities(member_access.user)
|
||||
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": True,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_team_invitations_email():
|
||||
"""Check email invitation during invitation creation."""
|
||||
|
||||
member_access = factories.TeamAccessFactory(role="member")
|
||||
team = member_access.team
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
factories.TeamAccessFactory(team=team)
|
||||
invitation = factories.InvitationFactory(team=team, email="john@people.com")
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
(email,) = mail.outbox
|
||||
|
||||
assert email.to == [invitation.email]
|
||||
assert email.subject == "Invitation to join Desk!"
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "Invitation to join Desk!" in email_content
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"django.core.mail.send_mail",
|
||||
side_effect=smtplib.SMTPException("Error SMTPException"),
|
||||
)
|
||||
@mock.patch.object(Logger, "error")
|
||||
def test_models_team_invitations_email_failed(mock_logger, _mock_send_mail):
|
||||
"""Check invitation behavior when an SMTP error occurs during invitation creation."""
|
||||
|
||||
member_access = factories.TeamAccessFactory(role="member")
|
||||
team = member_access.team
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
factories.TeamAccessFactory(team=team)
|
||||
|
||||
# No error should be raised
|
||||
invitation = factories.InvitationFactory(team=team, email="john@people.com")
|
||||
|
||||
# No email has been sent
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
# Logger should be called
|
||||
mock_logger.assert_called_once()
|
||||
|
||||
(
|
||||
_,
|
||||
email,
|
||||
exception,
|
||||
) = mock_logger.call_args.args
|
||||
|
||||
assert email == invitation.email
|
||||
assert isinstance(exception, smtplib.SMTPException)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Unit tests for the TeamAccess model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Unit tests for the Team model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -62,7 +61,7 @@ def test_models_teams_get_abilities_anonymous():
|
||||
abilities = team.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": False,
|
||||
"get": True,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
"manage_accesses": False,
|
||||
@@ -75,7 +74,7 @@ def test_models_teams_get_abilities_authenticated():
|
||||
abilities = team.get_abilities(factories.UserFactory())
|
||||
assert abilities == {
|
||||
"delete": False,
|
||||
"get": False,
|
||||
"get": True,
|
||||
"patch": False,
|
||||
"put": False,
|
||||
"manage_accesses": False,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Unit tests for the User model
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -36,13 +35,13 @@ def test_models_users_email_unique():
|
||||
with pytest.raises(
|
||||
ValidationError, match="User with this Email address already exists."
|
||||
):
|
||||
models.User.objects.create(email=user.email)
|
||||
factories.UserFactory(email=user.email)
|
||||
|
||||
|
||||
def test_models_users_email_several_null():
|
||||
"""Several users with a null value for the "email" field can co-exist."""
|
||||
factories.UserFactory(email=None)
|
||||
models.User.objects.create(email=None, password="foo.")
|
||||
factories.UserFactory(email=None)
|
||||
|
||||
assert models.User.objects.filter(email__isnull=True).count() == 2
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Test Throttle in People's app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Utils for tests in the People core application"""
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
|
||||
class OIDCToken(AccessToken):
|
||||
"""Set payload on token from user/contact/email"""
|
||||
|
||||
@classmethod
|
||||
def for_user(cls, user):
|
||||
"""Returns an authorization token for the given user for testing."""
|
||||
identity = user.identities.filter(is_main=True).first()
|
||||
|
||||
token = cls()
|
||||
token["first_name"] = (
|
||||
user.profile_contact.short_name if user.profile_contact else "David"
|
||||
)
|
||||
token["last_name"] = (
|
||||
" ".join(user.profile_contact.full_name.split()[1:])
|
||||
if user.profile_contact
|
||||
else "Bowman"
|
||||
)
|
||||
token["sub"] = str(identity.sub)
|
||||
token["email"] = user.email
|
||||
|
||||
return token
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tokens for People's core app."""
|
||||
from rest_framework_simplejwt.settings import api_settings
|
||||
from rest_framework_simplejwt.tokens import Token
|
||||
|
||||
|
||||
class BearerToken(Token):
|
||||
"""Bearer token as emitted by Keycloak OIDC for example."""
|
||||
|
||||
token_type = "Bearer" # noqa: S105
|
||||
lifetime = api_settings.ACCESS_TOKEN_LIFETIME
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Debug Urls to check the layout of emails"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
DebugViewHtml,
|
||||
DebugViewTxt,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"__debug__/mail/invitation_html",
|
||||
DebugViewHtml.as_view(),
|
||||
name="debug.mail.invitation_html",
|
||||
),
|
||||
path(
|
||||
"__debug__/mail/invitation_txt",
|
||||
DebugViewTxt.as_view(),
|
||||
name="debug.mail.invitation_txt",
|
||||
),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Debug Views to check the layout of emails"""
|
||||
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
|
||||
class DebugBaseView(TemplateView):
|
||||
"""Debug View to check the layout of emails"""
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Generates sample datas to have a valid debug email"""
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["title"] = "Development email preview"
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class DebugViewHtml(DebugBaseView):
|
||||
"""Debug View for HTML Email Layout"""
|
||||
|
||||
template_name = "mail/html/invitation.html"
|
||||
|
||||
|
||||
class DebugViewTxt(DebugBaseView):
|
||||
"""Debug View for Text Email Layout"""
|
||||
|
||||
template_name = "mail/text/invitation.txt"
|
||||
@@ -3,5 +3,6 @@
|
||||
NB_OBJECTS = {
|
||||
"users": 1000,
|
||||
"teams": 100,
|
||||
"max_identities_per_user": 3,
|
||||
"max_users_per_team": 100,
|
||||
}
|
||||
|
||||
@@ -11,14 +11,10 @@ from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
from demo import defaults
|
||||
|
||||
fake = Faker()
|
||||
|
||||
logger = logging.getLogger("people.commands.demo.create_demo")
|
||||
|
||||
|
||||
@@ -129,19 +125,15 @@ def create_demo(stdout):
|
||||
users_values = list(models.User.objects.values("id", "email"))
|
||||
for user_dict in users_values:
|
||||
for i in range(
|
||||
random.choices(range(5), weights=[5, 50, 30, 10, 5], k=1)[0]
|
||||
random.randint(0, defaults.NB_OBJECTS["max_identities_per_user"])
|
||||
):
|
||||
user_email = user_dict["email"]
|
||||
queue.push(
|
||||
models.Identity(
|
||||
user_id=user_dict["id"],
|
||||
sub=uuid4(),
|
||||
email=f"identity{i:d}{user_email:s}",
|
||||
is_main=(i == 0),
|
||||
# Leave 3% of emails and names empty
|
||||
email=f"identity{i:d}{user_email:s}"
|
||||
if random.random() < 0.97
|
||||
else None,
|
||||
name=fake.name() if random.random() < 0.97 else None,
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Management command overriding the "createsuperuser" command to allow creating users
|
||||
with their email and no username.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Test the `create_demo` management command"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import call_command
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-03-21 19:26+0000\n"
|
||||
"POT-Creation-Date: 2024-01-03 23:15+0000\n"
|
||||
"PO-Revision-Date: 2024-01-03 23:15\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
@@ -17,322 +17,175 @@ msgstr ""
|
||||
"X-Crowdin-File: backend.pot\n"
|
||||
"X-Crowdin-File-ID: 2\n"
|
||||
|
||||
#: core/admin.py:70
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:72
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:84
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:81
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:114
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:38
|
||||
#: core/models.py:30
|
||||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:31
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:40
|
||||
#: core/models.py:32
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:44
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:45
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:51
|
||||
msgid "created at"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:52
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:65
|
||||
#: core/models.py:57
|
||||
msgid "updated at"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:66
|
||||
#: core/models.py:58
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:89
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:90
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:95
|
||||
msgid "contact information"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:96
|
||||
msgid "A JSON object containing the contact information"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:110
|
||||
msgid "contact"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:119
|
||||
#: core/models.py:111
|
||||
msgid "contacts"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160 core/models.py:263 core/models.py:483
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:172
|
||||
#: core/models.py:173
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:174
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: core/models.py:180
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
#: core/models.py:183
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:185
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: core/models.py:188
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:190
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:193
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:195
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:207
|
||||
msgid "user"
|
||||
#: core/models.py:196
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:208
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:209
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:248
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
#: core/models.py:245
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:255
|
||||
#: core/models.py:252
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:257
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
#: core/models.py:254
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:264
|
||||
msgid "name"
|
||||
#: core/models.py:260
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:266
|
||||
#: core/models.py:262
|
||||
msgid "main"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:268
|
||||
#: core/models.py:264
|
||||
msgid "Designates whether the email is the main one."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:274
|
||||
#: core/models.py:270
|
||||
msgid "identity"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:275
|
||||
#: core/models.py:271
|
||||
msgid "identities"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:282
|
||||
#: core/models.py:278
|
||||
msgid "This email address is already declared for this user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:356
|
||||
#: core/models.py:323
|
||||
msgid "Team"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:357
|
||||
#: core/models.py:324
|
||||
msgid "Teams"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:417
|
||||
#: core/models.py:375
|
||||
msgid "Team/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:418
|
||||
#: core/models.py:376
|
||||
msgid "Team/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:423
|
||||
#: core/models.py:381
|
||||
msgid "This user is already in this team."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:500
|
||||
msgid "Team invitation"
|
||||
#: core/models.py:451
|
||||
msgid "Token contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:501
|
||||
msgid "Team invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:526
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:568 core/models.py:573
|
||||
msgid "Invitation to join Desk!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "Invitation to join a team"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:198
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "Welcome to"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:216
|
||||
#: core/templates/mail/text/invitation.txt:12
|
||||
msgid "Logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
"We are delighted to welcome you to our community on Equipes, your new "
|
||||
"companion to simplify the management of your groups efficiently, "
|
||||
"intuitively, and securely."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid ""
|
||||
"Our application is designed to help you organize, collaborate, and manage "
|
||||
"permissions."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:236
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "With Equipes, you will be able to:"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Create customized groups according to your specific needs."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Invite members of your team or community in just a few clicks."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid ""
|
||||
"Plan events, meetings, or activities effortlessly with our integrated "
|
||||
"calendar."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:240
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Share documents, photos, and important information securely."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:241
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
msgid ""
|
||||
"Facilitate exchanges and communication with our messaging and group "
|
||||
"discussion tools."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:252
|
||||
#: core/templates/mail/text/invitation.txt:23
|
||||
msgid "Visit Equipes"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:261
|
||||
#: core/templates/mail/text/invitation.txt:25
|
||||
msgid ""
|
||||
"We are confident that Equipes will help you increase efficiency and "
|
||||
"productivity while strengthening the bond among members."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:266
|
||||
#: core/templates/mail/text/invitation.txt:26
|
||||
msgid ""
|
||||
"Feel free to explore all the features of the application and share your "
|
||||
"feedback and suggestions with us. Your feedback is valuable to us and will "
|
||||
"enable us to continually improve our service."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:271
|
||||
#: core/templates/mail/text/invitation.txt:27
|
||||
msgid ""
|
||||
"Once again, welcome aboard! We are eager to accompany you on this group "
|
||||
"management adventure."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:278
|
||||
#: core/templates/mail/text/invitation.txt:29
|
||||
msgid "Sincerely,"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:279
|
||||
#: core/templates/mail/text/invitation.txt:31
|
||||
msgid "The La Suite Numérique Team"
|
||||
msgstr ""
|
||||
|
||||
#: people/settings.py:133
|
||||
#: people/settings.py:132
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: people/settings.py:134
|
||||
#: people/settings.py:133
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"""
|
||||
People's sandbox management script.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""API URL Configuration"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
@@ -22,12 +21,6 @@ team_related_router.register(
|
||||
basename="team_accesses",
|
||||
)
|
||||
|
||||
team_related_router.register(
|
||||
"invitations",
|
||||
viewsets.InvitationViewset,
|
||||
basename="invitations",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""People celery configuration file."""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
@@ -9,7 +9,6 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -71,7 +70,6 @@ class Base(Configuration):
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
SILENCED_SYSTEM_CHECKS = values.ListValue([])
|
||||
|
||||
# Application definition
|
||||
ROOT_URLCONF = "people.urls"
|
||||
@@ -230,18 +228,7 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"sustained": values.Value(
|
||||
default="150/hour",
|
||||
environ_name="SUSTAINED_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"burst": values.Value(
|
||||
default="20/minute",
|
||||
environ_name="BURST_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
"DEFAULT_THROTTLE_RATES": {"sustained": "150/hour", "burst": "20/minute"},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
@@ -267,16 +254,14 @@ class Base(Configuration):
|
||||
EMAIL_HOST_PASSWORD = values.Value(None)
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("do-not-reply@desk.beta.numerique.gouv.fr")
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
|
||||
CORS_ALLOWED_ORIGINS = values.ListValue([])
|
||||
CORS_ALLOWED_ORIGINS = values.ListValue([], environ_name="CORS_ALLOWED_ORIGINS")
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
||||
|
||||
# Sentry
|
||||
@@ -293,7 +278,6 @@ class Base(Configuration):
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours to match Agent Connect
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
@@ -308,8 +292,7 @@ class Base(Configuration):
|
||||
"people", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
OIDC_RP_CLIENT_SECRET = values.Value(
|
||||
environ_name="OIDC_RP_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
environ_name="OIDC_RP_CLIENT_SECRET", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_JWKS_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
|
||||
@@ -348,12 +331,6 @@ class Base(Configuration):
|
||||
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
|
||||
)
|
||||
|
||||
USER_OIDC_FIELDS_TO_NAME = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
environ_name="USER_OIDC_FIELDS_TO_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -434,7 +411,7 @@ class Development(Base):
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072"]
|
||||
DEBUG = True
|
||||
|
||||
SESSION_COOKIE_NAME = "people_sessionid"
|
||||
@@ -549,20 +526,6 @@ class Production(Base):
|
||||
AWS_STORAGE_BUCKET_NAME = values.Value("tf-default-people-media-storage")
|
||||
AWS_S3_REGION_NAME = values.Value()
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/1",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Feature(Production):
|
||||
"""
|
||||
|
||||
@@ -12,8 +12,6 @@ from drf_spectacular.views import (
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
|
||||
from debug import urls as debug_urls
|
||||
|
||||
from . import api_urls
|
||||
|
||||
API_VERSION = settings.API_VERSION
|
||||
@@ -27,7 +25,6 @@ if settings.DEBUG:
|
||||
urlpatterns
|
||||
+ staticfiles_urlpatterns()
|
||||
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
+ debug_urls.urlpatterns
|
||||
)
|
||||
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
|
||||
@@ -25,21 +25,20 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.34.75",
|
||||
"boto3==1.34.39",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.3.6",
|
||||
"django-configurations==2.5.1",
|
||||
"django-configurations==2.5",
|
||||
"django-cors-headers==4.3.1",
|
||||
"django-countries==7.6.1",
|
||||
"django-countries==7.5.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.0.3",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages==1.14.2",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.0.3",
|
||||
"djangorestframework==3.15.1",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.3.0",
|
||||
"django==5.0.2",
|
||||
"djangorestframework-simplejwt[crypto]==5.3.1",
|
||||
"djangorestframework==3.14.0",
|
||||
"drf_spectacular==0.27.1",
|
||||
"dockerflow==2024.1.0",
|
||||
"easy_thumbnails==2.8.5",
|
||||
"factory_boy==3.3.0",
|
||||
"gunicorn==21.2.0",
|
||||
@@ -48,10 +47,10 @@ dependencies = [
|
||||
"psycopg[binary]==3.1.18",
|
||||
"PyJWT==2.8.0",
|
||||
"requests==2.31.0",
|
||||
"sentry-sdk==1.44.0",
|
||||
"sentry-sdk==1.40.3",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.6.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"mozilla-django-oidc==4.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -63,21 +62,20 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.4.1",
|
||||
"drf-spectacular-sidecar==2024.2.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.23.0",
|
||||
"ipython==8.21.0",
|
||||
"pyfakefs==5.3.5",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.1.0",
|
||||
"pytest-cov==5.0.0",
|
||||
"pylint==3.0.3",
|
||||
"pytest-cov==4.1.0",
|
||||
"pytest-django==4.8.0",
|
||||
"pytest==8.1.1",
|
||||
"pytest==8.0.0",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.5.0",
|
||||
"responses==0.25.0",
|
||||
"ruff==0.3.5",
|
||||
"types-requests==2.31.0.20240402",
|
||||
"freezegun==1.4.0",
|
||||
"responses==0.24.1",
|
||||
"ruff==0.2.1",
|
||||
"types-requests==2.31.0.20240125",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
@@ -96,6 +94,7 @@ exclude = [
|
||||
"__pycache__",
|
||||
"*/migrations/*",
|
||||
]
|
||||
ignore= ["DJ001", "PLR2004"]
|
||||
line-length = 88
|
||||
|
||||
|
||||
@@ -116,13 +115,12 @@ select = [
|
||||
"SLF", # flake8-self
|
||||
"T20", # flake8-print
|
||||
]
|
||||
ignore= ["DJ001", "PLR2004"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
section-order = ["future","standard-library","django","third-party","people","first-party","local-folder"]
|
||||
sections = { people=["core"], django=["django"] }
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
[tool.ruff.per-file-ignores]
|
||||
"**/tests/*" = ["S", "SLF"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_API_URL=https://desk-staging.beta.numerique.gouv.fr/api/v1.0/
|
||||
NEXT_PUBLIC_API_URL=http://kubernetes-service-name-wip:8071/api/v1.0/
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ const config = {
|
||||
default: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-100': '#EDF5FA',
|
||||
'primary-150': '#E5EEFA',
|
||||
'info-150': '#E5EEFA',
|
||||
@@ -74,16 +72,10 @@ const config = {
|
||||
'forms-input': {
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
color: {
|
||||
error: 'var(--c--theme--colors--danger-500)',
|
||||
'error-hover': 'var(--c--theme--colors--danger-500)',
|
||||
'box-shadow-error-hover': 'var(--c--theme--colors--danger-500)',
|
||||
},
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
small: 'var(--c--theme--colors--primary-500)',
|
||||
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
|
||||
big: {
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
},
|
||||
@@ -91,8 +83,6 @@ const config = {
|
||||
},
|
||||
'forms-select': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color-disabled-hover':
|
||||
'var(--c--theme--colors--greyscale-200)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-select--border-radius)',
|
||||
focus: 'var(--c--components--forms-select--border-radius)',
|
||||
@@ -119,9 +109,6 @@ const config = {
|
||||
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
'background-color': '#ffffff',
|
||||
},
|
||||
button: {
|
||||
'border-radius': {
|
||||
active: 'var(--c--components--button--border-radius)',
|
||||
@@ -188,10 +175,8 @@ const config = {
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#f5f5fe',
|
||||
'primary-150': '#F4F4FD',
|
||||
'primary-200': '#ececfe',
|
||||
'primary-300': '#e3e3fd',
|
||||
'primary-400': '#cacafb',
|
||||
@@ -210,11 +195,10 @@ const config = {
|
||||
'secondary-700': '#3b2424',
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-text': '#303C4B',
|
||||
'greyscale-000': '#f6f6f6',
|
||||
'greyscale-100': '#eeeeee',
|
||||
'greyscale-200': '#e5e5e5',
|
||||
'greyscale-300': '#e1e1e1',
|
||||
'greyscale-000': '#cecece',
|
||||
'greyscale-100': '#f6f6f6',
|
||||
'greyscale-200': '#eeeeee',
|
||||
'greyscale-300': '#e5e5e5',
|
||||
'greyscale-400': '#dddddd',
|
||||
'greyscale-500': '#cecece',
|
||||
'greyscale-600': '#7b7b7b',
|
||||
@@ -274,97 +258,30 @@ const config = {
|
||||
'border-radius': '0',
|
||||
},
|
||||
button: {
|
||||
'medium-height': '48px',
|
||||
'border-radius': '4px',
|
||||
primary: {
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-hover': '#1212ff',
|
||||
'color-active': '#2323ff',
|
||||
},
|
||||
color: '#ffffff',
|
||||
'color-hover': '#ffffff',
|
||||
'color-active': '#ffffff',
|
||||
},
|
||||
'primary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-active': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
secondary: {
|
||||
background: {
|
||||
'color-hover': '#F6F6F6',
|
||||
'color-active': '#EDEDED',
|
||||
},
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'tertiary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
header: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
size: 'var(--c--theme--font--sizes--s)',
|
||||
},
|
||||
body: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-hover': '#F4F4FD',
|
||||
},
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
'border-radius': '2px',
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'border-radius': '0',
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-fileuploader': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-field': {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
'value-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
big: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius-hover': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'border-color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-switch': {
|
||||
'handle-border-radius': '2px',
|
||||
'rail-border-radius': '4px',
|
||||
},
|
||||
'forms-input': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-fileuploader': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
compiler: {
|
||||
// Enables the styled-components SWC transform
|
||||
styledComponents: true,
|
||||
},
|
||||
webpack(config) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
|
||||
@@ -15,40 +15,33 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openfun/cunningham-react": "2.7.0",
|
||||
"@tanstack/react-query": "5.28.9",
|
||||
"i18next": "23.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.4.4",
|
||||
"next": "14.1.4",
|
||||
"@openfun/cunningham-react": "2.4.0",
|
||||
"@tanstack/react-query": "5.18.1",
|
||||
"i18next": "23.7.16",
|
||||
"next": "14.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-aria-components": "1.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "14.1.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-i18next": "14.0.0",
|
||||
"styled-components": "6.1.8",
|
||||
"zustand": "4.5.2"
|
||||
"zustand": "4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.28.10",
|
||||
"@testing-library/jest-dom": "6.4.2",
|
||||
"@testing-library/react": "14.2.2",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@tanstack/react-query-devtools": "5.18.1",
|
||||
"@testing-library/jest-dom": "6.4.1",
|
||||
"@testing-library/react": "14.2.1",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/lodash": "4.17.0",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.2.73",
|
||||
"@types/react-dom": "*",
|
||||
"dotenv": "16.4.5",
|
||||
"@types/react": "18.2.53",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"dotenv": "16.4.1",
|
||||
"eslint-config-people": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.2.5",
|
||||
"stylelint": "16.3.1",
|
||||
"stylelint": "16.2.1",
|
||||
"stylelint-config-standard": "36.0.0",
|
||||
"stylelint-prettier": "5.0.0",
|
||||
"typescript": "*"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
interface IAPIError<T = unknown> {
|
||||
status: number;
|
||||
cause?: string[];
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export class APIError<T = unknown> extends Error implements IAPIError<T> {
|
||||
public status: IAPIError['status'];
|
||||
public cause?: IAPIError['cause'];
|
||||
public data?: IAPIError<T>['data'];
|
||||
|
||||
constructor(message: string, { status, cause, data }: IAPIError<T>) {
|
||||
super(message);
|
||||
|
||||
this.name = 'APIError';
|
||||
this.status = status;
|
||||
this.cause = cause;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { fetchAPI } from '@/api';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { useAuthStore } from '@/features/';
|
||||
|
||||
describe('fetchAPI', () => {
|
||||
beforeEach(() => {
|
||||
@@ -34,25 +34,10 @@ describe('fetchAPI', () => {
|
||||
});
|
||||
|
||||
it('logout if 401 response', async () => {
|
||||
const mockReplace = jest.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: {
|
||||
replace: mockReplace,
|
||||
},
|
||||
});
|
||||
|
||||
useAuthStore.setState({ userData: { email: 'test@test.com' } });
|
||||
|
||||
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 401);
|
||||
|
||||
await fetchAPI('some/url');
|
||||
|
||||
expect(useAuthStore.getState().userData).toBeUndefined();
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
'http://some.api.url/api/v1.0/authenticate/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,21 @@
|
||||
import { login, useAuthStore } from '@/core/auth';
|
||||
|
||||
/**
|
||||
* Retrieves the CSRF token from the document's cookies.
|
||||
*
|
||||
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
|
||||
*/
|
||||
function getCSRFToken() {
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.filter((cookie) => cookie.trim().startsWith('csrftoken='))
|
||||
.map((cookie) => cookie.split('=')[1])
|
||||
.pop();
|
||||
}
|
||||
import { useAuthStore } from '@/features/';
|
||||
|
||||
export const fetchAPI = async (input: string, init?: RequestInit) => {
|
||||
const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}${input}`;
|
||||
const { logout } = useAuthStore.getState();
|
||||
|
||||
const csrfToken = getCSRFToken();
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...init?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||
},
|
||||
});
|
||||
|
||||
// todo - handle 401, redirect to login screen
|
||||
// todo - please have a look to this documentation page https://mozilla-django-oidc.readthedocs.io/en/stable/xhr.html
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
// Fix - force re-logging the user, will be refactored
|
||||
login();
|
||||
}
|
||||
response.status === 401 && logout();
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from './APIError';
|
||||
export * from './fetchApi';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export const errorCauses = async (response: Response, data?: unknown) => {
|
||||
const errorsBody = (await response.json()) as Record<
|
||||
string,
|
||||
string | string[]
|
||||
> | null;
|
||||
|
||||
const causes = errorsBody
|
||||
? Object.entries(errorsBody)
|
||||
.map(([, value]) => value)
|
||||
.flat()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
cause: causes,
|
||||
data,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Box } from '@/components';
|
||||
import { HEADER_HEIGHT, Header, Menu } from '@/features/';
|
||||
|
||||
export default function InnerLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box as="main" $direction="column" $height="100vh" $css="overflow:hidden;">
|
||||
<Header />
|
||||
<Box $css="flex: 1;">
|
||||
<Menu />
|
||||
<Box
|
||||
$direction="column"
|
||||
$height={`calc(100vh - ${HEADER_HEIGHT})`}
|
||||
$width="100%"
|
||||
$css="overflow: auto;"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,16 @@ import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import Page from '../pages';
|
||||
import Page from '../page';
|
||||
|
||||
describe('Page', () => {
|
||||
it('checks Page rendering', () => {
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /Create a new team/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
|
||||
'Hello Desk !',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Contacts() {
|
||||
return <Box>Contacts</Box>;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Favorite() {
|
||||
return <Box>Favorite</Box>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import url('../cunningham/cunningham-style.css');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Groups() {
|
||||
return <Box>Groups</Box>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Help() {
|
||||
return <Box>Help</Box>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Auth } from '@/features/auth/Auth';
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import InnerLayout from './InnerLayout';
|
||||
import './globals.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { theme } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools />
|
||||
<CunninghamProvider theme={theme}>
|
||||
<Auth>
|
||||
<InnerLayout>{children}</InnerLayout>
|
||||
</Auth>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Teams } from '@/features';
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $direction="column" className="p-b">
|
||||
<h1>{t('Hello Desk !')}</h1>
|
||||
<Teams />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
export default function Recent() {
|
||||
return <Box>Recent</Box>;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,29 +0,0 @@
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_179_19260)">
|
||||
<path
|
||||
d="M12.6406 26.02C14.5606 26.06 16.3406 27.02 17.5406 28.7C19.0006 30.76 21.4206 32 24.0006 32C26.5806 32 29.0006 30.76 30.4606 28.68C31.6606 27 33.4406 26.04 35.3606 26C33.9206 23.56 28.1606 22 24.0006 22C19.8606 22 14.0806 23.56 12.6406 26.02Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8 26C11.32 26 14 23.32 14 20C14 16.68 11.32 14 8 14C4.68 14 2 16.68 2 20C2 23.32 4.68 26 8 26Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M40 26C43.32 26 46 23.32 46 20C46 16.68 43.32 14 40 14C36.68 14 34 16.68 34 20C34 23.32 36.68 26 40 26Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M24 20C27.32 20 30 17.32 30 14C30 10.68 27.32 8 24 8C20.68 8 18 10.68 18 14C18 17.32 20.68 20 24 20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M42 28H35.46C33.92 28 32.76 28.9 32.1 29.84C32.02 29.96 29.38 34 24 34C21.14 34 17.94 32.72 15.9 29.84C15.12 28.74 13.9 28 12.54 28H6C3.8 28 2 29.8 2 32V40H16V35.48C18.3 37.08 21.08 38 24 38C26.92 38 29.7 37.08 32 35.48V40H46V32C46 29.8 44.2 28 42 28Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_179_19260">
|
||||
<rect width="48" height="48" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M20 0C8.96 0 0 8.96 0 20C0 31.04 8.96 40 20 40C31.04 40 40 31.04 40 20C40 8.96 31.04 0 20 0ZM20 6C23.32 6 26 8.68 26 12C26 15.32 23.32 18 20 18C16.68 18 14 15.32 14 12C14 8.68 16.68 6 20 6ZM20 34.4C15 34.4 10.58 31.84 8 27.96C8.06 23.98 16 21.8 20 21.8C23.98 21.8 31.94 23.98 32 27.96C29.42 31.84 25 34.4 20 34.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 439 B |
@@ -1,4 +1,4 @@
|
||||
import { ComponentPropsWithRef, ReactHTML } from 'react';
|
||||
import { ReactHTML } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CSSProperties } from 'styled-components/dist/types';
|
||||
|
||||
@@ -14,19 +14,13 @@ export interface BoxProps {
|
||||
$gap?: CSSProperties['gap'];
|
||||
$height?: CSSProperties['height'];
|
||||
$justify?: CSSProperties['justifyContent'];
|
||||
$overflow?: CSSProperties['overflow'];
|
||||
$position?: CSSProperties['position'];
|
||||
$radius?: CSSProperties['borderRadius'];
|
||||
$width?: CSSProperties['width'];
|
||||
$maxWidth?: CSSProperties['maxWidth'];
|
||||
$minWidth?: CSSProperties['minWidth'];
|
||||
}
|
||||
|
||||
export type BoxType = ComponentPropsWithRef<typeof Box>;
|
||||
|
||||
export const Box = styled('div')<BoxProps>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
${({ $align }) => $align && `align-items: ${$align};`}
|
||||
${({ $background }) => $background && `background: ${$background};`}
|
||||
${({ $color }) => $color && `color: ${$color};`}
|
||||
@@ -36,11 +30,8 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $gap }) => $gap && `gap: ${$gap};`}
|
||||
${({ $height }) => $height && `height: ${$height};`}
|
||||
${({ $justify }) => $justify && `justify-content: ${$justify};`}
|
||||
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
|
||||
${({ $position }) => $position && `position: ${$position};`}
|
||||
${({ $radius }) => $radius && `border-radius: ${$radius};`}
|
||||
${({ $width }) => $width && `width: ${$width};`}
|
||||
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
|
||||
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
|
||||
${({ $css }) => $css && `${$css};`}
|
||||
`;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ComponentPropsWithRef, forwardRef } from 'react';
|
||||
|
||||
import { Box, BoxType } from './Box';
|
||||
|
||||
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
|
||||
|
||||
/**
|
||||
* Styleless button that extends the Box component.
|
||||
* Good to wrap around SVGs or other elements that need to be clickable.
|
||||
* @param props - @see BoxType props
|
||||
* @param ref
|
||||
* @see Box
|
||||
* @example
|
||||
* ```tsx
|
||||
* <BoxButton $radius="100%" aria-label="My button" onClick={() => console.log('clicked')}>
|
||||
* Click me
|
||||
* </BoxButton>
|
||||
* ```
|
||||
*/
|
||||
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
|
||||
({ $css, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
as="button"
|
||||
$background="none"
|
||||
$css={`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
${$css || ''}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BoxButton.displayName = 'BoxButton';
|
||||
export { BoxButton };
|
||||
@@ -1,30 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { BoxType } from '.';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
background-color: white;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 2px 2px 5px var(--shadow-color) 88;
|
||||
border: 1px solid var(--border-color);
|
||||
`;
|
||||
|
||||
export const Card = ({ children, ...props }: PropsWithChildren<BoxType>) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
style={{
|
||||
'--border-color': colorsTokens()['card-border'],
|
||||
'--shadow-color': colorsTokens()['primary-300'],
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, DialogTrigger, Popover } from 'react-aria-components';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #dddddd;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
interface DropButtonProps {
|
||||
button: ReactNode;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const DropButton = ({
|
||||
button,
|
||||
isOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: PropsWithChildren<DropButtonProps>) => {
|
||||
const [opacity, setOpacity] = useState(false);
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLocalOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
setTimeout(() => {
|
||||
setOpacity(isOpen);
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
|
||||
<StyledButton>{button}</StyledButton>
|
||||
<StyledPopover
|
||||
style={{ opacity: opacity ? 1 : 0 }}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Text } from '@/components';
|
||||
|
||||
interface IconOptionsProps {
|
||||
isOpen: boolean;
|
||||
'aria-label': string;
|
||||
}
|
||||
|
||||
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
|
||||
return (
|
||||
<Text
|
||||
aria-label={props['aria-label']}
|
||||
className="material-icons"
|
||||
$theme="primary"
|
||||
$css={`
|
||||
transition: all 0.3s ease-in-out;
|
||||
transform: rotate(${isOpen ? '90' : '0'}deg);
|
||||
`}
|
||||
>
|
||||
more_vert
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
|
||||
interface InfiniteScrollProps extends BoxType {
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
next: () => void;
|
||||
scrollContainer: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const InfiniteScroll = ({
|
||||
children,
|
||||
hasMore,
|
||||
isLoading,
|
||||
next,
|
||||
scrollContainer,
|
||||
...boxProps
|
||||
}: PropsWithChildren<InfiniteScrollProps>) => {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHandle = () => {
|
||||
if (!hasMore || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// To not wait until the end of the scroll to load more data
|
||||
const heightFromBottom = 150;
|
||||
|
||||
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
|
||||
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
timeout.current = setTimeout(nextHandle, 50);
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [hasMore, isLoading, next, scrollContainer]);
|
||||
|
||||
return <Box {...boxProps}>{children}</Box>;
|
||||
};
|
||||