Compare commits

..

1 Commits

Author SHA1 Message Date
Anthony LC
9790824ce1 🔖(minor) minor release to 1.1.0
Added:
- 🤡(demo) generate dummy documents on dev users
- (frontend) create side modal component
- (frontend) Doc grid actions (update / delete)
- (frontend) Doc editor header information

Changed:
- ♻️(frontend) replace docs panel with docs grid
- ♻️(frontend) create a doc from a modal
- ♻️(frontend) manage members from the share modal
2024-07-15 17:48:31 +02:00
334 changed files with 9356 additions and 20221 deletions

52
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"

View File

@@ -1,5 +1,4 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -8,6 +7,9 @@ on:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
@@ -46,15 +48,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
target: backend-production
@@ -96,15 +92,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
file: ./src/frontend/Dockerfile
@@ -114,7 +104,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-y-provider:
build-and-push-y-webrtc-signaling:
runs-on: ubuntu-latest
steps:
-
@@ -142,24 +132,18 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-y-provider
images: lasuite/impress-y-webrtc-signaling
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
file: ./src/frontend/Dockerfile
target: y-provider
target: y-webrtc-signaling
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}

View File

@@ -39,6 +39,29 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
test-front:
runs-on: ubuntu-latest
needs: install-front
@@ -73,13 +96,27 @@ jobs:
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e-chromium:
test-e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build-front
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make data/media
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
@@ -87,55 +124,48 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-chromium-report
path: src/frontend/apps/e2e/report/
retention-days: 7
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: test-e2e-chromium
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
- name: Restore the build cache
uses: actions/cache@v4
id: front-node_modules
id: cache-build
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-webrtc-signaling
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
run: |
make run
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
run: cd src/frontend/apps/e2e && yarn install
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
run: cd src/frontend/ && yarn e2e:test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-other-report
name: playwright-report
path: src/frontend/apps/e2e/report/
retention-days: 7

View File

@@ -168,7 +168,7 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
- name: Start Minio
run: |
docker pull minio/minio
docker run -d --name minio \
@@ -178,15 +178,6 @@ jobs:
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
@@ -207,10 +198,75 @@ jobs:
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
i18n-crowdin:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "infrastructure,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: 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: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml

4
.gitignore vendored
View File

@@ -33,6 +33,7 @@ MANIFEST
*.pot
# Environments
.env
.venv
env/
venv/
@@ -49,6 +50,9 @@ node_modules
# Mails
src/backend/core/templates/mail/
# Typescript client
src/frontend/tsclient
# Swagger
**/swagger.json

View File

@@ -6,5 +6,4 @@ creation_rules:
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud

View File

@@ -6,191 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## Added
- 🌐(frontend) Add German translation #255
- 🧑‍💻(helm) demo helm config #404
## Changed
- 🚸(backend) improve users similarity search and sort results #391
- 🌐(backend) add german translation #259
## Fixed
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
## [1.7.0] - 2024-10-24
## Added
- 📝Contributing.md #352
- 🌐(frontend) add localization to editor #368
- ✨Public and restricted doc editable #357
- ✨(frontend) Add full name if available #380
- ✨(backend) Add view accesses ability #376
## Changed
- ♻️(frontend) list accesses if user has abilities #376
- ♻️(frontend) avoid documents indexing in search engine #372
- 👔(backend) doc restricted by default #388
## Fixed
- 🐛(backend) require right to manage document accesses to see invitations #369
- 🐛(i18n) same frontend and backend language using shared cookies #365
- 🐛(frontend) add default toolbar buttons #355
- 🐛(frontend) throttle error correctly display #378
## Removed
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
## Added
- ✨AI to doc editor #250
- ✨(backend) allow uploading more types of attachments #309
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #318
## Changed
- ♻️(frontend) more multi theme friendly #325
- ♻️ Bootstrap frontend #257
- ♻️ Add username in email #314
## Fixed
- 🛂(backend) do not duplicate user when disabled
- 🐛(frontend) invalidate queries after removing user #336
- 🐛(backend) Fix dysfunctional permissions on document create #329
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [1.5.1] - 2024-10-10
## Fixed
- 🐛(db) fix users duplicate #316
## [1.5.0] - 2024-10-09
## Added
- ✨(backend) add name fields to the user synchronized with OIDC #301
- ✨(ci) add security scan #291
- ♻️(frontend) Add versions #277
- ✨(frontend) one-click document creation #275
- ✨(frontend) edit title inline #275
- 📱(frontend) mobile responsive #304
- 🌐(frontend) Update translation #308
## Changed
- 💄(frontend) error alert closeable on editor #284
- ♻️(backend) Change email content #283
- 🛂(frontend) viewers and editors can access share modal #302
- ♻️(frontend) remove footer on doc editor #313
## Fixed
- 🛂(frontend) match email if no existing user matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint #232
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
- ♻️(backend) getting list of document versions available for a user #258
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
- ✨Add link public/authenticated/restricted access with read/editor roles #234
- ✨(frontend) add copy link button #235
- 🛂(frontend) access public docs without being logged #235
## Changed
- ♻️(backend) Allow null titles on documents for easier creation #234
- 🛂(backend) stop to list public doc to everyone #234
- 🚚(frontend) change visibility in share modal #235
- ⚡️(frontend) Improve summary #244
## Fixed
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## Added
- ✨Add image attachments with access control
- ✨(frontend) Upload image to a document #211
- ✨(frontend) Summary #223
- ✨(frontend) update meta title for docs page #231
## Changed
- 💄(frontend) code background darkened on editor #214
- 🔥(frontend) hide markdown button if not text #213
## Fixed
- 🐛 Fix emoticon in pdf export #225
- 🐛 Fix collaboration on document #226
- 🐛 (docker) Fix compatibility with mac #230
## Removed
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
- 🎨(frontend) better conversion editor to pdf #151
- ✨Export docx (word) #161
- 🌐Internationalize invitation email #167
- ✨(frontend) White branding #164
- ✨Email invitation when add user to doc #171
- ✨Invitation management #174
## Fixed
- 🐛(y-webrtc) fix prob connection #147
- ⚡️(frontend) improve select share stability #159
- 🐛(backend) enable SSL when sending email #165
## Changed
- 🎨(frontend) stop limit layout height to screen size #158
- ⚡️(CI) only e2e chrome mandatory #177
## Removed
- 🔥(helm) remove htaccess #181
## [1.1.0] - 2024-07-15
## Added
@@ -206,7 +23,6 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
@@ -245,7 +61,6 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
@@ -254,15 +69,7 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.1.0...main
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0

View File

@@ -1,79 +0,0 @@
# Contributing to the Project
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions.
## Creating an Issue
When creating an issue, please provide the following details:
1. **Title**: A concise and descriptive title for the issue.
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
## Selecting an issue
We use a [GitHub Project](https://github.com/orgs/numerique-gouv/projects/13) in order to prioritize our workload.
Please check in priority the issues that are in the **todo** column and have a higher priority (P0 -> P2).
## Commit Message Format
All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
### Example Commit Message
```
✨(frontend) add user authentication logic
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
### Example Changelog Message
```
## [Unreleased]
## Added
- ✨(frontend) add AI to the project #321
```
## Pull Requests
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
Once all the required tests have passed, you can request a review from the project maintainers.
## Code Style
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
## Tests
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
## Asking for Help
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍

View File

@@ -1,17 +1,18 @@
# Django impress
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.10-slim-bullseye as base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
# ---- Back-end builder image ----
FROM base AS back-builder
FROM base as back-builder
WORKDIR /builder
@@ -23,7 +24,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:20 as mail-builder
COPY ./src/mail /mail/app
@@ -34,13 +35,15 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base AS link-collector
FROM base as link-collector
ARG IMPRESS_STATIC_ROOT=/data/static
# Install pango & rdfind
RUN apk add \
pango \
rdfind
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -59,22 +62,21 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
# ---- Core application image ----
FROM base AS core
FROM base as core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apk add \
cairo \
file \
font-noto \
font-noto-emoji \
gettext \
gdk-pixbuf \
libffi-dev \
pandoc \
pango \
shared-mime-info
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -98,13 +100,15 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core AS backend-development
FROM core as backend-development
# Switch back to the root user to install development dependencies
USER root:root
# Install psql
RUN apk add postgresql-client
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
# Uninstall impress and re-install it in editable mode along with development
# dependencies
@@ -124,7 +128,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core AS backend-production
FROM core as backend-production
ARG IMPRESS_STATIC_ROOT=/data/static

View File

@@ -49,6 +49,7 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -81,7 +82,7 @@ bootstrap: \
data/static \
create-env-files \
build \
run-with-frontend \
run-frontend-dev \
migrate \
demo \
back-i18n-compile \
@@ -90,28 +91,10 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: cache ?= --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
.PHONY: build
build-backend: cache ?=
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend
build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider $(cache)
.PHONY: build-yjs-provider
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -122,17 +105,11 @@ logs: ## display app-dev logs (follow mode)
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d y-webrtc-signaling
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -209,7 +186,7 @@ back-i18n-compile: ## compile the gettext files
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@$(MANAGE) makemessages -a --keep-pot --all
@$(MANAGE) makemessages -a --keep-pot
.PHONY: back-i18n-generate
shell: ## connect to database shell
@@ -298,6 +275,16 @@ mails-install: ## install the mail generator
@$(MAIL_YARN) install
.PHONY: mails-install
# -- TS client generator
tsclient-install: ## Install the Typescript API client generator
@$(TSCLIENT_YARN) install
.PHONY: tsclient-install
tsclient: tsclient-install ## Generate a Typescript API client
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
.PHONY: tsclient-install
# -- Misc
clean: ## restore repository state as it was freshly cloned
git clean -idx
@@ -309,19 +296,10 @@ help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# Front
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
# Front
run-frontend-dev: ## Install and run the frontend dev
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-frontend-dev
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
@@ -346,13 +324,3 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
bump-packages-version: VERSION_TYPE ?= minor
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
.PHONY: bump-packages-version

View File

@@ -1,15 +1,9 @@
# Impress
Impress is a web application for real-time collaborative text editing with user and role based access rights.
Features include :
- User authentication through OIDC
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
- Document export to pdf and docx from predefined templates
- Granular document permissions
- Public link sharing
- Offline mode
Impress prints your markdown to pdf from predefined templates with user and role based access rights.
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
Impress is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
## Getting started
@@ -37,6 +31,14 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap FLUSH_ARGS='--no-input'
```
Then you can access to the project in development mode by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
---
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
@@ -44,41 +46,12 @@ dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
$ make run-with-frontend
$ make run-frontend-dev
```
---
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```bash
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```bash
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```bash
$ make run
```
---
### Adding content
You can create a basic demo site by running:

View File

@@ -18,13 +18,13 @@ docker_build(
)
docker_build(
'localhost:5001/impress-y-provider:latest',
'localhost:5001/impress-y-webrtc-signaling:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-provider',
target = 'y-webrtc-signaling',
live_update=[
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
sync('../src/frontend/apps/y-webrtc-signaling/src', '/home/frontend/apps/y-webrtc-signaling/src'),
]
)

View File

@@ -16,7 +16,7 @@ reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi

View File

@@ -1,3 +1,5 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -63,6 +65,7 @@ services:
- mailcatcher
- redis
- createbuckets
- nginx
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -117,22 +120,6 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
- app-dev
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
Y_PROVIDER_URL: "ws://localhost:4444"
MEDIA_URL: "http://localhost:8083"
SW_DEACTIVATED: "true"
image: impress:frontend-development
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
@@ -154,22 +141,38 @@ services:
volumes:
- ".:/app"
y-provider:
y-webrtc-signaling:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: y-provider
target: y-webrtc-signaling
restart: unless-stopped
ports:
- "4444:4444"
volumes:
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
- ./src/frontend/apps/y-webrtc-signaling:/home/frontend/apps/y-webrtc-signaling
- /home/frontend/apps/y-webrtc-signaling/node_modules/
- /home/frontend/apps/y-webrtc-signaling/dist/
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress:/home/frontend/apps/impress
- /home/frontend/node_modules/
depends_on:
- y-webrtc-signaling
- celery-dev
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:

View File

@@ -4,36 +4,6 @@ server {
server_name localhost;
charset utf-8;
location /media/ {
# Auth request configuration
auth_request /auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
}
location /auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;

View File

@@ -1,72 +0,0 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v4.18.1"
y-provider:
image:
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "v4.18.1"
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
```
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin tag v4.18.1
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/impress-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/impress-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```

25
docs/tsclient.md Normal file
View File

@@ -0,0 +1,25 @@
# Api client TypeScript
The backend application can automatically create a TypeScript client to be used in frontend
applications. It is used in the impress front application itself.
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
and impress's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
## Requirements
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
install the backend application.
## Install openApiClientJs
```sh
$ cd src/tsclient
$ yarn install
```
## Generate the client
```sh
yarn generate:api:client:local <output_path_for_generated_client>
```

View File

@@ -39,8 +39,3 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama

View File

@@ -13,7 +13,13 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"i18next-parser",
"eslint"
]
}
]
}

0
bin/install-hooks.sh → scripts/install-hooks.sh Normal file → Executable file
View File

View File

3
scripts/updatekeys.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;

Submodule secrets updated: 38594182e8...1485c6dc9d

View File

@@ -447,10 +447,10 @@ max-bool-expr=5
max-branches=12
# Maximum number of locals for function / method body
max-locals=20
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=10
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20

View File

@@ -1,5 +1,4 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
@@ -29,19 +28,7 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -70,7 +57,6 @@ class UserAdmin(auth_admin.UserAdmin):
list_display = (
"id",
"sub",
"full_name",
"admin_email",
"email",
"is_active",
@@ -81,24 +67,9 @@ class UserAdmin(auth_admin.UserAdmin):
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = (
"is_active",
"-is_superuser",
"-is_staff",
"-is_device",
"-updated_at",
"full_name",
)
readonly_fields = (
"id",
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
@admin.register(models.Template)
@@ -120,14 +91,6 @@ class DocumentAdmin(admin.ModelAdmin):
"""Document admin interface declaration."""
inlines = (DocumentAccessInline,)
list_display = (
"id",
"title",
"link_reach",
"link_role",
"created_at",
"updated_at",
)
@admin.register(models.Invitation)

View File

@@ -1,5 +1,4 @@
"""Impress core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -17,9 +16,9 @@ def exception_handler(exc, context):
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
"""
if isinstance(exc, ValidationError):
detail = exc.message_dict
if hasattr(exc, "message"):
if hasattr(exc, "message_dict"):
detail = exc.message_dict
elif hasattr(exc, "message"):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages

View File

@@ -1,5 +1,4 @@
"""A JSONField for DRF to handle serialization/deserialization."""
import json
from rest_framework import serializers

View File

@@ -1,12 +1,8 @@
"""Permission handlers for the impress core app."""
from django.core import exceptions
from django.db.models import Q
from rest_framework import permissions
from core.models import DocumentAccess, RoleChoices
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
@@ -62,44 +58,9 @@ class IsOwnedOrPublic(IsAuthenticated):
return False
class CanCreateInvitationPermission(permissions.BasePermission):
"""
Custom permission class to handle permission checks for managing invitations.
"""
def has_permission(self, request, view):
user = request.user
# Ensure the user is authenticated
if not (bool(request.auth) or request.user.is_authenticated):
return False
# Apply permission checks only for creation (POST requests)
if view.action != "create":
return True
# Check if resource_id is passed in the context
try:
document_id = view.kwargs["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to manage document invitations."
) from exc
# Check if the user has access to manage invitations (Owner/Admin roles)
return DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
).exists()
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)

View File

@@ -1,16 +1,10 @@
"""Client serializers for the impress core app."""
import mimetypes
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import exceptions, serializers
from core import enums, models
from core.services.ai_services import AI_ACTIONS
from core import models
class UserSerializer(serializers.ModelSerializer):
@@ -18,8 +12,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email"]
read_only_fields = ["id", "email"]
class BaseAccessSerializer(serializers.ModelSerializer):
@@ -68,10 +62,10 @@ class BaseAccessSerializer(serializers.ModelSerializer):
"You must set a resource ID in kwargs to create a new access."
) from exc
teams = user.get_teams()
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
Q(user=user) | Q(team__in=teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
@@ -80,7 +74,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
Q(user=user) | Q(team__in=teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
@@ -151,111 +145,11 @@ class DocumentSerializer(BaseResourceSerializer):
"title",
"accesses",
"abilities",
"link_role",
"link_reach",
"is_public",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"link_role",
"link_reach",
"created_at",
"updated_at",
]
def get_fields(self):
"""Dynamically make `id` read-only on PUT requests but writable on POST requests."""
fields = super().get_fields()
request = self.context.get("request")
if request and request.method == "POST":
fields["id"].read_only = False
return fields
def validate_id(self, value):
"""Ensure the provided ID does not already exist when creating a new document."""
request = self.context.get("request")
# Only check this on POST (creation)
if request and request.method == "POST":
if models.Document.objects.filter(id=value).exists():
raise serializers.ValidationError(
"A document with this ID already exists. You cannot override it."
)
return value
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.
We expose it separately from document in order to simplify and secure access control.
"""
class Meta:
model = models.Document
fields = [
"link_role",
"link_reach",
]
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
class FileUploadSerializer(serializers.Serializer):
"""Receive file upload requests."""
file = serializers.FileField()
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
# Validate file size
if file.size > settings.DOCUMENT_IMAGE_MAX_SIZE:
max_size = settings.DOCUMENT_IMAGE_MAX_SIZE // (1024 * 1024)
raise serializers.ValidationError(
f"File size exceeds the maximum limit of {max_size:d} MB."
)
extension = file.name.rpartition(".")[-1] if "." in file.name else None
# Read the first few bytes to determine the MIME type accurately
mime = magic.Magic(mime=True)
magic_mime_type = mime.from_buffer(file.read(1024))
file.seek(0) # Reset file pointer to the beginning after reading
self.context["is_unsafe"] = (
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
extension_mime_type, _ = mimetypes.guess_type(file.name)
# Try guessing a coherent extension from the mimetype
if extension_mime_type != magic_mime_type:
self.context["is_unsafe"] = True
guessed_ext = mimetypes.guess_extension(magic_mime_type)
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
# can be) are replaced by the extension we eventually guessed from mimetype.
if (extension is None or len(extension) > 5) and guessed_ext:
extension = guessed_ext[1:]
if extension is None:
raise serializers.ValidationError("Could not determine file extension.")
self.context["expected_extension"] = extension
return file
def validate(self, attrs):
"""Override validate to add the computed extension to validated_data."""
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
return attrs
read_only_fields = ["id", "accesses", "abilities", "created_at", "updated_at"]
class TemplateSerializer(BaseResourceSerializer):
@@ -286,12 +180,6 @@ class DocumentGenerationSerializer(serializers.Serializer):
required=False,
default="html",
)
format = serializers.ChoiceField(
choices=["pdf", "docx"],
label=_("Format"),
required=False,
default="pdf",
)
class InvitationSerializer(serializers.ModelSerializer):
@@ -328,72 +216,46 @@ class InvitationSerializer(serializers.ModelSerializer):
return {}
def validate(self, attrs):
"""Validate invitation data."""
"""Validate and restrict invitation to new user based on email."""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
attrs["document_id"] = self.context["resource_id"]
try:
document_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to create a new document invitation."
) from exc
# Only set the issuer if the instance is being created
if self.instance is None:
attrs["issuer"] = user
if not user and user.is_authenticated:
raise exceptions.PermissionDenied(
"Anonymous users are not allowed to create invitations."
)
return attrs
teams = user.get_teams()
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this document."
)
def validate_role(self, role):
"""Custom validation for the role field."""
request = self.context.get("request")
user = getattr(request, "user", None)
document_id = self.context["resource_id"]
# If the role is OWNER, check if the user has OWNER access
if role == models.RoleChoices.OWNER:
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists():
raise serializers.ValidationError(
"Only owners of a document can invite other users as owners."
)
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a document can invite other users as owners."
)
return role
class VersionFilterSerializer(serializers.Serializer):
"""Validate version filters applied to the list endpoint."""
version_id = serializers.CharField(required=False, allow_blank=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
class AITransformSerializer(serializers.Serializer):
"""Serializer for AI transform requests."""
action = serializers.ChoiceField(choices=AI_ACTIONS, required=True)
text = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class AITranslateSerializer(serializers.Serializer):
"""Serializer for AI translate requests."""
language = serializers.ChoiceField(
choices=tuple(enums.ALL_LANGUAGES.items()), required=True
)
text = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs

View File

@@ -1,129 +0,0 @@
"""Util to generate S3 authorization headers for object storage access control"""
import time
from abc import ABC, abstractmethod
from django.conf import settings
from django.core.cache import cache
from django.core.files.storage import default_storage
import botocore
from rest_framework.throttling import BaseThrottle
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
class AIBaseRateThrottle(BaseThrottle, ABC):
"""Base throttle class for AI-related rate limiting with backoff."""
def __init__(self, rates):
"""Initialize instance attributes with configurable rates."""
super().__init__()
self.rates = rates
self.cache_key = None
self.recent_requests_minute = 0
self.recent_requests_hour = 0
self.recent_requests_day = 0
@abstractmethod
def get_cache_key(self, request, view):
"""Abstract method to generate cache key for throttling."""
def allow_request(self, request, view):
"""Check if the request is allowed based on rate limits."""
self.cache_key = self.get_cache_key(request, view)
if not self.cache_key:
return True # Allow if no cache key is generated
now = time.time()
history = cache.get(self.cache_key, [])
# Keep requests within the last 24 hours
history = [req for req in history if req > now - 86400]
# Calculate recent requests
self.recent_requests_minute = len([req for req in history if req > now - 60])
self.recent_requests_hour = len([req for req in history if req > now - 3600])
self.recent_requests_day = len(history)
# Check rate limits
if self.recent_requests_minute >= self.rates["minute"]:
return False
if self.recent_requests_hour >= self.rates["hour"]:
return False
if self.recent_requests_day >= self.rates["day"]:
return False
# Log the request
history.append(now)
cache.set(self.cache_key, history, timeout=86400)
return True
def wait(self):
"""Implement a backoff strategy by increasing wait time based on limits hit."""
if self.recent_requests_day >= self.rates["day"]:
return 86400
if self.recent_requests_hour >= self.rates["hour"]:
return 3600
if self.recent_requests_minute >= self.rates["minute"]:
return 60
return None
class AIDocumentRateThrottle(AIBaseRateThrottle):
"""Throttle for limiting AI requests per document with backoff."""
def __init__(self, *args, **kwargs):
super().__init__(settings.AI_DOCUMENT_RATE_THROTTLE_RATES)
def get_cache_key(self, request, view):
"""Include document ID in the cache key."""
document_id = view.kwargs["pk"]
return f"document_{document_id}_throttle_ai"
class AIUserRateThrottle(AIBaseRateThrottle):
"""Throttle that limits requests per user or IP with backoff and rate limits."""
def __init__(self, *args, **kwargs):
super().__init__(settings.AI_USER_RATE_THROTTLE_RATES)
def get_cache_key(self, request, view=None):
"""Generate a cache key based on the user ID or IP for anonymous users."""
if request.user.is_authenticated:
return f"user_{request.user.id!s}_throttle_ai"
return f"anonymous_{self.get_ident(request)}_throttle_ai"
def get_ident(self, request):
"""Return the request IP address."""
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
return (
x_forwarded_for.split(",")[0]
if x_forwarded_for
else request.META.get("REMOTE_ADDR")
)

View File

@@ -1,28 +1,19 @@
"""API endpoints"""
from io import BytesIO
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
Min,
OuterRef,
Q,
Subquery,
)
from django.http import Http404
from django.http import FileResponse, Http404
from botocore.exceptions import ClientError
from rest_framework import (
decorators,
exceptions,
filters,
metadata,
mixins,
pagination,
status,
@@ -32,25 +23,12 @@ from rest_framework import (
response as drf_response,
)
from core import enums, models
from core.services.ai_services import AIService
from core import models
from . import permissions, serializers, utils
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
from . import permissions, serializers
# pylint: disable=too-many-ancestors
ATTACHMENTS_FOLDER = "attachments"
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -157,21 +135,8 @@ class UserViewSet(
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
# For performance reasons we filter first by similarity, which relies on an index,
# then only calculate precise similarity scores for sorting purposes
queryset = queryset.filter(email__trigram_word_similar=query)
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", query)
)
# When the query only is on the name part, we should try to make many proposals
# But when the query looks like an email we should only propose serious matches
threshold = 0.6 if "@" in query else 0.1
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity"
)
return queryset
@decorators.action(
@@ -201,21 +166,28 @@ class ResourceViewsetMixin:
def get_queryset(self):
"""Custom queryset to get user related resources."""
queryset = super().get_queryset()
user = self.request.user
if not self.request.user.is_authenticated:
return queryset.filter(is_public=True)
if not user.is_authenticated:
return queryset
user = self.request.user
teams = user.get_teams()
user_roles_query = (
self.access_model_class.objects.filter(
Q(user=user) | Q(team__in=user.teams),
Q(user=user) | Q(team__in=teams),
**{self.resource_field_name: OuterRef("pk")},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
return (
queryset.filter(
Q(accesses__user=user) | Q(accesses__team__in=teams) | Q(is_public=True)
)
.annotate(user_roles=Subquery(user_roles_query))
.distinct()
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
@@ -254,7 +226,8 @@ class ResourceAccessViewsetMixin:
if self.action == "list":
user = self.request.user
teams = user.teams
teams = user.get_teams()
user_roles_query = (
queryset.filter(
Q(user=user) | Q(team__in=teams),
@@ -292,7 +265,7 @@ class ResourceAccessViewsetMixin:
):
return drf_response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=status.HTTP_403_FORBIDDEN,
status=403,
)
return super().destroy(request, *args, **kwargs)
@@ -318,88 +291,38 @@ class ResourceAccessViewsetMixin:
serializer.save()
class DocumentMetadata(metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
"""Add language choices only for the list endpoint."""
simple_metadata = super().determine_metadata(request, view)
if request.path.endswith("/documents/"):
simple_metadata["actions"]["POST"]["language"] = {
"choices": [
{"value": code, "display_name": name}
for code, name in enums.ALL_LANGUAGES.items()
]
}
return simple_metadata
class DocumentViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Document ViewSet"""
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
]
serializer_class = serializers.DocumentSerializer
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
metadata_class = DocumentMetadata
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| (
Q(link_traces__user=user)
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
else:
queryset = queryset.none()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
def perform_create(self, serializer):
"""
Add a trace that the document was accessed by a user. This is used to list documents
on a user's list view even though the user has no specific role in the document (link
access when the link reach configuration of the document allows it).
Override perform_create to use the provided ID in the payload if it exists
"""
instance = self.get_object()
serializer = self.get_serializer(instance)
document_id = self.request.data.get("id")
document = serializer.save(id=document_id) if document_id else serializer.save()
if self.request.user.is_authenticated:
try:
# Add a trace that the user visited the document (this is needed to include
# the document in the user's list view)
models.LinkTrace.objects.create(
document=instance,
user=self.request.user,
)
except ValidationError:
# The trace already exists, so we just pass without doing anything
pass
return drf_response.Response(serializer.data)
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: document},
)
@decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
@@ -407,36 +330,16 @@ class DocumentViewSet(
Return the document's versions but only those created after the user got access
to the document
"""
user = request.user
if not user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
# Validate query parameters using dedicated serializer
serializer = serializers.VersionFilterSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
document = self.get_object()
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = document.accesses.filter(
Q(user=user) | Q(team__in=user.teams)
).aggregate(min_date=Min("created_at"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return exceptions.PermissionDenied(
"Only users with specific access can see version history"
from_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=request.user) | Q(team__in=request.user.get_teams()),
)
versions_data = document.get_versions_slice(
from_version_id=serializer.validated_data.get("version_id"),
min_datetime=min_datetime,
page_size=serializer.validated_data.get("page_size"),
)
return drf_response.Response(versions_data)
return drf_response.Response(
document.get_versions_slice(from_datetime=from_datetime)
)
@decorators.action(
detail=True,
@@ -455,14 +358,13 @@ class DocumentViewSet(
# Don't let users access versions that were created before they were given access
# to the document
user = request.user
min_datetime = min(
from_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
Q(user=request.user) | Q(team__in=request.user.get_teams()),
)
)
if response["LastModified"] < min_datetime:
if response["LastModified"] < from_datetime:
raise Http404
if request.method == "DELETE":
@@ -475,153 +377,9 @@ class DocumentViewSet(
{
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
"id": version_id,
}
)
@decorators.action(detail=True, methods=["put"], url_path="link-configuration")
def link_configuration(self, request, *args, **kwargs):
"""Update link configuration with specific rights (cf get_abilities)."""
# Check permissions first
document = self.get_object()
# Deserialize and validate the data
serializer = serializers.LinkDocumentSerializer(
document, data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
serializer.save()
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
# Check permissions first
document = self.get_object()
# Validate metadata in payload
serializer = serializers.FileUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
extension = serializer.validated_data["expected_extension"]
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
# Prepare metadata for storage
extra_args = {"Metadata": {"owner": str(request.user.id)}}
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
file = serializer.validated_data["file"]
default_storage.connection.meta.client.upload_fileobj(
file, default_storage.bucket_name, key, ExtraArgs=extra_args
)
return drf_response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = MEDIA_URL_PATTERN.search(original_url.path)
try:
pk, attachment_key = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Check permission
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise exceptions.PermissionDenied() from excpt
if not document.get_abilities(request.user).get("retrieve", False):
raise exceptions.PermissionDenied()
# Generate authorization headers and return an authorization to proceed with the request
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
@decorators.action(
detail=True,
methods=["post"],
name="Apply a transformation action on a piece of text with AI",
url_path="ai-transform",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_transform(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-transform
with expected data:
- text: str
- action: str [prompt, correct, rephrase, summarize]
Return JSON response with the processed text.
"""
# Check permissions first
self.get_object()
serializer = serializers.AITransformSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data["text"]
action = serializer.validated_data["action"]
response = AIService().transform(text, action)
return drf_response.Response(response, status=status.HTTP_200_OK)
@decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
serializer_class=serializers.AITranslateSerializer,
url_path="ai-translate",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_translate(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-translate
with expected data:
- text: str
- language: str [settings.LANGUAGES]
Return JSON response with the translated text.
"""
# Check permissions first
self.get_object()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data["text"]
language = serializer.validated_data["language"]
response = AIService().translate(text, language)
return drf_response.Response(response, status=status.HTTP_200_OK)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
@@ -663,23 +421,12 @@ class DocumentAccessViewSet(
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.email_invitation(
language,
access.user.email,
access.role,
self.request.user,
)
class TemplateViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -695,27 +442,6 @@ class TemplateViewSet(
resource_field_name = "template"
queryset = models.Template.objects.all()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| Q(is_public=True)
)
else:
queryset = queryset.filter(is_public=True)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
@decorators.action(
detail=True,
methods=["post"],
@@ -725,16 +451,7 @@ class TemplateViewSet(
# pylint: disable=unused-argument
def generate_document(self, request, pk=None):
"""
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: format = "pdf"
- Docx: format = "docx"
Generate and return pdf for this template with the content passed.
"""
serializer = serializers.DocumentGenerationSerializer(data=request.data)
@@ -745,10 +462,13 @@ class TemplateViewSet(
body = serializer.validated_data["body"]
body_type = serializer.validated_data["body_type"]
export_format = serializer.validated_data["format"]
template = self.get_object()
return template.generate_document(body, body_type, export_format)
pdf_content = template.generate_document(body, body_type)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={template.title}.pdf"
return response
class TemplateAccessViewSet(
@@ -797,7 +517,6 @@ class InvitationViewset(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -811,9 +530,8 @@ class InvitationViewset(
- role: str [administrator|editor|reader]
Return newly created invitation (issuer and document are automatically set)
PATCH /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated document invitation
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/v1.0/documents/<document_id>/invitations/<invitation_id>/
Delete targeted invitation
@@ -821,10 +539,7 @@ class InvitationViewset(
lookup_field = "id"
pagination_class = Pagination
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
]
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = (
models.Invitation.objects.all()
.select_related("document")
@@ -845,7 +560,7 @@ class InvitationViewset(
if self.action == "list":
user = self.request.user
teams = user.teams
teams = user.get_teams()
# Determine which role the logged-in user has in the document
user_roles_query = (
@@ -859,16 +574,10 @@ class InvitationViewset(
)
queryset = (
# The logged-in user should be administrator or owner to see its accesses
# The logged-in user should be part of a document to see its accesses
queryset.filter(
Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
)
| Q(
document__accesses__team__in=teams,
document__accesses__role__in=models.PRIVILEGED_ROLES,
),
Q(document__accesses__user=user)
| Q(document__accesses__team__in=teams),
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access
@@ -876,13 +585,3 @@ class InvitationViewset(
.distinct()
)
return queryset
def perform_create(self, serializer):
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
)

View File

@@ -1,6 +1,5 @@
"""Authentication Backends for the Impress core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -46,77 +45,56 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
try:
userinfo = user_response.json()
except ValueError:
try:
userinfo = self.verify_token(user_response.text)
except Exception as e:
raise SuspiciousOperation(
_("Invalid response format or token verification failed")
) from e
userinfo = self.verify_token(user_response.text)
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found."""
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
email = user_info.get("email")
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
claims = {
"email": email,
"full_name": full_name,
"short_name": short_name,
}
sub = user_info.get("sub")
if not sub:
if sub is None:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
user = self.get_existing_user(sub, email)
if user:
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
user = None
return user
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
def create_user(self, claims):
"""Return a newly created User instance."""
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
sub = claims.get("sub")
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
if sub is None:
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
user = User.objects.create(
sub=sub,
email=claims.get("email"),
password="!", # noqa: S106
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
return user

View File

@@ -1,12 +1,15 @@
"""
Core application enums declaration
"""
from django.conf import global_settings
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
# pylint: disable=no-member
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
ALL_LANGUAGES = getattr(
settings,
"ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES],
)

View File

@@ -2,7 +2,6 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
@@ -22,29 +21,9 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
@factory.post_generation
def with_owned_document(self, create, extracted, **kwargs):
"""
Create a document for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
Create a template for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
class DocumentFactory(factory.django.DjangoModelFactory):
"""A factory to create documents"""
@@ -55,13 +34,8 @@ class DocumentFactory(factory.django.DjangoModelFactory):
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"document{n}")
is_public = factory.Faker("boolean")
content = factory.Sequence(lambda n: f"content{n}")
link_reach = factory.fuzzy.FuzzyChoice(
[a[0] for a in models.LinkReachChoices.choices]
)
link_role = factory.fuzzy.FuzzyChoice(
[r[0] for r in models.LinkRoleChoices.choices]
)
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -73,13 +47,6 @@ class DocumentFactory(factory.django.DjangoModelFactory):
else:
UserDocumentAccessFactory(document=self, user=item[0], role=item[1])
@factory.post_generation
def link_traces(self, create, extracted, **kwargs):
"""Add link traces to document from a given list of users."""
if create and extracted:
for item in extracted:
models.LinkTrace.objects.create(document=self, user=item)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""

View File

@@ -1,52 +0,0 @@
# Generated by Django 5.1 on 2024-09-08 16:55
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AddField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
),
migrations.AddField(
model_name='document',
name='link_role',
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
),
migrations.AlterField(
model_name='document',
name='is_public',
field=models.BooleanField(null=True),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='LinkTrace',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document/user link trace',
'verbose_name_plural': 'Document/user link traces',
'db_table': 'impress_link_trace',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
},
),
]

View File

@@ -1,35 +0,0 @@
# Generated by Django 5.1 on 2024-09-08 17:04
from django.db import migrations
def migrate_is_public_to_link_reach(apps, schema_editor):
"""
Forward migration: Migrate 'is_public' to 'link_reach'.
If is_public == True, set link_reach to 'public'
"""
Document = apps.get_model('core', 'Document')
Document.objects.filter(is_public=True).update(link_reach='public')
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
"""
Reverse migration: Migrate 'link_reach' back to 'is_public'.
- If link_reach == 'public', set is_public to True
- Else set is_public to False
"""
Document = apps.get_model('core', 'Document')
Document.objects.filter(link_reach='public').update(is_public=True)
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
class Migration(migrations.Migration):
dependencies = [
('core', '0003_document_link_reach_document_link_role_and_more'),
]
operations = [
migrations.RunPython(
migrate_is_public_to_link_reach,
reverse_migrate_link_reach_to_is_public
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2024-09-09 17:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_migrate_is_public_to_link_reach'),
]
operations = [
migrations.AlterField(
model_name='document',
name='title',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.1.1 on 2024-09-29 03:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]

View File

@@ -1,128 +0,0 @@
# Generated by Django 5.1.1 on 2024-10-10 11:45
from django.db import migrations
procedure = """
DO $$
DECLARE
user_email TEXT;
BEGIN
-- Step 1: Create a temporary table (without the unique constraint)
-- impress_document_access
DROP TABLE IF EXISTS impress_document_access_tmp;
CREATE TEMP TABLE impress_document_access_tmp AS
SELECT * FROM impress_document_access;
-- impress_link_trace
DROP TABLE IF EXISTS impress_link_trace_tmp;
CREATE TEMP TABLE impress_link_trace_tmp AS
SELECT * FROM impress_link_trace;
-- Step 2: Loop through each email that appears more than once
FOR user_email IN
SELECT email
FROM impress_user
GROUP BY email
HAVING COUNT(email) > 1
LOOP
-- Step 3: Update user_id in the temporary table based on email
-- For impress_document_access
UPDATE impress_document_access_tmp
SET user_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE user_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
-- For impress_link_trace
UPDATE impress_link_trace_tmp
SET user_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE user_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
-- update impress_invitation
UPDATE impress_invitation
SET issuer_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE issuer_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
DELETE FROM impress_user
WHERE id IN (
SELECT id
FROM impress_user
WHERE email = user_email
)
AND id != (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
);
RAISE NOTICE 'Processed updates for email: %', user_email;
END LOOP;
-- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id)
-- For impress_document_access
DELETE FROM impress_document_access_tmp a
USING impress_document_access_tmp b
WHERE a.ctid < b.ctid -- Keep one row
AND a.document_id = b.document_id
AND a.user_id = b.user_id;
-- Step 5: Replace the original table with the cleaned-up temporary table
TRUNCATE TABLE impress_document_access;
-- Insert cleaned-up data back into the original table
INSERT INTO impress_document_access
SELECT * FROM impress_document_access_tmp;
-- For impress_link_trace
DELETE FROM impress_link_trace_tmp a
USING impress_link_trace_tmp b
WHERE a.ctid < b.ctid -- Keep one row
AND a.document_id = b.document_id
AND a.user_id = b.user_id;
-- Step 5: Replace the original table with the cleaned-up temporary table
TRUNCATE TABLE impress_link_trace;
-- Insert cleaned-up data back into the original table
INSERT INTO impress_link_trace
SELECT * FROM impress_link_trace_tmp;
RAISE NOTICE 'Update and deduplication process completed.';
END $$;
"""
class Migration(migrations.Migration):
dependencies = [
('core', '0006_add_user_full_name_and_short_name'),
]
operations = [
migrations.RunSQL(procedure),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.2 on 2024-10-25 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]

View File

@@ -1,14 +1,11 @@
"""
Declare and configure the models for the impress core application
"""
import hashlib
import smtplib
import tempfile
import textwrap
import uuid
from datetime import timedelta
from io import BytesIO
from logging import getLogger
from django.conf import settings
@@ -18,53 +15,44 @@ from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import models
from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
import pypandoc
import weasyprint
from botocore.exceptions import ClientError
from timezone_field import TimeZoneField
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
logger = getLogger(__name__)
def get_resource_roles(resource, user):
"""Compute the roles a user has on a resource."""
if not user.is_authenticated:
return []
try:
roles = resource.user_roles or []
except AttributeError:
roles = []
if user.is_authenticated:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
roles = resource.user_roles or []
except AttributeError:
teams = user.get_teams()
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a resource."""
"""Defines the possible roles a user can have in a template."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
@@ -72,23 +60,6 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the document
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -130,17 +101,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
@@ -148,10 +119,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
blank=True,
null=True,
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
@@ -247,8 +214,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@cached_property
def teams(self):
def get_teams(self):
"""
Get list of teams in which the user is, as a list of strings.
Must be cached if retrieved remotely.
@@ -280,7 +246,7 @@ class BaseAccess(BaseModel):
"""
roles = []
if user.is_authenticated:
teams = user.teams
teams = user.get_teams()
try:
roles = self.user_roles or []
except AttributeError:
@@ -332,14 +298,11 @@ class BaseAccess(BaseModel):
class Document(BaseModel):
"""Pad document carrying the content."""
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.RESTRICTED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
title = models.CharField(_("title"), max_length=255)
is_public = models.BooleanField(
_("public"),
default=False,
help_text=_("Whether this document is public for anyone to use."),
)
_content = None
@@ -351,37 +314,7 @@ class Document(BaseModel):
verbose_name_plural = _("Documents")
def __str__(self):
return str(self.title) if self.title else str(_("Untitled Document"))
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
return self.title
@property
def key_base(self):
@@ -423,62 +356,95 @@ class Document(BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None):
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
if default_storage.exists(file_key):
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa
)
else:
has_changed = True
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def get_versions_slice(
self, from_version_id="", from_datetime=None, page_size=None
):
"""Get document versions from object storage with pagination and starting conditions"""
# /!\ Trick here /!\
# The "KeyMarker" and "VersionIdMarker" fields must either be both set or both not set.
# The error we get otherwise is not helpful at all.
markers = {}
token = {}
if from_version_id:
markers.update(
token.update(
{"KeyMarker": self.file_key, "VersionIdMarker": from_version_id}
)
real_page_size = (
min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
if page_size
else settings.DOCUMENT_VERSIONS_PAGE_SIZE
)
if from_datetime:
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=settings.S3_VERSIONS_PAGE_SIZE,
**token,
)
# Find the first version after the given datetime
version = None
for version in response.get("Versions", []):
if version["LastModified"] >= from_datetime:
token = {
"KeyMarker": self.file_key,
"VersionIdMarker": version["VersionId"],
}
break
else:
if version is None or version["LastModified"] < from_datetime:
if response["NextVersionIdMarker"]:
return self.get_versions_slice(
from_version_id=response["NextVersionIdMarker"],
page_size=settings.S3_VERSIONS_PAGE_SIZE,
from_datetime=from_datetime,
)
return {
"next_version_id_marker": "",
"is_truncated": False,
"versions": [],
}
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
# compensate the latest version that we exclude below and get one more to
# know if there are more pages
MaxKeys=real_page_size + 2,
**markers,
MaxKeys=min(page_size, settings.S3_VERSIONS_PAGE_SIZE)
if page_size
else settings.S3_VERSIONS_PAGE_SIZE,
**token,
)
min_last_modified = min_datetime or self.created_at
versions = [
{
key_snake: version[key_camel]
for key_snake, key_camel in [
("etag", "ETag"),
("is_latest", "IsLatest"),
("last_modified", "LastModified"),
("version_id", "VersionId"),
]
}
for version in response.get("Versions", [])
if version["LastModified"] >= min_last_modified
and version["IsLatest"] is False
]
results = versions[:real_page_size]
count = len(results)
if count == len(versions):
is_truncated = False
next_version_id_marker = ""
else:
is_truncated = True
next_version_id_marker = versions[count - 1]["version_id"]
return {
"next_version_id_marker": next_version_id_marker,
"is_truncated": is_truncated,
"versions": results,
"count": count,
"next_version_id_marker": response["NextVersionIdMarker"],
"is_truncated": response["IsTruncated"],
"versions": [
{
key_snake: version[key_camel]
for key_camel, key_snake in [
("ETag", "etag"),
("IsLatest", "is_latest"),
("LastModified", "last_modified"),
("VersionId", "version_id"),
]
}
for version in response.get("Versions", [])
],
}
def delete_version(self, version_id):
@@ -491,114 +457,25 @@ class Document(BaseModel):
"""
Compute and return abilities for a given user on the document.
"""
roles = set(get_resource_roles(self, user))
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
# Anonymous users should also not see document accesses
has_role = bool(roles)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
self.link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
roles.add(self.link_role)
roles = get_resource_roles(self, user)
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = bool(roles)
can_get = self.is_public or bool(roles)
can_get_versions = bool(roles)
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"destroy": RoleChoices.OWNER in roles,
"link_configuration": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
"""Send email invitation."""
sender_name = sender.full_name or sender.email
domain = Site.objects.get_current().domain
try:
with override(language):
title = _(
"%(sender_name)s shared a document with you: %(document)s"
) % {
"sender_name": sender_name,
"document": self.title,
}
template_vars = {
"title": title,
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"sender_name": sender_name,
"sender_name_email": f"{sender.full_name} ({sender.email})"
if sender.full_name
else sender.email,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
class LinkTrace(BaseModel):
"""
Relation model to trace accesses to a document via a link by a logged-in user.
This is necessary to show the document in the user's list of documents even
though the user does not have a role on the document.
"""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="link_traces",
)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
class Meta:
db_table = "impress_link_trace"
verbose_name = _("Document/user link trace")
verbose_name_plural = _("Document/user link traces")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_link_trace_document_user",
violation_error_message=_(
"A link trace already exists for this document/user."
),
),
]
def __str__(self):
return f"{self.user!s} trace on document {self.document!s}"
class DocumentAccess(BaseAccess):
"""Relation model to give access to a document for a user or a team with a role."""
@@ -681,96 +558,16 @@ class Template(BaseModel):
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"accesses_manage": is_owner_or_admin,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
}
def generate_pdf(self, body_html, metadata):
def generate_document(self, body, body_type):
"""
Generate and return a pdf document wrapped around the current template
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = weasyprint.CSS(
string=self.css,
font_config=weasyprint.text.fonts.FontConfiguration(),
)
pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={self.title}.pdf"
return response
def generate_word(self, body_html, metadata):
"""
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
html_string = f"""
<!DOCTYPE html>
<html>
<head>
<style>
{self.css}
</style>
</head>
<body>
{template_string}
</body>
</html>
"""
reference_docx = "core/static/reference.docx"
output = BytesIO()
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
output_path = tmp_file.name
pypandoc.convert_text(
html_string,
"docx",
format="html",
outputfile=output_path,
extra_args=["--reference-doc", reference_docx],
)
# Create a BytesIO object to store the output of the temporary docx file
with open(output_path, "rb") as f:
output = BytesIO(f.read())
# Ensure the pointer is at the beginning
output.seek(0)
response = FileResponse(
output,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
response["Content-Disposition"] = f"attachment; filename={self.title}.docx"
return response
def generate_document(self, body, body_type, export_format):
"""
Generate and return a document for this template around the
Generate and return a PDF document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: export_format = "pdf"
- Docx: export_format = "docx"
"""
document = frontmatter.loads(body)
metadata = document.metadata
@@ -783,10 +580,16 @@ class Template(BaseModel):
markdown.markdown(textwrap.dedent(strip_body)) if strip_body else ""
)
if export_format == "pdf":
return self.generate_pdf(body_html, metadata)
return self.generate_word(body_html, metadata)
document_html = HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = CSS(
string=self.css,
font_config=FontConfiguration(),
)
return document_html.write_pdf(stylesheets=[css], zoom=1)
class TemplateAccess(BaseAccess):
@@ -865,6 +668,14 @@ class Invitation(BaseModel):
def __str__(self):
return f"{self.email} invited to {self.document}"
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()
@@ -886,10 +697,11 @@ class Invitation(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
roles = []
if user.is_authenticated:
teams = user.teams
teams = user.get_teams()
try:
roles = self.user_roles or []
except AttributeError:
@@ -900,13 +712,33 @@ class Invitation(BaseModel):
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_delete = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": is_admin_or_owner,
"update": is_admin_or_owner,
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
"destroy": can_delete,
"update": False,
"partial_update": False,
"retrieve": bool(roles),
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
title = _("Invitation to join Impress!")
template_vars = {"title": title, "site": Site.objects.get_current()}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
title,
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)

View File

@@ -1,89 +0,0 @@
"""AI services."""
import json
import re
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from openai import OpenAI
from core import enums
AI_ACTIONS = {
"prompt": (
"Answer the prompt in markdown format. Return JSON: "
'{"answer": "Your markdown answer"}. '
"Do not provide any other information."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
'Return JSON: {"answer": "your corrected markdown text"}. '
"Do not provide any other information."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
'Return JSON: {"answer": "your rephrased markdown text"}. '
"Do not provide any other information."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
'Return JSON: {"answer": "your markdown summary"}. '
"Do not provide any other information."
),
}
AI_TRANSLATE = (
"Translate the markdown text to {language:s}, preserving markdown formatting. "
'Return JSON: {{"answer": "your translated markdown text in {language:s}"}}. '
"Do not provide any other information."
)
class AIService:
"""Service class for AI-related operations."""
def __init__(self):
"""Ensure that the AI configuration is set properly."""
if (
settings.AI_BASE_URL is None
or settings.AI_API_KEY is None
or settings.AI_MODEL is None
):
raise ImproperlyConfigured("AI configuration not set")
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
def call_ai_api(self, system_content, text):
"""Helper method to call the OpenAI API and process the response."""
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": json.dumps({"markdown_input": text})},
],
)
content = response.choices[0].message.content
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")
return json_response
def transform(self, text, action):
"""Transform text based on specified action."""
system_content = AI_ACTIONS[action]
return self.call_ai_api(system_content, text)
def translate(self, text, language):
"""Translate text to a specified language."""
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,12 +1,8 @@
"""Unit tests for the Authentication Backends."""
import re
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
@@ -38,130 +34,6 @@ def test_authentication_getter_existing_user_no_email(
assert user == db_user
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_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 user when they change.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
@@ -180,8 +52,6 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
assert user.sub == "123"
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -207,13 +77,11 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert models.User.objects.count() == 1
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
@@ -224,144 +92,10 @@ def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypa
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(
SuspiciousOperation,
match="User info contained no recognizable user identification",
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch):
"""Test get_userinfo method with a token response."""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response():
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="Invalid response format or token verification failed",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the sub but is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(1),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not matches the sub but matches the email and is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": "random",
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(2),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1

View File

@@ -1,5 +1,4 @@
"""Fixtures for tests in the impress core application"""
from unittest import mock
import pytest
@@ -10,9 +9,7 @@ VIA = [USER, TEAM]
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""
with mock.patch(
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
def mock_user_get_teams():
"""Mock for the "get_teams" method on the User model."""
with mock.patch("core.models.User.get_teams") as mock_get_teams:
yield mock_get_teams

View File

@@ -1,235 +0,0 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from django.core import mail
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
document = factories.DocumentFactory()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.DocumentAccess.objects.exists() is False
def test_api_document_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document accesses for a document to
which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_reader_or_editor(
via, role, mock_user_teams
):
"""Readers or editors of a document should not be allowed to create document accesses."""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"id": str(new_document_access.id),
"team": "",
"role": role,
"user": other_user,
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
assert "docs/" + str(document.id) + "/" in email_content

View File

@@ -1,858 +0,0 @@
"""
Unit tests for the Invitation model
"""
import random
from datetime import timedelta
from unittest import mock
from django.core import mail
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
# List
def test_api_document_invitations_list_anonymous_user():
"""Anonymous users should not be able to list invitations."""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/"
)
assert response.status_code == 401
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_document_invitations_list_authenticated_privileged(
role, via, mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list invitations for documents to which they are
related with administrator or owner privilege, including invitations issued by other users.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document, issuer=user)
other_invitations = factories.InvitationFactory.create_batch(
2, document=document, issuer=other_user
)
# invitations from other documents should not be listed
other_document = factories.DocumentFactory()
factories.InvitationFactory.create_batch(2, document=other_document)
client = APIClient()
client.force_login(user)
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/invitations/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(i.id),
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
"email": str(i.email),
"document": str(document.id),
"role": i.role,
"issuer": str(i.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"retrieve": True,
},
}
for i in [invitation, *other_invitations]
],
key=lambda x: x["created_at"],
)
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["reader", "editor"])
def test_api_document_invitations_list_authenticated_unprivileged(
role, via, mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should not be able to list invitations for documents to which they are
related with reader or editor role, including invitations issued by other users.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
factories.InvitationFactory(document=document, issuer=user)
factories.InvitationFactory.create_batch(2, document=document, issuer=other_user)
# invitations from other documents should not be listed
other_document = factories.DocumentFactory()
factories.InvitationFactory.create_batch(2, document=other_document)
client = APIClient()
client.force_login(user)
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/invitations/",
)
assert response.status_code == 200
assert response.json()["count"] == 0
def test_api_document_invitations_list_expired_invitations_still_listed():
"""
Expired invitations are still listed.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory(
users=[(user, "administrator"), (other_user, "owner")]
)
expired_invitation = factories.InvitationFactory(
document=document,
role="reader",
issuer=user,
)
client = APIClient()
client.force_login(user)
# mock timezone.now to accelerate validation expiration
too_late = timezone.now() + timedelta(seconds=604800) # 7 days
with mock.patch("django.utils.timezone.now", return_value=too_late):
assert expired_invitation.is_expired is True
response = client.get(
f"/api/v1.0/documents/{document.id!s}/invitations/",
)
assert response.status_code == 200
assert response.json()["count"] == 1
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(expired_invitation.id),
"created_at": expired_invitation.created_at.isoformat().replace(
"+00:00", "Z"
),
"email": str(expired_invitation.email),
"document": str(document.id),
"role": expired_invitation.role,
"issuer": str(expired_invitation.issuer.id),
"is_expired": True,
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
},
],
key=lambda x: x["created_at"],
)
# Retrieve
def test_api_document_invitations_retrieve_anonymous_user():
"""
Anonymous users should not be able to retrieve invitations.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 401
def test_api_document_invitations_retrieve_unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 403
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_document_invitations_retrieve_document_privileged(
role, via, mock_user_teams
):
"""
Authenticated users related to the document should be able to retrieve invitations
provided they are administrators or owners of the document.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
"email": invitation.email,
"document": str(invitation.document.id),
"role": str(invitation.role),
"issuer": str(invitation.issuer.id),
"is_expired": False,
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["reader", "editor"])
def test_api_document_invitations_retrieve_document_unprivileged(
role, via, mock_user_teams
):
"""
Authenticated users related to the document should not be able to retrieve invitations
if they are simply reader or editor of the document.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 403
assert response.content
# Create
def test_api_document_invitations_create_anonymous():
"""Anonymous users should not be able to create invitations."""
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_invitations_create_authenticated_outsider():
"""Users outside of document should not be permitted to invite to document."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 403
@pytest.mark.parametrize(
"inviting,invited,response_code",
(
["reader", "reader", 403],
["reader", "editor", 403],
["reader", "administrator", 403],
["reader", "owner", 403],
["editor", "reader", 403],
["editor", "editor", 403],
["editor", "administrator", 403],
["editor", "owner", 403],
["administrator", "reader", 201],
["administrator", "editor", 201],
["administrator", "administrator", 201],
["administrator", "owner", 400],
["owner", "reader", 201],
["owner", "editor", 201],
["owner", "administrator", 201],
["owner", "owner", 201],
),
)
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations_create_privileged_members(
via, inviting, invited, response_code, mock_user_teams
):
"""
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=inviting
)
invitation_values = {
"email": "guest@example.com",
"role": invited,
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == response_code
if response_code == 201:
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
else:
assert models.Invitation.objects.exists() is False
if response_code == 400:
assert response.json() == {
"role": [
"Only owners of a document can invite other users as owners.",
],
}
def test_api_document_invitations_create_email_from_content_language():
"""
The email generated is from the language set in the Content-Language header
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "fr-fr"},
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} a partagé un document avec vous: {document.title}"
in email_content
)
def test_api_document_invitations_create_email_from_content_language_not_supported():
"""
If the language from the Content-Language is not supported
it will display the default language, English.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
def test_api_document_invitations_create_email_full_name_empty():
"""
If the full name of the user is empty, it will display the email address.
"""
user = factories.UserFactory(full_name="")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert (
f'{user.email} invited you with the role "reader" on the '
f"following document : {document.title}" in email_content
)
def test_api_document_invitations_create_issuer_and_document_override():
"""It should not be possible to set the "document" and "issuer" fields."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
other_document = factories.DocumentFactory(users=[(user, "owner")])
invitation_values = {
"document": str(other_document.id),
"issuer": str(factories.UserFactory().id),
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 201
# document and issuer automatically set
assert response.json()["document"] == str(document.id)
assert response.json()["issuer"] == str(user.id)
def test_api_document_invitations_create_cannot_duplicate_invitation():
"""An email should not be invited multiple times to the same document."""
existing_invitation = factories.InvitationFactory()
document = existing_invitation.document
# Grant privileged role on the Document to the user
user = factories.UserFactory()
models.DocumentAccess.objects.create(
document=document, user=user, role="administrator"
)
# Create a new invitation to the same document with the exact same email address
invitation_values = {
"email": existing_invitation.email,
"role": random.choice(["administrator", "editor", "reader"]),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 400
assert response.json() == [
"Document invitation with this Email address and Document already exists."
]
def test_api_document_invitations_create_cannot_invite_existing_users():
"""
It should not be possible to invite already existing users.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an exising identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 400
assert response.json() == ["This email is already associated to a registered user."]
# Update
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_document_invitations_update_authenticated_privileged_any_field_except_role(
role, via, mock_user_teams
):
"""
Authenticated user can update invitations if they are administrator or owner of the document.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
old_invitation_values = serializers.InvitationSerializer(instance=invitation).data
new_invitation_values = serializers.InvitationSerializer(
instance=factories.InvitationFactory()
).data
# The update of a role is tested in the next test
del new_invitation_values["role"]
client = APIClient()
client.force_login(user)
url = (
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/"
)
response = client.put(url, new_invitation_values, format="json")
assert response.status_code == 200
invitation.refresh_from_db()
invitation_values = serializers.InvitationSerializer(instance=invitation).data
for key, value in invitation_values.items():
if key == "email":
assert value == new_invitation_values[key]
elif key == "updated_at":
assert value > old_invitation_values[key]
else:
assert value == old_invitation_values[key]
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role_set", models.RoleChoices.values)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_document_invitations_update_authenticated_privileged_role(
role, role_set, via, mock_user_teams
):
"""
Authenticated user can update invitations if they are administrator or owner of the document,
but only owners can set the invitation role to the "owner" role.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
old_role = invitation.role
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
new_invitation_values = serializers.InvitationSerializer(instance=invitation).data
new_invitation_values["role"] = role_set
client = APIClient()
client.force_login(user)
url = (
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/"
)
response = client.put(url, new_invitation_values, format="json")
invitation.refresh_from_db()
if role_set == "owner" and role != "owner":
assert response.status_code == 400
assert invitation.role == old_role
assert response.json() == {
"role": [
"Only owners of a document can invite other users as owners.",
],
}
else:
assert response.status_code == 200
assert invitation.role == role_set
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["reader", "editor"])
def test_api_document_invitations_update_authenticated_unprivileged(
role, via, mock_user_teams
):
"""
Authenticated user should not be allowed to update invitations if they are
simple reader or editor of the document.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
old_invitation_values = serializers.InvitationSerializer(instance=invitation).data
new_invitation_values = serializers.InvitationSerializer(
instance=factories.InvitationFactory()
).data
client = APIClient()
client.force_login(user)
url = (
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/"
)
response = client.put(url, new_invitation_values, format="json")
assert response.status_code == 403
invitation.refresh_from_db()
invitation_values = serializers.InvitationSerializer(instance=invitation).data
for key, value in invitation_values.items():
assert value == old_invitation_values[key]
# Delete
def test_api_document_invitations_delete_anonymous():
"""Anonymous user should not be able to delete invitations."""
invitation = factories.InvitationFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 401
def test_api_document_invitations_delete_authenticated_outsider():
"""Members unrelated to a document should not be allowed to cancel invitations."""
user = factories.UserFactory(with_owned_document=True)
document = factories.DocumentFactory()
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 403
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_document_invitations_delete_privileged_members(role, via, mock_user_teams):
"""Privileged member should be able to cancel invitation."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 204
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user_teams):
"""Readers or editors should not be able to cancel invitation."""
user = factories.UserFactory(with_owned_document=True)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 403
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)

View File

@@ -1,346 +0,0 @@
"""
Test AI transform API endpoint for users in impress's core app.
"""
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI transform if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "hello", "action": "prompt"})
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Summarize the markdown text, preserving language and markdown formatting. "
'Return JSON: {"answer": "your markdown summary"}. Do not provide any other '
"information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI transform if the
link reach and role don't allow it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Autenticated who are not related to a document should be able to request AI transform
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_transform_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to request AI transform.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to request AI transform.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
def test_api_documents_ai_transform_empty_text():
"""The text should not be empty when requesting AI transform."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": " ", "action": "prompt"})
assert response.status_code == 400
assert response.json() == {"text": ["This field may not be blank."]}
def test_api_documents_ai_transform_invalid_action():
"""The action should valid when requesting AI transform."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "invalid"})
assert response.status_code == 400
assert response.json() == {"action": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}

View File

@@ -1,370 +0,0 @@
"""
Test AI translate API endpoint for users in impress's core app.
"""
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
def test_api_documents_ai_translate_viewset_options_metadata():
"""The documents endpoint should give us the list of available languages."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory(link_reach="public", link_role="editor")
response = APIClient().options("/api/v1.0/documents/")
assert response.status_code == 200
metadata = response.json()
assert metadata["name"] == "Document List"
assert metadata["actions"]["POST"]["language"]["choices"][0] == {
"value": "af",
"display_name": "Afrikaans",
}
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI translate if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "hello", "language": "es"})
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Spanish, preserving markdown formatting. "
'Return JSON: {"answer": "your translated markdown text in Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI translate if the
link reach and role don't allow it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Autenticated who are not related to a document should be able to request AI translate
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_translate_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to request AI translate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to request AI translate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
def test_api_documents_ai_translate_empty_text():
"""The text should not be empty when requesting AI translate."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": " ", "language": "es"})
assert response.status_code == 400
assert response.json() == {"text": ["This field may not be blank."]}
def test_api_documents_ai_translate_invalid_action():
"""The action should valid when requesting AI translate."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "invalid"})
assert response.status_code == 400
assert response.json() == {"language": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}

View File

@@ -1,337 +0,0 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import re
import uuid
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
PIXEL = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_attachment_upload_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to upload attachments if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_documents_attachment_upload_anonymous_success():
"""
Anonymous users should be able to upload attachments to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't upload attachments if the
link reach and role don't allow it.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to upload an attachment.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id)}
def test_api_documents_attachment_upload_invalid(client):
"""Attempt to upload without a file should return an explicit error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["No file was submitted."]}
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceeed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
# Create a temporary file larger than the allowed size
file = SimpleUploadedFile(
name="test.txt", content=b"a" * (1048576 + 1), content_type="text/plain"
)
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
@pytest.mark.parametrize(
"name,content,extension",
[
("test.exe", b"text", "exe"),
("test", b"text", "txt"),
("test.aaaaaa", b"test", "txt"),
("test.txt", PIXEL, "txt"),
("test.py", b"#!/usr/bin/python", "py"),
],
)
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
"""
A file with no extension or a wrong extension is accepted and the extension
is corrected in storage.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(name=name, content=content)
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.{extension:s}")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
def test_api_documents_attachment_upload_empty_file():
"""An empty file should be rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(name="test.png", content=b"")
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
def test_api_documents_attachment_upload_unsafe():
"""A file with an unsafe mime type should be tagged as such."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
)
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.exe")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}

View File

@@ -1,8 +1,7 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
from uuid import uuid4
import uuid
import pytest
from rest_framework.test import APIClient
@@ -26,7 +25,7 @@ def test_api_documents_create_anonymous():
assert not Document.objects.exists()
def test_api_documents_create_authenticated_success():
def test_api_documents_create_authenticated():
"""
Authenticated users should be able to create documents and should automatically be declared
as the owner of the newly created document.
@@ -47,68 +46,27 @@ def test_api_documents_create_authenticated_success():
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.link_reach == "restricted"
assert document.accesses.filter(role="owner", user=user).exists()
def test_api_documents_create_authenticated_title_null():
"""It should be possible to create several documents with a null title."""
def test_api_documents_create_with_id_from_payload():
"""
We should be able to create a document with an ID from the payload.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory(title=None)
response = client.post("/api/v1.0/documents/", {}, format="json")
assert response.status_code == 201
assert Document.objects.filter(title__isnull=True).count() == 2
def test_api_documents_create_force_id_success():
"""It should be possible to force the document ID when creating a document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
forced_id = uuid4()
doc_id = uuid.uuid4()
response = client.post(
"/api/v1.0/documents/",
{
"id": str(forced_id),
"title": "my document",
},
{"title": "my document", "id": str(doc_id)},
format="json",
)
assert response.status_code == 201
documents = Document.objects.all()
assert len(documents) == 1
assert documents[0].id == forced_id
def test_api_documents_create_force_id_existing():
"""
It should not be possible to use the ID of an existing document when forcing ID on creation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
response = client.post(
"/api/v1.0/documents/",
{
"id": str(document.id),
"title": "my document",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"id": ["A document with this ID already exists. You cannot override it."]
}
document = Document.objects.get()
assert document.title == "my document"
assert document.id == doc_id
assert document.accesses.filter(role="owner", user=user).exists()

View File

@@ -1,6 +1,7 @@
"""
Tests for Documents API endpoint in impress's core app: delete
"""
import random
import pytest
from rest_framework.test import APIClient
@@ -23,36 +24,35 @@ def test_api_documents_delete_anonymous():
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_documents_delete_authenticated_unrelated(reach, role):
def test_api_documents_delete_authenticated_unrelated():
"""
Authenticated users should not be allowed to delete a document to which
they are not related.
Authenticated users should not be allowed to delete a document to which they are not
related.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
is_public = random.choice([True, False])
document = factories.DocumentFactory(is_public=is_public)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 403
assert models.Document.objects.count() == 2
assert response.status_code == 403 if is_public else 404
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams):
def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a document for which they are
only a reader, editor or administrator.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -61,7 +61,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -74,11 +74,11 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Document.objects.count() == 2
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
def test_api_documents_delete_authenticated_owner(via, mock_user_get_teams):
"""
Authenticated users should be able to delete a document they own.
"""
@@ -91,7 +91,7 @@ def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)

View File

@@ -1,152 +0,0 @@
"""Tests for link configuration of documents on API endpoint"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_link_configuration_update_anonymous(reach, role):
"""Anonymous users should not be allowed to update a link configuration."""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_link_configuration_update_authenticated_unrelated(reach, role):
"""
Authenticated users should not be allowed to update the link configuration for
a document to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("role", ["editor", "reader"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_link_configuration_update_authenticated_related_forbidden(
via, role, mock_user_teams
):
"""
Users who are readers or editors of a document should not be allowed to update
the link configuration.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("role", ["administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_link_configuration_update_authenticated_related_success(
via, role, mock_user_teams
):
"""
A user who is administrator or owner of a document should be allowed to update
the link configuration.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]

View File

@@ -1,72 +1,68 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import operator
from unittest import mock
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories, models
from core import factories
fake = Faker()
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_list_anonymous(reach, role):
"""
Anonymous users should not be allowed to list documents whatever the
link reach and the role
"""
factories.DocumentFactory(link_reach=reach, link_role=role)
def test_api_documents_list_anonymous():
"""Anonymous users should only be able to list public documents."""
factories.DocumentFactory.create_batch(2, is_public=False)
documents = factories.DocumentFactory.create_batch(2, is_public=True)
expected_ids = {str(document.id) for document in documents}
response = APIClient().get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
def test_api_documents_list_authenticated_direct():
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of or documents that have a link reach other
than restricted.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = [
access.document
for access in factories.UserDocumentAccessFactory.create_batch(2, user=user)
]
# Unrelated and untraced documents
for reach in models.LinkReachChoices:
for role in models.LinkRoleChoices:
factories.DocumentFactory(link_reach=reach, link_role=role)
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_teams):
def test_api_documents_list_authenticated_direct():
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
related_documents = [
access.document
for access in factories.UserDocumentAccessFactory.create_batch(5, user=user)
]
public_documents = factories.DocumentFactory.create_batch(2, is_public=True)
factories.DocumentFactory.create_batch(2, is_public=False)
expected_ids = {
str(document.id) for document in related_documents + public_documents
}
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_get_teams):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
@@ -76,7 +72,7 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
documents_team1 = [
access.document
@@ -86,71 +82,19 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
access.document
for access in factories.TeamDocumentAccessFactory.create_batch(3, team="team2")
]
public_documents = factories.DocumentFactory.create_batch(2, is_public=True)
factories.DocumentFactory.create_batch(2, is_public=False)
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
expected_ids = {
str(document.id)
for document in documents_team1 + documents_team2 + public_documents
}
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_link_reach_restricted():
"""
An authenticated user who has link traces to a document that is restricted should not
see it on the list view
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_traces=[user], link_reach="restricted")
# Link traces for other documents or other users should not interfere
models.LinkTrace.objects.create(document=document, user=factories.UserFactory())
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
# Only the other document is returned but not the restricted document even though the user
# visited it earlier (probably b/c it previously had public or authenticated reach...)
assert len(results) == 1
assert results[0]["id"] == str(other_document.id)
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
"""
An authenticated user who has link traces to a document with public or authenticated
link reach should see it on the list view.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = [
factories.DocumentFactory(link_traces=[user], link_reach=reach)
for reach in models.LinkReachChoices
if reach != "restricted"
]
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@@ -175,7 +119,7 @@ def test_api_documents_list_pagination(
"/api/v1.0/documents/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
@@ -191,7 +135,7 @@ def test_api_documents_list_pagination(
"/api/v1.0/documents/?page=2",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
@@ -212,63 +156,204 @@ def test_api_documents_list_authenticated_distinct():
other_user = factories.UserFactory()
document = factories.DocumentFactory(users=[user, other_user])
document = factories.DocumentFactory(users=[user, other_user], is_public=True)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_order_created_at_desc():
"""
Test that the endpoint GET documents is sorted in 'created_at' descending order by default.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_created = [
document.created_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_created.sort(reverse=True)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)
response_data = response.json()
response_document_created = [
document["created_at"] for document in response_data["results"]
]
assert (
response_document_created == documents_created
), "created_at values are not sorted from newest to oldest"
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
def test_api_documents_order_created_at_asc():
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
documents_created = [
document.created_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
response = client.get("/api/v1.0/documents/")
documents_created.sort()
response = client.get(
"/api/v1.0/documents/?ordering=created_at",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
response_data = response.json()
response_document_created = [
document["created_at"] for document in response_data["results"]
]
assert (
response_document_created == documents_created
), "created_at values are not sorted from oldest to newest"
def test_api_documents_list_ordering_by_fields():
"""It should be possible to order by several fields"""
def test_api_documents_order_updated_at_desc():
"""
Test that the 'updated_at' field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
"title",
"-title",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
documents_updated.sort(reverse=True)
response = client.get(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
response = APIClient().get(
"/api/v1.0/documents/?ordering=-updated_at",
)
assert response.status_code == 200
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
response_data = response.json()
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
assert (
response_document_updated == documents_updated
), "updated_at values are not sorted from newest to oldest"
def test_api_documents_order_updated_at_asc():
"""
Test that the 'updated_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_updated.sort()
response = APIClient().get(
"/api/v1.0/documents/?ordering=updated_at",
)
assert response.status_code == 200
response_data = response.json()
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
assert (
response_document_updated == documents_updated
), "updated_at values are not sorted from oldest to newest"
def test_api_documents_order_title_desc():
"""
Test that the 'title' field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_title = [
factories.DocumentFactory(is_public=True, title=fake.sentence(nb_words=4)).title
for _ in range(5)
]
documents_title.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/?ordering=-title",
)
assert response.status_code == 200
response_data = response.json()
response_documents_title = [
document["title"] for document in response_data["results"]
]
assert (
response_documents_title == documents_title
), "title values are not sorted descending"
def test_api_documents_order_title_asc():
"""
Test that the 'title' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_title = [
factories.DocumentFactory(is_public=True, title=fake.sentence(nb_words=4)).title
for _ in range(5)
]
documents_title.sort()
response = APIClient().get(
"/api/v1.0/documents/?ordering=title",
)
assert response.status_code == 200
response_data = response.json()
response_documents_title = [
document["title"] for document in response_data["results"]
]
assert (
response_documents_title == documents_title
), "title values are not sorted ascending"

View File

@@ -1,11 +1,10 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
@@ -13,7 +12,7 @@ pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public documents."""
document = factories.DocumentFactory(link_reach="public")
document = factories.DocumentFactory(is_public=True)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
@@ -21,46 +20,35 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"destroy": False,
"invite_owner": False,
"link_configuration": False,
"partial_update": document.link_role == "editor",
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": document.link_role == "editor",
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
def test_api_documents_retrieve_anonymous_restricted_or_authenticated(reach):
def test_api_documents_retrieve_anonymous_not_public():
"""Anonymous users should not be able to retrieve a document that is not public."""
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=False)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(reach):
def test_api_documents_retrieve_authenticated_unrelated_public():
"""
Authenticated users should be able to retrieve a public document to which they are
not related.
@@ -70,7 +58,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=True)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
@@ -79,82 +67,41 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"invite_owner": False,
"partial_update": document.link_role == "editor",
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": document.link_role == "editor",
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_trace_twice(reach):
def test_api_documents_retrieve_authenticated_unrelated_not_public():
"""
Accessing a document several times should not raise any error even though the
trace already exists for this document and user.
Authenticated users should not be allowed to retrieve a document that is not public and
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
# A second visit should not raise any error
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
def test_api_documents_retrieve_authenticated_unrelated_restricted():
"""
Authenticated users should not be allowed to retrieve a document that is restricted and
to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_documents_retrieve_authenticated_related_direct():
@@ -202,26 +149,25 @@ def test_api_documents_retrieve_authenticated_related_direct():
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"link_reach": document.link_reach,
"link_role": document.link_role,
"is_public": document.is_public,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams):
def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_teams):
"""
Authenticated users should not be able to retrieve a restricted document related to
teams in which the user is not.
Authenticated users should not be able to retrieve a document related to teams in
which the user is not.
"""
mock_user_teams.return_value = []
mock_user_get_teams.return_value = []
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -237,10 +183,8 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize(
@@ -253,20 +197,20 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -284,8 +228,6 @@ def test_api_documents_retrieve_authenticated_related_team_members(
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
expected_abilities = {
@@ -340,8 +282,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -356,20 +297,20 @@ def test_api_documents_retrieve_authenticated_related_team_members(
],
)
def test_api_documents_retrieve_authenticated_related_team_administrators(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -460,8 +401,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -477,20 +417,20 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
],
)
def test_api_documents_retrieve_authenticated_related_team_owners(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
they are related via a team whatever the role and see all its accesses.
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -584,8 +524,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}

View File

@@ -1,214 +0,0 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import uuid
from io import BytesIO
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document = factories.DocumentFactory(link_reach="public")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach):
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
with link reach set to authenticated or restricted.
"""
document = factories.DocumentFactory(link_reach=reach)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
assert "Authorization" not in response
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach):
"""
Authenticated users who are not related to a document should be able to retrieve
attachments related to a document with public or authenticated link reach.
"""
document = factories.DocumentFactory(link_reach=reach)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_authenticated_restricted():
"""
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is restricted.
"""
document = factories.DocumentFactory(link_reach="restricted")
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
assert "Authorization" not in response
@pytest.mark.parametrize("via", VIA)
def test_api_documents_retrieve_auth_related(via, mock_user_teams):
"""
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"

View File

@@ -1,11 +1,8 @@
"""
Tests for Documents API endpoint in impress's core app: update
"""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
@@ -16,22 +13,9 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_update_anonymous_forbidden(reach, role):
"""
Anonymous users should not be allowed to update a document when link
configuration does not allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
def test_api_documents_update_anonymous():
"""Anonymous users should not be allowed to update a document."""
document = factories.DocumentFactory()
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -52,28 +36,18 @@ def test_api_documents_update_anonymous_forbidden(reach, role):
assert document_values == old_document_values
@pytest.mark.parametrize(
"reach,role",
[
("public", "reader"),
("authenticated", "reader"),
("restricted", "reader"),
("restricted", "editor"),
],
)
def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
def test_api_documents_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a document to which
they are not related if the link configuration does not allow it.
Authenticated users should not be allowed to update a document to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
document = factories.DocumentFactory(is_public=False)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
@@ -83,79 +57,30 @@ def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
document.refresh_from_db()
document_values = serializers.DocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
(False, "public", "editor"),
(True, "public", "editor"),
(True, "authenticated", "editor"),
],
)
def test_api_documents_update_anonymous_or_authenticated_unrelated(
is_authenticated, reach, role
):
"""
Authenticated users should be able to update a document to which
they are not related if the link configuration allows it.
"""
client = APIClient()
if is_authenticated:
user = factories.UserFactory(with_owned_document=True)
client.force_login(user)
else:
user = AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_reader(via, mock_user_teams):
def test_api_documents_update_authenticated_reader(via, mock_user_get_teams):
"""
Users who are reader of a document but not administrators should
Users who are editors or reader of a document but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
@@ -184,10 +109,10 @@ def test_api_documents_update_authenticated_reader(via, mock_user_teams):
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_editor_administrator_or_owner(
via, role, mock_user_teams
via, role, mock_user_get_teams
):
"""A user who is editor, administrator or owner of a document should be allowed to update it."""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -196,7 +121,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -216,7 +141,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -225,9 +150,9 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_owners(via, mock_user_teams):
def test_api_documents_update_authenticated_owners(via, mock_user_get_teams):
"""Administrators of a document should be allowed to update it."""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -236,7 +161,7 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -255,7 +180,7 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -264,12 +189,14 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
def test_api_documents_update_administrator_or_owner_of_another(
via, mock_user_get_teams
):
"""
Being administrator or owner of a document should not grant authorization to update
another document.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -280,27 +207,28 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
document=document, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
old_document_values = serializers.DocumentSerializer(instance=other_document).data
is_public = random.choice([True, False])
document = factories.DocumentFactory(title="Old title", is_public=is_public)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{other_document.id!s}/",
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.status_code == 403 if is_public else 404
other_document.refresh_from_db()
other_document_values = serializers.DocumentSerializer(instance=other_document).data
assert other_document_values == old_document_values
document.refresh_from_db()
document_values = serializers.DocumentSerializer(instance=document).data
assert document_values == old_document_values

View File

@@ -1,7 +1,6 @@
"""
Test suite for generated openapi schema.
"""
import json
from io import StringIO

View File

@@ -1,206 +0,0 @@
"""
Test template accesses create API endpoint for users in impress's core app.
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
template = factories.TemplateFactory()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: create
"""
import pytest
from rest_framework.test import APIClient

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: delete
"""
import random
import pytest
@@ -49,7 +48,7 @@ def test_api_templates_delete_authenticated_unrelated():
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_member_or_administrator(
via, role, mock_user_teams
via, role, mock_user_get_teams
):
"""
Authenticated users should not be allowed to delete a template for which they are
@@ -64,7 +63,7 @@ def test_api_templates_delete_authenticated_member_or_administrator(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -81,7 +80,7 @@ def test_api_templates_delete_authenticated_member_or_administrator(
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
def test_api_templates_delete_authenticated_owner(via, mock_user_get_teams):
"""
Authenticated users should be able to delete a template they own.
"""
@@ -94,7 +93,7 @@ def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)

View File

@@ -1,7 +1,6 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -44,10 +43,8 @@ def test_api_templates_generate_document_anonymous_not_public():
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_templates_generate_document_authenticated_public():
@@ -89,24 +86,22 @@ def test_api_templates_generate_document_authenticated_not_public():
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
def test_api_templates_generate_document_related(via, mock_user_teams):
def test_api_templates_generate_document_related(via, mock_user_get_teams):
"""Users related to a template can generate pdf document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(team="lasuite")
data = {"body": "# Test markdown body"}
@@ -183,26 +178,3 @@ def test_api_templates_generate_document_type_unknown():
'"unknown" is not a valid choice.',
]
}
def test_api_templates_generate_document_export_docx():
"""Generate pdf document with the body type html."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "<p>Test body</p>", "body_type": "html", "format": "docx"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert (
response.headers["content-type"]
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)

View File

@@ -1,11 +1,11 @@
"""
Tests for Templates API endpoint in impress's core app: list
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
@@ -16,12 +16,12 @@ pytestmark = pytest.mark.django_db
def test_api_templates_list_anonymous():
"""Anonymous users should only be able to list public templates."""
factories.TemplateFactory.create_batch(2, is_public=False)
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in public_templates}
templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in templates}
response = APIClient().get("/api/v1.0/templates/")
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
@@ -31,7 +31,7 @@ def test_api_templates_list_anonymous():
def test_api_templates_list_authenticated_direct():
"""
Authenticated users should be able to list templates they are a direct
owner/administrator/member of or that are public.
owner/administrator/member of.
"""
user = factories.UserFactory()
@@ -53,24 +53,24 @@ def test_api_templates_list_authenticated_direct():
"/api/v1.0/templates/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated_via_team(mock_user_teams):
def test_api_templates_list_authenticated_via_team(mock_user_get_teams):
"""
Authenticated users should be able to list templates they are a
owner/administrator/member of via a team or that are public.
owner/administrator/member of via a team.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
templates_team1 = [
access.template
@@ -90,7 +90,7 @@ def test_api_templates_list_authenticated_via_team(mock_user_teams):
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
@@ -117,7 +117,7 @@ def test_api_templates_list_pagination(
"/api/v1.0/templates/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
@@ -133,7 +133,7 @@ def test_api_templates_list_pagination(
"/api/v1.0/templates/?page=2",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
@@ -160,24 +160,26 @@ def test_api_templates_list_authenticated_distinct():
"/api/v1.0/templates/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(template.id)
def test_api_templates_list_order_default():
"""The templates list should be sorted by 'created_at' in descending order by default."""
def test_api_templates_order():
"""
Test that the endpoint GET templates is sorted in 'created_at' descending order by default.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template_ids = [
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
str(template.id)
for template in factories.TemplateFactory.create_batch(5, is_public=True)
]
response = client.get(
response = APIClient().get(
"/api/v1.0/templates/",
)
@@ -192,21 +194,21 @@ def test_api_templates_list_order_default():
), "created_at values are not sorted from newest to oldest"
def test_api_templates_list_order_param():
def test_api_templates_order_param():
"""
The templates list is sorted by 'created_at' in ascending order when setting
the "ordering" query parameter.
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
templates_ids = [
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
str(template.id)
for template in factories.TemplateFactory.create_batch(5, is_public=True)
]
response = client.get(
response = APIClient().get(
"/api/v1.0/templates/?ordering=created_at",
)
assert response.status_code == 200

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
@@ -22,7 +21,7 @@ def test_api_templates_retrieve_anonymous_public():
"abilities": {
"destroy": False,
"generate_document": True,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -41,10 +40,8 @@ def test_api_templates_retrieve_anonymous_not_public():
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_templates_retrieve_authenticated_unrelated_public():
@@ -68,7 +65,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
"abilities": {
"destroy": False,
"generate_document": True,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -96,10 +93,8 @@ def test_api_templates_retrieve_authenticated_unrelated_not_public():
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_templates_retrieve_authenticated_related_direct():
@@ -150,12 +145,12 @@ def test_api_templates_retrieve_authenticated_related_direct():
}
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams):
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_teams):
"""
Authenticated users should not be able to retrieve a template related to teams in
which the user is not.
"""
mock_user_teams.return_value = []
mock_user_get_teams.return_value = []
user = factories.UserFactory()
@@ -178,10 +173,8 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize(
@@ -194,13 +187,13 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams)
],
)
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
@@ -293,13 +286,13 @@ def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
],
)
def test_api_templates_retrieve_authenticated_related_team_administrators(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
@@ -411,13 +404,13 @@ def test_api_templates_retrieve_authenticated_related_team_administrators(
],
)
def test_api_templates_retrieve_authenticated_related_team_owners(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: update
"""
import random
import pytest
@@ -58,10 +57,8 @@ def test_api_templates_update_authenticated_unrelated():
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
@@ -69,7 +66,7 @@ def test_api_templates_update_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
def test_api_templates_update_authenticated_readers(via, mock_user_get_teams):
"""
Users who are readers of a template should not be allowed to update it.
"""
@@ -82,7 +79,7 @@ def test_api_templates_update_authenticated_readers(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
)
@@ -111,7 +108,7 @@ def test_api_templates_update_authenticated_readers(via, mock_user_teams):
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
via, role, mock_user_teams
via, role, mock_user_get_teams
):
"""Administrator or owner of a template should be allowed to update it."""
user = factories.UserFactory()
@@ -123,7 +120,7 @@ def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -150,7 +147,7 @@ def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
def test_api_templates_update_authenticated_owners(via, mock_user_get_teams):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
@@ -161,7 +158,7 @@ def test_api_templates_update_authenticated_owners(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -187,7 +184,9 @@ def test_api_templates_update_authenticated_owners(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
def test_api_templates_update_administrator_or_owner_of_another(
via, mock_user_get_teams
):
"""
Being administrator or owner of a template should not grant authorization to update
another template.
@@ -203,7 +202,7 @@ def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_t
template=template, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template,
team="lasuite",

View File

@@ -1,7 +1,6 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -57,7 +56,7 @@ def test_api_document_accesses_list_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
def test_api_document_accesses_list_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
@@ -68,7 +67,6 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
client.force_login(user)
document = factories.DocumentFactory()
user_access = None
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
@@ -76,7 +74,7 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
@@ -149,7 +147,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a document access for
a document to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -175,13 +173,11 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {
"detail": "No DocumentAccess matches the given query."
}
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_teams):
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_get_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
@@ -195,7 +191,7 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_tea
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
access = factories.UserDocumentAccessFactory(document=document)
@@ -216,6 +212,203 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_tea
}
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
user = factories.UserFactory()
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.DocumentAccess.objects.exists() is False
def test_api_document_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document accesses for a document to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_reader_or_editor(
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to create document accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"id": str(new_document_access.id),
"team": "",
"role": role,
"user": other_user,
}
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
def test_api_document_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a document access."""
access = factories.UserDocumentAccessFactory()
@@ -246,7 +439,7 @@ def test_api_document_accesses_update_authenticated_unrelated():
Authenticated users should not be allowed to update a document access for a document to which
they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -276,10 +469,10 @@ def test_api_document_accesses_update_authenticated_unrelated():
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_authenticated_reader_or_editor(
via, role, mock_user_teams
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to update its accesses."""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -288,7 +481,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -316,12 +509,14 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_except_owner(
via, mock_user_get_teams
):
"""
A user who is a direct administrator in a document should be allowed to update a user
access for this document, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -332,7 +527,7 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -373,12 +568,14 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_from_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_from_owner(
via, mock_user_get_teams
):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of an "owner" for this document.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -389,7 +586,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -420,12 +617,12 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_get_teams):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of another user to grant document ownership.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -436,7 +633,7 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -474,12 +671,12 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner(via, mock_user_teams):
def test_api_document_accesses_update_owner(via, mock_user_get_teams):
"""
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -488,7 +685,7 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -530,24 +727,23 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
def test_api_document_accesses_update_owner_self(via, mock_user_get_teams):
"""
A user who is owner of a document should be allowed to update
their own user access provided there are other owners in the document.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -605,7 +801,7 @@ def test_api_document_accesses_delete_authenticated():
Authenticated users should not be allowed to delete a document access for a
document to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -617,17 +813,17 @@ def test_api_document_accesses_delete_authenticated():
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_teams):
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a document access for a
document in which they are a simple reader or editor.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -636,14 +832,14 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
access = factories.UserDocumentAccessFactory(document=document)
assert models.DocumentAccess.objects.count() == 3
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -651,12 +847,12 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 3
assert models.DocumentAccess.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrators_except_owners(
via, mock_user_teams
via, mock_user_get_teams
):
"""
Users who are administrators in a document should be allowed to delete an access
@@ -673,7 +869,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -694,12 +890,12 @@ def test_api_document_accesses_delete_administrators_except_owners(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_teams):
def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_get_teams):
"""
Users who are administrators in a document should not be allowed to delete an ownership
access from the document.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -710,14 +906,14 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
access = factories.UserDocumentAccessFactory(document=document, role="owner")
assert models.DocumentAccess.objects.count() == 3
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -725,11 +921,11 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 3
assert models.DocumentAccess.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners(via, mock_user_teams):
def test_api_document_accesses_delete_owners(via, mock_user_get_teams):
"""
Users should be able to delete the document access of another user
for a document of which they are owner.
@@ -743,7 +939,7 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -762,31 +958,30 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_get_teams):
"""
It should not be possible to delete the last owner access from a document
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 1
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 1

View File

@@ -0,0 +1,504 @@
"""
Unit tests for the Invitation model
"""
import random
import time
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
response = APIClient().post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_invitations__create__authenticated_outsider():
"""Users outside of document should not be permitted to invite to document."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"inviting,invited,is_allowed",
(
["reader", "reader", False],
["reader", "editor", False],
["reader", "administrator", False],
["reader", "owner", False],
["editor", "reader", False],
["editor", "editor", False],
["editor", "administrator", False],
["editor", "owner", False],
["administrator", "reader", True],
["administrator", "editor", True],
["administrator", "administrator", True],
["administrator", "owner", False],
["owner", "reader", True],
["owner", "editor", True],
["owner", "administrator", True],
["owner", "owner", True],
),
)
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__create__privileged_members(
via, inviting, invited, is_allowed, mock_user_get_teams
):
"""
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=inviting
)
invitation_values = {
"email": "guest@example.com",
"role": invited,
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
if is_allowed:
assert response.status_code == status.HTTP_201_CREATED
assert models.Invitation.objects.count() == 1
else:
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Invitation.objects.exists() is False
def test_api_document_invitations__create__issuer_and_document_override():
"""It should not be possible to set the "document" and "issuer" fields."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
other_document = factories.DocumentFactory(users=[(user, "owner")])
invitation_values = {
"document": str(other_document.id),
"issuer": str(factories.UserFactory().id),
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# document and issuer automatically set
assert response.json()["document"] == str(document.id)
assert response.json()["issuer"] == str(user.id)
def test_api_document_invitations__create__cannot_duplicate_invitation():
"""An email should not be invited multiple times to the same document."""
existing_invitation = factories.InvitationFactory()
document = existing_invitation.document
# Grant privileged role on the Document to the user
user = factories.UserFactory()
models.DocumentAccess.objects.create(
document=document, user=user, role="administrator"
)
# Create a new invitation to the same document with the exact same email address
invitation_values = {
"email": existing_invitation.email,
"role": random.choice(["administrator", "editor", "reader"]),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Document invitation with this Email address and Document already exists."
]
def test_api_document_invitations__create__cannot_invite_existing_users():
"""
It should not be possible to invite already existing users.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an exising identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
def test_api_document_invitations__list__anonymous_user():
"""Anonymous users should not be able to list invitations."""
document = factories.DocumentFactory()
response = APIClient().get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__list__authenticated(
via, mock_user_get_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list invitations for documents to which they are
related, whatever the role and including invitations issued by other users.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory()
role = random.choice(models.RoleChoices.choices)[0]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(
document=document, role="administrator", issuer=user
)
other_invitations = factories.InvitationFactory.create_batch(
2, document=document, role="reader", issuer=other_user
)
# invitations from other documents should not be listed
other_document = factories.DocumentFactory()
factories.InvitationFactory.create_batch(2, document=other_document, role="reader")
client = APIClient()
client.force_login(user)
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/documents/{document.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(i.id),
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
"email": str(i.email),
"document": str(document.id),
"role": i.role,
"issuer": str(i.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"retrieve": True,
},
}
for i in [invitation, *other_invitations]
],
key=lambda x: x["created_at"],
)
def test_api_document_invitations__list__expired_invitations_still_listed(settings):
"""
Expired invitations are still listed.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory(
users=[(user, "administrator"), (other_user, "owner")]
)
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
document=document,
role="reader",
issuer=user,
)
time.sleep(1)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 1
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(expired_invitation.id),
"created_at": expired_invitation.created_at.isoformat().replace(
"+00:00", "Z"
),
"email": str(expired_invitation.email),
"document": str(document.id),
"role": expired_invitation.role,
"issuer": str(expired_invitation.issuer.id),
"is_expired": True,
"abilities": {
"destroy": True,
"update": False,
"partial_update": False,
"retrieve": True,
},
},
],
key=lambda x: x["created_at"],
)
def test_api_document_invitations__retrieve__anonymous_user():
"""
Anonymous users should not be able to retrieve invitations.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_document_invitations__retrieve__unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__retrieve__document_member(via, mock_user_get_teams):
"""
Authenticated users related to the document should be able to retrieve invitations
whatever their role in the document.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
role = random.choice(models.RoleChoices.choices)[0]
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
"email": invitation.email,
"document": str(invitation.document.id),
"role": str(invitation.role),
"issuer": str(invitation.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"retrieve": True,
},
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
def test_api_document_invitations__update__forbidden(method, via, mock_user_get_teams):
"""
Update of invitations is currently forbidden.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role="owner"
)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
if method == "put":
response = client.put(url)
if method == "patch":
response = client.patch(url)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
def test_api_document_invitations__delete__anonymous():
"""Anonymous user should not be able to delete invitations."""
invitation = factories.InvitationFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_document_invitations__delete__authenticated_outsider():
"""Members unrelated to a document should not be allowed to cancel invitations."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_document_invitations__delete__privileged_members(
role, via, mock_user_get_teams
):
"""Privileged member should be able to cancel invitation."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations_delete_readers_or_editors(
via, role, mock_user_get_teams
):
"""Readers or editors should not be able to cancel invitation."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)

View File

@@ -1,7 +1,6 @@
"""
Test document versions API endpoints for users in impress's core app.
"""
import random
import time
@@ -14,37 +13,45 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_document_versions_list_anonymous(role, reach):
def test_api_document_versions_list_anonymous_public():
"""
Anonymous users should not be allowed to list document versions for a document
whatever the reach and role.
Anonymous users should not be allowed to list document versions for a public document.
"""
document = factories.DocumentFactory(link_role=role, link_reach=reach)
# Accesses and traces for other users should not interfere
factories.UserDocumentAccessFactory(document=document)
models.LinkTrace.objects.create(document=document, user=factories.UserFactory())
document = factories.DocumentFactory(is_public=True)
factories.UserDocumentAccessFactory.create_batch(2, document=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 403
assert response.json() == {"detail": "Authentication required."}
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_list_authenticated_unrelated(reach):
def test_api_document_versions_list_anonymous_private():
"""
Authenticated users should not be allowed to list document versions for a document
Anonymous users should not be allowed to find document versions for a private document.
"""
document = factories.DocumentFactory(is_public=False)
factories.UserDocumentAccessFactory.create_batch(2, document=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_document_versions_list_authenticated_unrelated_public():
"""
Authenticated users should not be allowed to list document versions for a public document
to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=True)
factories.UserDocumentAccessFactory.create_batch(3, document=document)
# The versions of another document to which the user is related should not be listed either
@@ -59,8 +66,31 @@ def test_api_document_versions_list_authenticated_unrelated(reach):
}
def test_api_document_versions_list_authenticated_unrelated_private():
"""
Authenticated users should not be allowed to find document versions for a private document
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
factories.UserDocumentAccessFactory.create_batch(3, document=document)
# The versions of another document to which the user is related should not be listed either
factories.UserDocumentAccessFactory(user=user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related_success(via, mock_user_teams):
def test_api_document_versions_list_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users should be able to list document versions for a document
to which they are directly related, whatever their role in the document.
@@ -78,7 +108,7 @@ def test_api_document_versions_list_authenticated_related_success(via, mock_user
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
@@ -95,12 +125,11 @@ def test_api_document_versions_list_authenticated_related_success(via, mock_user
assert response.status_code == 200
content = response.json()
assert content["count"] == 0
assert len(content["versions"]) == 0
# Add a new version to the document
for i in range(3):
document.content = f"new content {i:d}"
document.save()
document.content = "new content"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
@@ -108,112 +137,16 @@ def test_api_document_versions_list_authenticated_related_success(via, mock_user
assert response.status_code == 200
content = response.json()
# The current version is not listed
assert content["count"] == 2
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related_pagination(
via, mock_user_teams
):
"""
The list of versions should be paginated and exclude versions that were created prior to the
user gaining access to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
for i in range(3):
document.content = f"before {i:d}"
document.save()
if via == USER:
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
)
for i in range(4):
document.content = f"after {i:d}"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
content = response.json()
assert content["is_truncated"] is False
# The current version is not listed
assert content["count"] == 3
assert len(content["versions"]) == 1
assert content["next_version_id_marker"] == ""
all_version_ids = [version["version_id"] for version in content["versions"]]
# - set page size
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2",
)
content = response.json()
assert content["count"] == 2
assert content["is_truncated"] is True
marker = content["next_version_id_marker"]
assert marker == all_version_ids[1]
assert [
version["version_id"] for version in content["versions"]
] == all_version_ids[:2]
# - get page 2
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}",
)
content = response.json()
assert content["count"] == 1
assert content["is_truncated"] is False
assert content["next_version_id_marker"] == ""
assert content["versions"][0]["version_id"] == all_version_ids[2]
def test_api_document_versions_list_exceeds_max_page_size():
"""Page size should not exceed the limit set on the serializer"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[user])
document.content = "version 2"
document.save()
response = client.get(f"/api/v1.0/documents/{document.id!s}/versions/?page_size=51")
assert response.status_code == 400
assert response.json() == {
"page_size": ["Ensure this value is less than or equal to 50."]
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_retrieve_anonymous(reach):
def test_api_document_versions_retrieve_anonymous_public():
"""
Anonymous users should not be allowed to find specific versions for a document with
restricted or authenticated link reach.
Anonymous users should not be allowed to retrieve specific versions for a public document.
"""
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
document = factories.DocumentFactory(is_public=True)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
@@ -225,21 +158,31 @@ def test_api_document_versions_retrieve_anonymous(reach):
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_retrieve_authenticated_unrelated(reach):
def test_api_document_versions_retrieve_anonymous_private():
"""
Authenticated users should not be allowed to retrieve specific versions for a
Anonymous users should not be allowed to find specific versions for a private document.
"""
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
response = APIClient().get(url)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_document_versions_retrieve_authenticated_unrelated_public():
"""
Authenticated users should not be allowed to retrieve specific versions for a public
document to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
document = factories.DocumentFactory(is_public=True)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -251,11 +194,31 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
}
def test_api_document_versions_retrieve_authenticated_unrelated_private():
"""
Authenticated users should not be allowed to find specific versions for a private document
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_teams):
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document versions.
associated document user accesses.
"""
user = factories.UserFactory()
@@ -263,47 +226,26 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
client.force_login(user)
document = factories.DocumentFactory()
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
# Versions created before the document was shared should not be available to the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
# Versions created before the document was shared should not be seen by the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Create a new version should not make it available to the user because
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 2
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Adding one more version should make the previous version available to the user
document.content = "new content 2"
document.save()
assert len(document.get_versions_slice()["versions"]) == 3
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -311,7 +253,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
)
assert response.status_code == 200
assert response.json()["content"] == "new content 1"
assert response.json()["content"] == "new content"
def test_api_document_versions_create_anonymous():
@@ -324,8 +266,10 @@ def test_api_document_versions_create_anonymous():
format="json",
)
assert response.status_code == 405
assert response.json() == {"detail": 'Method "POST" not allowed.'}
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_versions_create_authenticated_unrelated():
@@ -350,7 +294,7 @@ def test_api_document_versions_create_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_create_authenticated_related(via, mock_user_teams):
def test_api_document_versions_create_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users related to a document should not be allowed to create document versions
whatever their role.
@@ -364,7 +308,7 @@ def test_api_document_versions_create_authenticated_related(via, mock_user_teams
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.post(
@@ -379,19 +323,14 @@ def test_api_document_versions_create_authenticated_related(via, mock_user_teams
def test_api_document_versions_update_anonymous():
"""Anonymous users should not be allowed to update a document version."""
access = factories.UserDocumentAccessFactory()
document = access.document
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
{"foo": "bar"},
format="json",
)
assert response.status_code == 405
assert response.status_code == 401
def test_api_document_versions_update_authenticated_unrelated():
@@ -405,12 +344,7 @@ def test_api_document_versions_update_authenticated_unrelated():
client.force_login(user)
access = factories.UserDocumentAccessFactory()
document = access.document
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
@@ -421,7 +355,7 @@ def test_api_document_versions_update_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_update_authenticated_related(via, mock_user_teams):
def test_api_document_versions_update_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users with access to a document should not be able to update its versions
whatever their role.
@@ -432,21 +366,14 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
client.force_login(user)
document = factories.DocumentFactory()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id!s}/",
{"foo": "bar"},
@@ -469,21 +396,17 @@ def test_api_document_versions_delete_anonymous():
assert response.status_code == 401
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_delete_authenticated(reach):
def test_api_document_versions_delete_authenticated_public():
"""
Authenticated users should not be allowed to delete a document version for a
public document to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
document = factories.DocumentFactory(is_public=True)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
@@ -493,14 +416,35 @@ def test_api_document_versions_delete_authenticated(reach):
assert response.status_code == 403
def test_api_document_versions_delete_authenticated_private():
"""
Authenticated users should not be allowed to find a document version to delete it
for a private document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_teams):
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a document version for a
document in which they are a simple reader or editor.
"""
user = factories.UserFactory(with_owned_document=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -509,7 +453,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -520,7 +464,13 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 1
assert len(versions) == 2
version_id = versions[1]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 403
version_id = versions[0]["version_id"]
response = client.delete(
@@ -529,11 +479,11 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
assert response.status_code == 403
versions = document.get_versions_slice()["versions"]
assert len(versions) == 1
assert len(versions) == 2
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_delete_administrator_or_owner(via, mock_user_teams):
def test_api_document_versions_delete_administrator_or_owner(via, mock_user_get_teams):
"""
Users who are administrator or owner of a document should be allowed to delete a version.
"""
@@ -547,32 +497,26 @@ def test_api_document_versions_delete_administrator_or_owner(via, mock_user_team
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content 1"
document.content = "new content"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 1
assert len(versions) == 2
version_id = versions[0]["version_id"]
version_id = versions[1]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
# 404 because the version was created before the user was given access to the document
assert response.status_code == 404
document.content = "new content 2"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
version_id = versions[0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",

View File

@@ -1,7 +1,6 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -32,7 +31,7 @@ def test_api_template_accesses_list_authenticated_unrelated():
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -57,18 +56,17 @@ def test_api_template_accesses_list_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
def test_api_template_accesses_list_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
@@ -76,7 +74,7 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
@@ -146,7 +144,7 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -172,18 +170,16 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_get_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -192,7 +188,7 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_tea
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
@@ -211,6 +207,201 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_tea
}
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
user = factories.UserFactory()
template = factories.TemplateFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_get_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.UserTemplateAccessFactory()
@@ -241,14 +432,14 @@ def test_api_template_accesses_update_authenticated_unrelated():
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
@@ -271,10 +462,10 @@ def test_api_template_accesses_update_authenticated_unrelated():
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_teams
via, role, mock_user_get_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -283,7 +474,7 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -311,12 +502,14 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
def test_api_template_accesses_update_administrator_except_owner(
via, mock_user_get_teams
):
"""
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -327,7 +520,7 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -336,8 +529,8 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -368,12 +561,14 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
def test_api_template_accesses_update_administrator_from_owner(
via, mock_user_get_teams
):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -384,7 +579,7 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -393,8 +588,8 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -415,12 +610,12 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_get_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -431,7 +626,7 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -442,8 +637,8 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -469,12 +664,12 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_teams):
def test_api_template_accesses_update_owner(via, mock_user_get_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -483,7 +678,7 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -492,8 +687,8 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -525,26 +720,26 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
def test_api_template_accesses_update_owner_self(via, mock_user_get_teams):
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
else:
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
@@ -593,7 +788,7 @@ def test_api_template_accesses_delete_authenticated():
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -605,17 +800,17 @@ def test_api_template_accesses_delete_authenticated():
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -624,14 +819,14 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -639,12 +834,12 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
via, mock_user_get_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
@@ -661,7 +856,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -682,12 +877,12 @@ def test_api_template_accesses_delete_administrators_except_owners(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_get_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -698,14 +893,14 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -713,11 +908,11 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_teams):
def test_api_template_accesses_delete_owners(via, mock_user_get_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
@@ -731,7 +926,7 @@ def test_api_template_accesses_delete_owners(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -750,31 +945,30 @@ def test_api_template_accesses_delete_owners(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_get_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory(with_owned_template=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 1
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 1

View File

@@ -1,7 +1,6 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -69,48 +68,6 @@ def test_api_users_list_query_email():
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by similarity"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="jane.smith@example.gouv.fr")
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
factories.UserFactory(email="david.jones@example.gouv.fr")
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
response = client.get(
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(alice.id)]
response = client.get(
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users
@@ -162,8 +119,6 @@ def test_api_users_retrieve_me_authenticated():
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}

View File

@@ -1,127 +0,0 @@
"""
Test throttling on documents for the AI endpoint.
"""
from unittest.mock import patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
from core.api.utils import AIDocumentRateThrottle
class DocumentAPIView(APIView):
"""A simple view to test the throttle"""
throttle_classes = [AIDocumentRateThrottle]
def get(self, request, *args, **kwargs):
"""Minimal get method for testing purposes."""
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_document_rate_throttle_minute_limit(mock_time):
"""Test that minute limit is enforced."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Simulate requests to the document API
for _i in range(3): # 3 first requests should be allowed
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 59
# 4th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 60s backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
@override_settings(
AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10}
)
@patch("time.time")
def test_ai_document_rate_throttle_hour_limit(mock_time):
"""Test that the hour limit is enforced without hitting the minute limit."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 21 seconds to avoid hitting the minute limit
for _i in range(6):
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 21
# Simulate passage of time
mock_time.return_value += 3600 - 6 * 21 - 1
# 7th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 1h backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_document_rate_throttle_day_limit(mock_time):
"""Test that day limit is enforced."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 10 minutes to avoid hitting
# the minute and hour limits
for _i in range(10): # 10 requests should be allowed
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 60 * 10
# Simulate passage of time
mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1
# 11th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 24h backoff wait time has passed we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200

View File

@@ -1,146 +0,0 @@
"""
Test throttling on users for the AI endpoint.
"""
from unittest.mock import patch
from uuid import uuid4
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
from core.api.utils import AIUserRateThrottle
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
class DocumentAPIView(APIView):
"""A simple view to test the throttle"""
throttle_classes = [AIUserRateThrottle]
def get(self, request, *args, **kwargs):
"""Minimal get method for testing purposes."""
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_user_rate_throttle_minute_limit(mock_time):
"""Test that minute limit is enforced."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Simulate requests to the document API
for _i in range(3): # 3 first requests should be allowed
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 59
# 4th request should be throttled
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 60s backoff wait time has passed, we can make a request again
mock_time.return_value += 1
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10})
@patch("time.time")
def test_ai_user_rate_throttle_hour_limit(mock_time):
"""Test that the hour limit is enforced without hitting the minute limit."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 21 seconds to avoid hitting the minute limit
for _i in range(6):
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 21
# Simulate passage of time
mock_time.return_value += 3600 - 6 * 21 - 1
# 7th request should be throttled
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 1h backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_user_rate_throttle_day_limit(mock_time):
"""Test that day limit is enforced."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 10 minutes to avoid hitting
# the minute and hour limits
for _i in range(10): # 10 requests should be allowed
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 60 * 10
# Simulate passage of time
mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1
# 11th request should be throttled
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 24h backoff wait time has passed we can make a request again
mock_time.return_value += 1
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200

View File

@@ -1,7 +1,6 @@
"""
Unit tests for the DocumentAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -319,7 +318,7 @@ def test_models_document_access_get_abilities_for_editor_of_administrator():
def test_models_document_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries,
django_assert_num_queries
):
"""Check abilities of editor access for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
@@ -378,7 +377,7 @@ def test_models_document_access_get_abilities_for_reader_of_administrator():
def test_models_document_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries,
django_assert_num_queries
):
"""Check abilities of reader access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="reader")

View File

@@ -1,16 +1,9 @@
"""
Unit tests for the Document model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import mail
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
@@ -33,15 +26,15 @@ def test_models_documents_id_unique():
def test_models_documents_title_null():
"""The "title" field can be null."""
document = models.Document.objects.create(title=None)
assert document.title is None
"""The "title" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Document.objects.create(title=None)
def test_models_documents_title_empty():
"""The "title" field can be empty."""
document = models.Document.objects.create(title="")
assert document.title == ""
"""The "title" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Document.objects.create(title="")
def test_models_documents_title_max_length():
@@ -63,105 +56,64 @@ def test_models_documents_file_key():
# get_abilities
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
],
)
def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role):
"""
Check abilities returned for a document giving insufficient roles to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
def test_models_documents_get_abilities_anonymous_public():
"""Check abilities returned for an anonymous user if the document is public."""
document = factories.DocumentFactory(is_public=True)
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"destroy": False,
"invite_owner": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
def test_models_documents_get_abilities_anonymous_not_public():
"""Check abilities returned for an anonymous user if the document is private."""
document = factories.DocumentFactory(is_public=False)
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_reader(is_authenticated, reach):
"""
Check abilities returned for a document giving reader role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="reader")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
def test_models_documents_get_abilities_authenticated_unrelated_public():
"""Check abilities returned for an authenticated user if the user is public."""
document = factories.DocumentFactory(is_public=True)
abilities = document.get_abilities(factories.UserFactory())
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_editor(is_authenticated, reach):
"""
Check abilities returned for a document giving editor role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
def test_models_documents_get_abilities_authenticated_unrelated_not_public():
"""Check abilities returned for an authenticated user if the document is private."""
document = factories.DocumentFactory(is_public=False)
abilities = document.get_abilities(factories.UserFactory())
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
"retrieve": False,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -174,17 +126,11 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": True,
"link_configuration": True,
"invite_owner": True,
"partial_update": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"partial_update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
@@ -196,17 +142,11 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": True,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"partial_update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
@@ -221,17 +161,11 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
"manage_accesses": False,
"partial_update": True,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -240,25 +174,17 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a document."""
access = factories.UserDocumentAccessFactory(
role="reader", document__link_role="reader"
)
access = factories.UserDocumentAccessFactory(role="reader")
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -267,38 +193,30 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.UserDocumentAccessFactory(
role="reader", document__link_role="reader"
)
access = factories.UserDocumentAccessFactory(role="reader")
access.document.user_roles = ["reader"]
with django_assert_num_queries(0):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
def test_models_documents_get_versions_slice_pagination(settings):
def test_models_documents_get_versions_slice(settings):
"""
The "get_versions_slice" method should allow navigating all versions of
the document with pagination.
"""
settings.DOCUMENT_VERSIONS_PAGE_SIZE = 4
settings.S3_VERSIONS_PAGE_SIZE = 4
# Create a document with 7 versions
document = factories.DocumentFactory()
@@ -306,7 +224,7 @@ def test_models_documents_get_versions_slice_pagination(settings):
document.content = f"bar{i:d}"
document.save()
# Add a document version not related to the first document
# Add a version not related to the first document
factories.DocumentFactory()
# - Get default max versions
@@ -324,7 +242,7 @@ def test_models_documents_get_versions_slice_pagination(settings):
from_version_id=response["next_version_id_marker"]
)
assert response["is_truncated"] is False
assert len(response["versions"]) == 2
assert len(response["versions"]) == 3
assert response["next_version_id_marker"] == ""
# - Get custom max versions
@@ -334,30 +252,6 @@ def test_models_documents_get_versions_slice_pagination(settings):
assert response["next_version_id_marker"] != ""
def test_models_documents_get_versions_slice_min_datetime():
"""
The "get_versions_slice" method should filter out versions anterior to
the from_datetime passed in argument and the current version.
"""
document = factories.DocumentFactory()
from_dt = []
for i in range(6):
from_dt.append(timezone.now())
document.content = f"bar{i:d}"
document.save()
response = document.get_versions_slice(min_datetime=from_dt[2])
assert len(response["versions"]) == 3
for version in response["versions"]:
assert version["last_modified"] > from_dt[2]
response = document.get_versions_slice(min_datetime=from_dt[4])
assert len(response["versions"]) == 1
assert response["versions"][0]["last_modified"] > from_dt[4]
def test_models_documents_version_duplicate():
"""A new version should be created in object storage only if the content has changed."""
document = factories.DocumentFactory()
@@ -384,105 +278,3 @@ def test_models_documents_version_duplicate():
Bucket=default_storage.bucket_name, Prefix=file_key
)
assert len(response["Versions"]) == 2
def test_models_documents__email_invitation__success():
"""
The email invitation is sent successfully.
"""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f'Test Sender (sender@example.com) invited you with the role "editor" '
f"on the following document : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in french.
"""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory(
full_name="Test Sender2", email="sender2@example.com"
)
document.email_invitation(
"fr-fr",
"guest2@example.com",
models.RoleChoices.OWNER,
sender,
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest2@example.com"]
email_content = " ".join(email.body.split())
assert (
f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" '
f"sur le document suivant : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@mock.patch(
"core.models.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory()
document.email_invitation(
"en",
"guest3@example.com",
models.RoleChoices.ADMIN,
sender,
)
# 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 == "guest3@example.com"
assert isinstance(exception, smtplib.SMTPException)

View File

@@ -2,12 +2,13 @@
Unit tests for the Invitation model
"""
from datetime import timedelta
import smtplib
import time
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions
from django.utils import timezone
from django.core import exceptions, mail
import pytest
from faker import Faker
@@ -22,6 +23,13 @@ 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"):
@@ -62,7 +70,7 @@ def test_models_invitations_role_among_choices():
factories.InvitationFactory(role="boss")
def test_models_invitations_is_expired():
def test_models_invitations__is_expired(settings):
"""
The 'is_expired' property should return False until validity duration
is exceeded and True afterwards.
@@ -70,16 +78,13 @@ def test_models_invitations_is_expired():
expired_invitation = factories.InvitationFactory()
assert expired_invitation.is_expired is False
not_late = timezone.now() + timedelta(seconds=604799)
with mock.patch("django.utils.timezone.now", return_value=not_late):
assert expired_invitation.is_expired is False
settings.INVITATION_VALIDITY_DURATION = 1
time.sleep(1)
too_late = timezone.now() + timedelta(seconds=604800) # 7 days
with mock.patch("django.utils.timezone.now", return_value=too_late):
assert expired_invitation.is_expired is True
assert expired_invitation.is_expired is True
def test_models_invitationd_new_userd_convert_invitations_to_accesses():
def test_models_invitation__new_user__convert_invitations_to_accesses():
"""
Upon creating a new user, invitations linked to the email
should be converted to accesses and then deleted.
@@ -114,7 +119,7 @@ def test_models_invitationd_new_userd_convert_invitations_to_accesses():
).exists() # the other invitation remains
def test_models_invitationd_new_user_filter_expired_invitations():
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.
@@ -145,7 +150,7 @@ def test_models_invitationd_new_user_filter_expired_invitations():
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
def test_models_invitationd_new_userd_user_creation_constant_num_queries(
def test_models_invitation__new_user__user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
"""
@@ -163,6 +168,67 @@ def test_models_invitationd_new_userd_user_creation_constant_num_queries(
models.User.objects.create(email=user_email, password="!")
def test_models_document_invitations_email():
"""Check email invitation during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="reader")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
invitation = factories.InvitationFactory(document=document, email="john@people.com")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Impress!"
email_content = " ".join(email.body.split())
assert "Invitation to join Impress!" in email_content
assert "[//example.com]" in email_content
@mock.patch(
"django.core.mail.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_document_invitations_email_failed(mock_logger, _mock_send_mail):
"""Check invitation behavior when an SMTP error occurs during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="reader")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
# No error should be raised
invitation = factories.InvitationFactory(document=document, 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)
# get_abilities
@@ -194,7 +260,7 @@ def test_models_document_invitations_get_abilities_authenticated():
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_models_document_invitations_get_abilities_privileged_member(
role, via, mock_user_teams
role, via, mock_user_get_teams
):
"""Check abilities for a document member with a privileged role."""
@@ -203,7 +269,7 @@ def test_models_document_invitations_get_abilities_privileged_member(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -216,13 +282,13 @@ def test_models_document_invitations_get_abilities_privileged_member(
assert abilities == {
"destroy": True,
"retrieve": True,
"partial_update": True,
"update": True,
"partial_update": False,
"update": False,
}
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
def test_models_document_invitations_get_abilities_reader(via, mock_user_get_teams):
"""Check abilities for a document reader with 'reader' role."""
user = factories.UserFactory()
@@ -230,7 +296,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
@@ -240,14 +306,14 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"partial_update": False,
"update": False,
}
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
def test_models_document_invitations_get_abilities_editor(via, mock_user_get_teams):
"""Check abilities for a document editor with 'editor' role."""
user = factories.UserFactory()
@@ -255,7 +321,7 @@ def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="editor")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="editor"
)
@@ -265,7 +331,7 @@ def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"partial_update": False,
"update": False,
}

View File

@@ -1,7 +1,6 @@
"""
Unit tests for the TemplateAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -319,7 +318,7 @@ def test_models_template_access_get_abilities_for_editor_of_administrator():
def test_models_template_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries,
django_assert_num_queries
):
"""Check abilities of editor access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
@@ -378,7 +377,7 @@ def test_models_template_access_get_abilities_for_reader_of_administrator():
def test_models_template_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries,
django_assert_num_queries
):
"""Check abilities of reader access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")

View File

@@ -1,11 +1,6 @@
"""
Unit tests for the Template model
"""
import os
import time
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -62,7 +57,7 @@ def test_models_templates_get_abilities_anonymous_public():
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": False,
"generate_document": True,
}
@@ -76,7 +71,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": False,
"generate_document": False,
}
@@ -90,7 +85,7 @@ def test_models_templates_get_abilities_authenticated_public():
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": False,
"generate_document": True,
}
@@ -104,7 +99,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": False,
"generate_document": False,
}
@@ -119,7 +114,7 @@ def test_models_templates_get_abilities_owner():
"destroy": True,
"retrieve": True,
"update": True,
"accesses_manage": True,
"manage_accesses": True,
"partial_update": True,
"generate_document": True,
}
@@ -133,7 +128,7 @@ def test_models_templates_get_abilities_administrator():
"destroy": False,
"retrieve": True,
"update": True,
"accesses_manage": True,
"manage_accesses": True,
"partial_update": True,
"generate_document": True,
}
@@ -150,7 +145,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": True,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": True,
"generate_document": True,
}
@@ -167,7 +162,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": False,
"generate_document": True,
}
@@ -185,35 +180,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"manage_accesses": False,
"partial_update": False,
"generate_document": True,
}
def test_models_templates__generate_word():
"""Generate word document and assert no tmp files are left in /tmp folder."""
template = factories.TemplateFactory()
response = template.generate_word("<p>Test body</p>", {})
assert response.status_code == 200
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
@mock.patch(
"pypandoc.convert_text",
side_effect=RuntimeError("Conversion failed"),
)
def test_models_templates__generate_word__raise_error(_mock_pypandoc):
"""
Generate word document and assert no tmp files are left in /tmp folder
even when the conversion fails.
"""
template = factories.TemplateFactory()
try:
template.generate_word("<p>Test body</p>", {})
except RuntimeError as e:
assert str(e) == "Conversion failed"
time.sleep(0.5)
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0

View File

@@ -1,7 +1,6 @@
"""
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError

View File

@@ -1,104 +0,0 @@
"""
Test ai API endpoints in the impress core app.
"""
import json
from unittest.mock import MagicMock, patch
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from openai import OpenAIError
from core.services.ai_services import AIService
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"setting_name, setting_value",
[
("AI_BASE_URL", None),
("AI_API_KEY", None),
("AI_MODEL", None),
],
)
def test_api_ai_setting_missing(setting_name, setting_value):
"""Setting should be set"""
with override_settings(**{setting_name: setting_value}):
with pytest.raises(
ImproperlyConfigured,
match="AI configuration not set",
):
AIService()
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__client_error(mock_create):
"""Fail when the client raises an error"""
mock_create.side_effect = OpenAIError("Mocked client error")
with pytest.raises(
OpenAIError,
match="Mocked client error",
):
AIService().transform("hello", "prompt")
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__client_invalid_response(mock_create):
"""Fail when the client response is invalid"""
answer = {"no_answer": "This is an invalid response"}
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=json.dumps(answer)))]
)
with pytest.raises(
RuntimeError,
match="AI response does not contain an answer",
):
AIService().transform("hello", "prompt")
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success(mock_create):
"""The AI request should work as expect when called with valid arguments."""
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut"}
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success_sanitize(mock_create):
"""The AI response should be sanitized"""
answer = '{"answer": "Salut\\n \tle \nmonde"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut\n \tle \nmonde"}

View File

@@ -1,5 +1,4 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path, re_path

View File

@@ -1,7 +1,7 @@
<page size="A4">
<div class="header">
<img width="200"
src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png"
<img
src="https://upload.wikimedia.org/wikipedia/fr/7/72/Logo_du_Gouvernement_de_la_R%C3%A9publique_fran%C3%A7aise_%282020%29.svg"
/>
</div>
<div class="content">

View File

@@ -1,20 +1,18 @@
body {
background: white;
font-family: arial;
font-family: arial
}
.header {
display: flex;
justify-content: space-between;
}
.header img {
width: 5cm;
margin-left: -0.4cm;
}
.body{
margin-top: 1.5rem;
margin-top: 1.5rem
}
img {
max-width: 100%;
}
[custom-style="center"] {
text-align: center;
}
[custom-style="right"] {
text-align: right;
}

View File

@@ -2,7 +2,6 @@
"""create_demo management command"""
import logging
import math
import random
import time
from collections import defaultdict
@@ -112,11 +111,7 @@ def create_demo(stdout):
queue = BulkQueue(stdout)
with Timeit(stdout, "Creating users"):
name_size = int(math.sqrt(defaults.NB_OBJECTS["users"]))
first_names = [fake.first_name() for _ in range(name_size)]
last_names = [fake.last_name() for _ in range(name_size)]
for i in range(defaults.NB_OBJECTS["users"]):
first_name = random.choice(first_names)
queue.push(
models.User(
admin_email=f"user{i:d}@example.com",
@@ -125,8 +120,6 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
short_name=first_name,
full_name=f"{first_name:s} {random.choice(last_names):s}",
language=random.choice(settings.LANGUAGES)[0],
)
)
@@ -137,9 +130,7 @@ def create_demo(stdout):
queue.push(
models.Document(
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
is_public=random_true_with_probability(0.5),
)
)

View File

@@ -1,5 +1,4 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand

View File

@@ -1,5 +1,4 @@
"""Impress celery configuration file."""
import os
from celery import Celery

View File

@@ -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
@@ -138,92 +137,13 @@ class Base(Configuration):
environ_prefix=None,
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.Value(
10 * (2**20), # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
)
DOCUMENT_UNSAFE_MIME_TYPES = [
# Executable Files
"application/x-msdownload",
"application/x-bat",
"application/x-dosexec",
"application/x-sh",
"application/x-ms-dos-executable",
"application/x-msi",
"application/java-archive",
"application/octet-stream",
# Dynamic Web Pages
"application/x-httpd-php",
"application/x-asp",
"application/x-aspx",
"application/jsp",
"application/xhtml+xml",
"application/x-python-code",
"application/x-perl",
"text/html",
"text/javascript",
"text/x-php",
# System Files
"application/x-msdownload",
"application/x-sys",
"application/x-drv",
"application/cpl",
"application/x-apple-diskimage",
# Script Files
"application/javascript",
"application/x-vbscript",
"application/x-powershell",
"application/x-shellscript",
# Compressed/Archive Files
"application/zip",
"application/x-tar",
"application/gzip",
"application/x-bzip2",
"application/x-7z-compressed",
"application/x-rar",
"application/x-rar-compressed",
"application/x-compress",
"application/x-lzma",
# Macros in Documents
"application/vnd.ms-word",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.ms-word.document.macroenabled.12",
"application/vnd.ms-excel.sheet.macroenabled.12",
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
# Disk Images & Virtual Disk Files
"application/x-iso9660-image",
"application/x-vmdk",
"application/x-apple-diskimage",
"application/x-dmg",
# Other Dangerous MIME Types
"application/x-ms-application",
"application/x-msdownload",
"application/x-shockwave-flash",
"application/x-silverlight-app",
"application/x-java-vm",
"application/x-bittorrent",
"application/hta",
"application/x-csh",
"application/x-ksh",
"application/x-ms-regedit",
"application/x-msdownload",
"application/xml",
"image/svg+xml",
]
# Document versions
DOCUMENT_VERSIONS_PAGE_SIZE = 50
S3_VERSIONS_PAGE_SIZE = 50
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
# Languages
LANGUAGE_CODE = values.Value("en-us")
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -237,7 +157,6 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -359,7 +278,6 @@ 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("from@example.com")
AUTH_USER_MODEL = "core.User"
@@ -377,7 +295,6 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
@@ -447,40 +364,9 @@ class Base(Configuration):
OIDC_STORE_ID_TOKEN = values.BooleanValue(
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=True,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
AI_DOCUMENT_RATE_THROTTLE_RATES = {
"minute": 5,
"hour": 100,
"day": 500,
}
AI_USER_RATE_THROTTLE_RATES = {
"minute": 3,
"hour": 50,
"day": 200,
}
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
@@ -640,14 +526,6 @@ class Production(Base):
# In other cases, you should comment the following line to avoid security issues.
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True

View File

@@ -1,349 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:21\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
msgid "Personal info"
msgstr "Persönliche Angaben"
#: core/admin.py:34
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:46
msgid "Important dates"
msgstr "Wichtige Termine"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Leser"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Bearbeiter"
#: core/models.py:71
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:72
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:80
msgid "Restricted"
msgstr "Eingeschränkt"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:86
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
msgid "identity email address"
msgstr ""
#: core/models.py:153
msgid "admin email address"
msgstr ""
#: core/models.py:160
msgid "language"
msgstr ""
#: core/models.py:161
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
msgid "device"
msgstr ""
#: core/models.py:172
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
msgid "staff status"
msgstr ""
#: core/models.py:177
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
msgid "active"
msgstr ""
#: core/models.py:183
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
msgid "user"
msgstr ""
#: core/models.py:196
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt: %(document)s"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
msgid "public"
msgstr ""
#: core/models.py:651
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
msgid "Template"
msgstr ""
#: core/models.py:658
msgid "Templates"
msgstr ""
#: core/models.py:797
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
msgid "email address"
msgstr ""
#: core/models.py:850
msgid "Document invitation"
msgstr ""
#: core/models.py:851
msgid "Document invitations"
msgstr ""
#: core/models.py:868
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s hat ein Dokument mit Ihnen geteilt! "
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen: "
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und Zusammenarbeiten an Dokumenten im Team. "
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Bereitgestellt von La Suite Numérique"
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen:"
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr "Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und gemeinsamen Arbeiten an Dokumenten im Team."
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -1,358 +1,208 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:33
#: core/admin.py:31
msgid "Personal info"
msgstr ""
#: core/admin.py:46
#: core/admin.py:33
msgid "Permissions"
msgstr ""
#: core/admin.py:58
#: core/admin.py:45
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:253
msgid "Body"
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:57
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:63 core/models.py:70
msgid "Editor"
#: core/models.py:27
msgid "Member"
msgstr ""
#: core/models.py:71
#: core/models.py:28
msgid "Administrator"
msgstr ""
#: core/models.py:72
#: core/models.py:29
msgid "Owner"
msgstr ""
#: core/models.py:80
msgid "Restricted"
msgstr ""
#: core/models.py:84
msgid "Authenticated"
msgstr ""
#: core/models.py:86
msgid "Public"
msgstr ""
#: core/models.py:98
#: core/models.py:41
msgid "id"
msgstr ""
#: core/models.py:99
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: core/models.py:48
msgid "created on"
msgstr ""
#: core/models.py:106
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
#: core/models.py:54
msgid "updated on"
msgstr ""
#: core/models.py:112
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:138
#: core/models.py:81
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:150
msgid "short name"
msgstr ""
#: core/models.py:152
#: core/models.py:91
msgid "identity email address"
msgstr ""
#: core/models.py:157
#: core/models.py:96
msgid "admin email address"
msgstr ""
#: core/models.py:164
#: core/models.py:103
msgid "language"
msgstr ""
#: core/models.py:165
#: core/models.py:104
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:171
#: core/models.py:110
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:174
#: core/models.py:113
msgid "device"
msgstr ""
#: core/models.py:176
#: core/models.py:115
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:179
#: core/models.py:118
msgid "staff status"
msgstr ""
#: core/models.py:181
#: core/models.py:120
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:184
#: core/models.py:123
msgid "active"
msgstr ""
#: core/models.py:187
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:199
#: core/models.py:138
msgid "user"
msgstr ""
#: core/models.py:200
#: core/models.py:139
msgid "users"
msgstr ""
#: core/models.py:332 core/models.py:638
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:347
msgid "Document"
msgstr ""
#: core/models.py:348
msgid "Documents"
msgstr ""
#: core/models.py:351
msgid "Untitled Document"
msgstr ""
#: core/models.py:530
#, python-format
msgid "%(sender_name)s shared a document with you: %(document)s"
msgstr ""
#: core/models.py:574
msgid "Document/user link trace"
msgstr ""
#: core/models.py:575
msgid "Document/user link traces"
msgstr ""
#: core/models.py:581
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:602
msgid "Document/user relation"
msgstr ""
#: core/models.py:603
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:639
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:640
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:641
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:643
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:645
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:651
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:652
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:791
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:792
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:798
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:804
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:827
msgid "email address"
#: core/models.py:275
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:844
msgid "Document invitation"
msgstr ""
#: core/models.py:845
msgid "Document invitations"
msgstr ""
#: core/models.py:862
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(sender_name)s shared a document with you ! "
msgstr ""
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr ""
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:222
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:16
msgid "Brought to you by La Suite Numérique"
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:134
msgid "English"
msgstr ""
#: impress/settings.py:178
#: impress/settings.py:135
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -1,358 +1,208 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:31
msgid "Personal info"
msgstr ""
#: core/admin.py:33
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:46
msgid "Permissions"
msgstr "Permissions"
msgstr ""
#: core/admin.py:58
#: core/admin.py:45
msgid "Important dates"
msgstr "Dates importantes"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
msgid "Body type"
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:57
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Lecteur"
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:27
msgid "Member"
msgstr ""
#: core/models.py:71
#: core/models.py:28
msgid "Administrator"
msgstr "Administrateur"
msgstr ""
#: core/models.py:72
#: core/models.py:29
msgid "Owner"
msgstr "Propriétaire"
msgstr ""
#: core/models.py:80
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:86
msgid "Public"
msgstr "Public"
#: core/models.py:98
#: core/models.py:41
msgid "id"
msgstr ""
#: core/models.py:99
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: core/models.py:48
msgid "created on"
msgstr ""
#: core/models.py:106
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
#: core/models.py:54
msgid "updated on"
msgstr ""
#: core/models.py:112
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:138
#: core/models.py:81
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:150
msgid "short name"
msgstr ""
#: core/models.py:152
#: core/models.py:91
msgid "identity email address"
msgstr ""
#: core/models.py:157
#: core/models.py:96
msgid "admin email address"
msgstr ""
#: core/models.py:164
#: core/models.py:103
msgid "language"
msgstr ""
#: core/models.py:165
#: core/models.py:104
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:171
#: core/models.py:110
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:174
#: core/models.py:113
msgid "device"
msgstr ""
#: core/models.py:176
#: core/models.py:115
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:179
#: core/models.py:118
msgid "staff status"
msgstr ""
#: core/models.py:181
#: core/models.py:120
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:184
#: core/models.py:123
msgid "active"
msgstr ""
#: core/models.py:187
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:199
#: core/models.py:138
msgid "user"
msgstr ""
#: core/models.py:200
#: core/models.py:139
msgid "users"
msgstr ""
#: core/models.py:332 core/models.py:638
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:347
msgid "Document"
msgstr ""
#: core/models.py:348
msgid "Documents"
msgstr ""
#: core/models.py:351
msgid "Untitled Document"
msgstr ""
#: core/models.py:530
#, python-format
msgid "%(sender_name)s shared a document with you: %(document)s"
msgstr "%(sender_name)s a partagé un document avec vous: %(document)s"
#: core/models.py:574
msgid "Document/user link trace"
msgstr ""
#: core/models.py:575
msgid "Document/user link traces"
msgstr ""
#: core/models.py:581
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:602
msgid "Document/user relation"
msgstr ""
#: core/models.py:603
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:639
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:640
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:641
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:643
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:645
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:651
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:652
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:791
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:792
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:798
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:804
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:827
msgid "email address"
#: core/models.py:275
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:844
msgid "Document invitation"
msgstr ""
#: core/models.py:845
msgid "Document invitations"
msgstr ""
#: core/models.py:862
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(sender_name)s shared a document with you ! "
msgstr " %(sender_name)s a partagé un document avec vous ! "
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr " %(sender_name_email)s vous a invité avec le rôle \"%(role)s\" sur le document suivant : "
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:222
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:16
msgid "Brought to you by La Suite Numérique"
msgstr "Proposé par La Suite Numérique"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:134
msgid "English"
msgstr ""
#: impress/settings.py:178
#: impress/settings.py:135
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -2,7 +2,6 @@
"""
impress's sandbox management script.
"""
import os
import sys

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.7.0"
version = "1.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,39 +25,37 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.44",
"boto3==1.33.6",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.5.0",
"django-countries==7.6.1",
"celery[redis]==5.3.6",
"django-configurations==2.5",
"django-cors-headers==4.3.1",
"django-countries==7.5.1",
"django-parler==2.3",
"redis==5.1.1",
"redis==5.0.3",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-storages[s3]==1.14.2",
"django-timezone-field>=5.1",
"django==5.1.2",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"django==5.0.7",
"djangorestframework==3.14.0",
"drf_spectacular==0.26.5",
"dockerflow==2022.8.0",
"easy_thumbnails==2.8.5",
"factory_boy==3.3.0",
"freezegun==1.5.0",
"gunicorn==22.0.0",
"jsonschema==4.20.0",
"markdown==3.5.1",
"nested-multipart-parser==1.5.0",
"openai==1.52.0",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"pypandoc==1.14",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.17.0",
"psycopg[binary]==3.1.14",
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.32.2",
"sentry-sdk==1.38.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
]
[project.urls]
@@ -69,21 +67,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"freezegun==1.5.1",
"drf-spectacular-sidecar==2023.12.1",
"ipdb==0.13.13",
"ipython==8.28.0",
"pyfakefs==5.7.1",
"pylint-django==2.6.1",
"pylint==3.3.1",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.7.0",
"types-requests==2.32.0.20241016",
"ipython==8.18.1",
"pyfakefs==5.3.2",
"pylint-django==2.5.5",
"pylint==3.0.3",
"pytest-cov==4.1.0",
"pytest-django==4.7.0",
"pytest==7.4.3",
"pytest-icdiff==0.8",
"pytest-xdist==3.5.0",
"responses==0.24.1",
"ruff==0.1.6",
"types-requests==2.31.0.10",
]
[tool.setuptools]
@@ -102,11 +99,11 @@ exclude = [
"__pycache__",
"*/migrations/*",
]
ignore= ["DJ001", "PLR2004"]
line-length = 88
[tool.ruff.lint]
ignore = ["DJ001", "PLR2004"]
select = [
"B", # flake8-bugbear
"BLE", # flake8-blind-except
@@ -128,7 +125,7 @@ select = [
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
[tool.ruff.lint.per-file-ignores]
[tool.ruff.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]

View File

@@ -1,10 +1,10 @@
FROM node:20-alpine AS frontend-deps-y-provider
FROM node:20-alpine as frontend-deps-y-webrtc-signaling
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/servers/y-provider/package.json ./servers/y-provider/package.json
COPY ./src/frontend/apps/y-webrtc-signaling/package.json ./apps/y-webrtc-signaling/package.json
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
RUN yarn install
@@ -14,10 +14,10 @@ COPY ./src/frontend/ .
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-provider ----
FROM frontend-deps-y-provider AS y-provider
# ---- y-webrtc-signaling ----
FROM frontend-deps-y-webrtc-signaling as y-webrtc-signaling
WORKDIR /home/frontend/servers/y-provider
WORKDIR /home/frontend/apps/y-webrtc-signaling
RUN yarn build
# Un-privileged user running the application
@@ -28,7 +28,7 @@ ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]
FROM node:20-alpine AS frontend-deps
FROM node:20-alpine as frontend-deps
WORKDIR /home/frontend/
@@ -43,11 +43,11 @@ COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps AS impress
FROM frontend-deps as impress
WORKDIR /home/frontend/apps/impress
FROM frontend-deps AS impress-dev
FROM frontend-deps as impress-dev
WORKDIR /home/frontend/apps/impress
@@ -57,29 +57,14 @@ CMD [ "yarn", "dev"]
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
# to avoid rebuilding the app at every changes.
FROM impress AS impress-builder
FROM impress as impress-builder
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG Y_PROVIDER_URL
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER

View File

@@ -17,11 +17,3 @@ setup('authenticate-webkit', async ({ page }) => {
.context()
.storageState({ path: `playwright/.auth/user-webkit.json` });
});
setup('authenticate-firefox', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'firefox');
await page
.context()
.storageState({ path: `playwright/.auth/user-firefox.json` });
});

View File

@@ -1,20 +1,24 @@
import { Page, expect } from '@playwright/test';
export const keyCloakSignIn = async (page: Page, browserName: string) => {
const title = await page.locator('h1').first().textContent({
timeout: 5000,
});
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
).toBeVisible();
if (await page.getByLabel('Restart login').isVisible()) {
await page.getByLabel('Restart login').click();
}
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
await page.click('input[type="submit"]', { force: true });
} else if (title?.includes('Sign in to your account')) {
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
}
};
export const randomName = (name: string, browserName: string, length: number) =>
@@ -27,22 +31,38 @@ export const createDoc = async (
docName: string,
browserName: string,
length: number,
isPublic: boolean = false,
) => {
const buttonCreate = page.getByRole('button', {
name: 'Create the document',
});
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
// Fill input
await page
.getByRole('textbox', {
name: 'Document name',
})
.fill(randomDocs[i]);
if (isPublic) {
await page.getByText('Is it public ?').click();
}
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
}
return randomDocs;
@@ -97,14 +117,13 @@ export const goToGridDoc = async (
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
const rows = datagridTable.getByRole('row');
const rows = datagrid.getByRole('row');
const row = title
? rows.filter({
hasText: title,
@@ -117,124 +136,7 @@ export const goToGridDoc = async (
expect(docTitle).toBeDefined();
await row.getByRole('link').first().click();
await docTitleCell.click();
return docTitle as string;
};
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
await route.fulfill({
json: {
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
...json,
},
});
} else {
await route.continue();
}
});
};
export const mockedInvitations = async (page: Page, json?: object) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('invitations') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: '120ec765-43af-4602-83eb-7f4e1224548a',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
is_expired: false,
...json,
},
],
},
});
} else {
await route.continue();
}
});
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('accesses') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
},
team: '',
role: 'reader',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
set_role_to: ['administrator', 'editor'],
},
...json,
},
],
},
});
} else {
await route.continue();
}
});
};

Some files were not shown because too many files have changed in this diff Show More